Skip to content

Commit 9c50390

Browse files
feat(preview): explain browser host setup instead of dropping Chrome's sandbox
The shared headless browser cannot start on Ubuntu 23.10+, where AppArmor blocks the user namespace Chrome's sandbox needs, or in images without Chrome's libraries. HTML previews used to retry without the sandbox and remember it; server tabs just failed. Both now keep the sandbox unless the operator sets T3CODE_SERVER_BROWSER_SANDBOX=0, and a failed launch names the missing setup: Chrome's sandbox abort becomes the AppArmor steps, and `ldd` names missing libraries. Agents get it as the tool error; viewers get a 4503 close and stop retrying, on web and mobile. The remote access guide gains a Browser host setup section with the profile and the apt packages. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent b1b659a commit 9c50390

13 files changed

Lines changed: 377 additions & 77 deletions

File tree

‎apps/mobile/src/features/browser/preview-stream.browser.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
createPreviewFramePainter,
33
createPreviewStreamClient,
4+
previewStreamHostSetupMessage,
45
previewStreamModifiers,
56
type PreviewStreamClient,
67
type PreviewStreamControl,
@@ -233,6 +234,8 @@ export function start(configuration: PreviewStreamConfiguration) {
233234
},
234235
onUnauthorized: () => post({ type: "unauthorized" }),
235236
onGone: () => post({ type: "gone" }),
237+
onHostSetup: (setup) =>
238+
post({ type: "status", status: "error", detail: previewStreamHostSetupMessage(setup) }),
236239
},
237240
);
238241
client = next;

‎apps/server/src/htmlRender/HtmlRender.ts‎

Lines changed: 19 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { ThreadId } from "@t3tools/contracts";
2-
import { HostProcessUserId } from "@t3tools/shared/hostProcess";
2+
import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
33
import {
44
clampHtmlRenderHeight,
55
HTML_RENDER_COLUMN_WIDTH,
@@ -32,6 +32,7 @@ import { createAttachmentId } from "../attachmentStore.ts";
3232
import * as ServerConfig from "../config.ts";
3333
import * as HeadlessChrome from "./headlessChrome.ts";
3434
import * as PreviewBrowser from "../preview/PreviewBrowser.ts";
35+
import * as PreviewBrowserHost from "../preview/PreviewBrowserHost.ts";
3536

3637
const MIB = 1024 * 1024;
3738
const MAX_IMAGE_BYTES = 10 * MIB;
@@ -118,6 +119,7 @@ export class HtmlRender extends Context.Service<
118119
| PreviewBrowser.PreviewBrowserInstallError
119120
| PreviewBrowser.PreviewBrowserInstallingError
120121
| PreviewBrowser.PreviewBrowserUnsupportedError
122+
| PreviewBrowserHost.PreviewBrowserHostError
121123
| HeadlessChrome.HtmlRenderBrowserError
122124
>;
123125
}
@@ -336,40 +338,24 @@ const make = Effect.gen(function* () {
336338
FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner
337339
>();
338340
const browsers = yield* Semaphore.make(MAX_CONCURRENT_BROWSERS);
339-
// Chrome refuses its sandbox as root. Other hosts that cannot provide one
340-
// (Ubuntu 23.10+ AppArmor) are learned from the first launch and remembered.
341-
let noSandbox = (yield* HostProcessUserId) === 0;
341+
// Chrome's sandbox stays on unless the operator explicitly turns it off.
342+
// Chrome also refuses it as root, where that opt-out is the only way to run.
343+
const noSandbox = PreviewBrowserHost.sandboxDisabled(yield* HostProcessEnvironment);
342344

345+
/** Runs one browser launch; a host that cannot start it gets setup steps instead. */
343346
const launching = <A>(
344-
run: (
345-
noSandbox: boolean,
346-
) => Effect.Effect<
347-
A,
348-
HeadlessChrome.HtmlRenderBrowserError | HeadlessChrome.HtmlRenderSandboxUnavailableError
349-
>,
347+
executable: string,
348+
run: (noSandbox: boolean) => Effect.Effect<A, HeadlessChrome.HtmlRenderBrowserError>,
350349
) =>
351350
browsers.withPermits(1)(
352-
// Read once a permit is held, so a queued call sees a fallback learned meanwhile.
353-
Effect.suspend(() => run(noSandbox)).pipe(
354-
Effect.catchTag("HtmlRenderSandboxUnavailableError", () =>
355-
Effect.logInfo(
356-
"Chrome's sandbox is unavailable on this host; launching it without one.",
357-
).pipe(
358-
Effect.andThen(
359-
Effect.sync(() => {
360-
noSandbox = true;
361-
}),
362-
),
363-
Effect.andThen(run(true)),
364-
),
365-
),
366-
Effect.catchTag("HtmlRenderSandboxUnavailableError", (cause) =>
367-
Effect.fail(
368-
new HeadlessChrome.HtmlRenderBrowserError({
369-
reason: "the browser has no sandbox",
370-
cause,
371-
}),
372-
),
351+
run(noSandbox).pipe(
352+
Effect.catchTag("HtmlRenderBrowserError", (error) =>
353+
error.output === undefined
354+
? Effect.fail(error)
355+
: PreviewBrowserHost.diagnoseLaunchFailure({ executable, output: error.output }).pipe(
356+
Effect.provideContext(services),
357+
Effect.flatMap((hostError) => Effect.fail(hostError ?? error)),
358+
),
373359
),
374360
),
375361
);
@@ -379,7 +365,7 @@ const make = Effect.gen(function* () {
379365
Effect.gen(function* () {
380366
const executable = yield* previewBrowser.installed;
381367
if (Option.isNone(executable)) return undefined;
382-
const heights = yield* launching((noSandbox) =>
368+
const heights = yield* launching(executable.value, (noSandbox) =>
383369
HeadlessChrome.measureHtmlHeights({
384370
executable: executable.value,
385371
noSandbox,
@@ -471,7 +457,7 @@ const make = Effect.gen(function* () {
471457
appearance,
472458
HTML_RENDER_MEASURE_FONTS,
473459
);
474-
const screenshot = yield* launching((noSandbox) =>
460+
const screenshot = yield* launching(executable, (noSandbox) =>
475461
HeadlessChrome.captureHtmlScreenshot({
476462
executable,
477463
noSandbox,

‎apps/server/src/htmlRender/headlessChrome.ts‎

Lines changed: 15 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -14,24 +14,19 @@ import { publicProxy } from "./publicProxy.ts";
1414

1515
export class HtmlRenderBrowserError extends Schema.TaggedError<HtmlRenderBrowserError>()(
1616
"HtmlRenderBrowserError",
17-
{ reason: Schema.String, cause: Schema.optional(Schema.Defect()) },
17+
{
18+
reason: Schema.String,
19+
cause: Schema.optional(Schema.Defect()),
20+
/** The end of the browser's stderr when it exited, for diagnosing host setup. */
21+
output: Schema.optional(Schema.String),
22+
},
1823
) {
1924
override get message(): string {
2025
return `Headless Chrome could not render the page: ${this.reason}.`;
2126
}
2227
}
2328

24-
/** The browser died at startup because this host cannot give it a sandbox. */
25-
export class HtmlRenderSandboxUnavailableError extends Schema.TaggedError<HtmlRenderSandboxUnavailableError>()(
26-
"HtmlRenderSandboxUnavailableError",
27-
{},
28-
) {
29-
override get message(): string {
30-
return "Chrome's sandbox is unavailable on this host.";
31-
}
32-
}
33-
34-
type BrowserFailure = HtmlRenderBrowserError | HtmlRenderSandboxUnavailableError;
29+
type BrowserFailure = HtmlRenderBrowserError;
3530

3631
export interface ConsoleMessage {
3732
readonly level: "log" | "info" | "warning" | "error";
@@ -67,9 +62,6 @@ const textEncoder = new TextEncoder();
6762
*/
6863
const pageBody = (html: string) =>
6964
Buffer.from(Buffer.from(html, "utf8").toString("base64"), "latin1");
70-
// What Chrome prints before aborting when it cannot sandbox itself, e.g. on
71-
// Ubuntu 23.10+ where AppArmor restricts unprivileged user namespaces.
72-
const NO_SANDBOX_SIGNATURE = "No usable sandbox";
7365

7466
const CdpMessage = Schema.fromJsonString(
7567
Schema.Struct({
@@ -239,13 +231,11 @@ const launchBrowser = Effect.fnUntraced(function* (input: {
239231
);
240232

241233
let stderrTail = "";
242-
let sandboxUnavailable = false;
243234
const stderrReader = yield* child.stderr.pipe(
244235
Stream.decodeText(),
245236
Stream.runForEach((text) =>
246237
Effect.sync(() => {
247-
stderrTail = (stderrTail + text).slice(-1_024);
248-
if (stderrTail.includes(NO_SANDBOX_SIGNATURE)) sandboxUnavailable = true;
238+
stderrTail = (stderrTail + text).slice(-2_048);
249239
}),
250240
),
251241
Effect.ignore,
@@ -340,13 +330,14 @@ const launchBrowser = Effect.fnUntraced(function* (input: {
340330
return Effect.void;
341331
};
342332

343-
// The pipe closes when the browser exits. A sandbox abort says why on
344-
// stderr, which may still be draining, so give it a moment.
333+
// The pipe closes when the browser exits. A startup abort, such as a missing
334+
// sandbox, says why on stderr, which may still be draining, so give it a moment.
345335
const disconnect = Effect.gen(function* () {
346336
yield* Fiber.await(stderrReader).pipe(Effect.timeout("1 second"), Effect.ignore);
347-
const error = sandboxUnavailable
348-
? new HtmlRenderSandboxUnavailableError()
349-
: new HtmlRenderBrowserError({ reason: "the browser exited unexpectedly" });
337+
const error = new HtmlRenderBrowserError({
338+
reason: "the browser exited unexpectedly",
339+
output: stderrTail,
340+
});
350341
disconnected = error;
351342
const waiters = [...pending.values()];
352343
pending.clear();
@@ -396,8 +387,7 @@ const launchBrowser = Effect.fnUntraced(function* (input: {
396387
return yield* Deferred.await(reply).pipe(
397388
Effect.flatMap(Schema.decodeUnknownEffect(result)),
398389
Effect.mapError((error) =>
399-
error._tag === "HtmlRenderBrowserError" ||
400-
error._tag === "HtmlRenderSandboxUnavailableError"
390+
error._tag === "HtmlRenderBrowserError"
401391
? error
402392
: new HtmlRenderBrowserError({
403393
reason: `${method} returned an unexpected result`,
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { describe, expect, it } from "@effect/vitest";
2+
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
3+
import * as Effect from "effect/Effect";
4+
import * as Sink from "effect/Sink";
5+
import * as Stream from "effect/Stream";
6+
import { ChildProcessSpawner } from "effect/process";
7+
8+
import * as PreviewBrowserHost from "./PreviewBrowserHost.ts";
9+
10+
// What `ldd` printed for the pinned headless shell in a bare debian:trixie-slim container.
11+
const LDD_IN_BARE_DEBIAN = `\tlinux-vdso.so.1 (0x00007ffd2d1f8000)
12+
\tlibdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f0c1b6a1000)
13+
\tlibglib-2.0.so.0 => not found
14+
\tlibnss3.so => not found
15+
\tlibX11.so.6 => not found
16+
\tlibc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f0c1b4c0000)
17+
`;
18+
19+
// Chrome's own abort on Ubuntu 26.04 with unprivileged user namespaces restricted.
20+
const SANDBOX_ABORT =
21+
"[1005/163106.285700:FATAL:content/browser/zygote_host/zygote_host_impl_linux.cc:129] No usable sandbox! If you are running on Ubuntu 23.10+ or another Linux distro that has disabled unprivileged user namespaces with AppArmor, see https://chromium.googlesource.com/";
22+
23+
const lddReporting = (stdout: string) => {
24+
const commands: Array<string> = [];
25+
const spawner = ChildProcessSpawner.make((command) =>
26+
Effect.sync(() => {
27+
const { command: name, args } = command as unknown as {
28+
readonly command: string;
29+
readonly args: ReadonlyArray<string>;
30+
};
31+
commands.push([name, ...args].join(" "));
32+
return ChildProcessSpawner.makeHandle({
33+
pid: ChildProcessSpawner.ProcessId(1),
34+
exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
35+
isRunning: Effect.succeed(false),
36+
kill: () => Effect.void,
37+
unref: Effect.succeed(Effect.void),
38+
stdin: Sink.drain,
39+
stdout: Stream.make(new TextEncoder().encode(stdout)),
40+
stderr: Stream.empty,
41+
all: Stream.empty,
42+
getInputFd: () => Sink.drain,
43+
getOutputFd: () => Stream.empty,
44+
});
45+
}),
46+
);
47+
return { spawner, commands };
48+
};
49+
50+
const diagnose = (input: { platform: NodeJS.Platform; output: string; ldd: string }) => {
51+
const { spawner, commands } = lddReporting(input.ldd);
52+
return PreviewBrowserHost.diagnoseLaunchFailure({
53+
executable: "/home/me/.t3/tools/chrome-headless-shell/linux64/154/chrome-headless-shell",
54+
output: input.output,
55+
}).pipe(
56+
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
57+
Effect.provideService(HostProcessPlatform, input.platform),
58+
Effect.map((error) => ({ error, commands })),
59+
);
60+
};
61+
62+
describe("diagnoseLaunchFailure", () => {
63+
it.effect("turns Chrome's sandbox abort into the AppArmor steps", () =>
64+
Effect.gen(function* () {
65+
const { error, commands } = yield* diagnose({
66+
platform: "linux",
67+
output: SANDBOX_ABORT,
68+
ldd: "",
69+
});
70+
expect(error?._tag).toBe("PreviewBrowserSandboxError");
71+
expect(error?.message).toContain("AppArmor");
72+
expect(error?.message).toContain("T3CODE_SERVER_BROWSER_SANDBOX=0");
73+
expect(commands).toEqual([]);
74+
}),
75+
);
76+
77+
it.effect("names every library the loader cannot find", () =>
78+
Effect.gen(function* () {
79+
const { error } = yield* diagnose({
80+
platform: "linux",
81+
output: "error while loading shared libraries: libglib-2.0.so.0",
82+
ldd: LDD_IN_BARE_DEBIAN,
83+
});
84+
expect(error).toMatchObject({
85+
_tag: "PreviewBrowserLibrariesError",
86+
libraries: ["libglib-2.0.so.0", "libnss3.so", "libX11.so.6"],
87+
});
88+
expect(error?.message).toContain("apt-get install");
89+
}),
90+
);
91+
92+
it.effect("leaves other failures alone", () =>
93+
Effect.gen(function* () {
94+
const linux = yield* diagnose({
95+
platform: "linux",
96+
output: "crashed",
97+
ldd: "\tlibc.so.6 => /lib/libc.so.6\n",
98+
});
99+
expect(linux.error).toBeUndefined();
100+
const mac = yield* diagnose({ platform: "darwin", output: "crashed", ldd: "" });
101+
expect(mac.error).toBeUndefined();
102+
expect(mac.commands).toEqual([]);
103+
}),
104+
);
105+
});
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* Why the shared headless browser cannot start on this host, in terms the
3+
* person running the server can act on. T3 never turns Chrome's sandbox off by
4+
* itself; a host that cannot give it one gets these steps instead, and only the
5+
* operator's explicit `T3CODE_SERVER_BROWSER_SANDBOX=0` launches without it.
6+
*/
7+
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
8+
import * as Effect from "effect/Effect";
9+
import * as Schema from "effect/Schema";
10+
import * as ChildProcess from "effect/process/ChildProcess";
11+
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
12+
13+
const GUIDE =
14+
"https://github.com/pingdotgg/t3code/blob/main/docs/user/remote-access.md#browser-host-setup";
15+
16+
/** What Chrome prints before aborting when it cannot sandbox itself. */
17+
export const NO_SANDBOX_SIGNATURE = "No usable sandbox";
18+
19+
/** Whether an operator explicitly allowed Chrome to run without its sandbox. */
20+
export const sandboxDisabled = (env: Readonly<Record<string, string | undefined>>) =>
21+
env.T3CODE_SERVER_BROWSER_SANDBOX === "0";
22+
23+
export class PreviewBrowserSandboxError extends Schema.TaggedError<PreviewBrowserSandboxError>()(
24+
"PreviewBrowserSandboxError",
25+
{},
26+
) {
27+
override get message(): string {
28+
return [
29+
"T3's headless browser needs Chrome's sandbox, which this host blocks.",
30+
"On Ubuntu 23.10 and later, AppArmor stops unprivileged programs from creating the user namespace it uses.",
31+
`Allow it once with the AppArmor profile in ${GUIDE}, or set T3CODE_SERVER_BROWSER_SANDBOX=0 to run without the sandbox.`,
32+
].join(" ");
33+
}
34+
}
35+
36+
export class PreviewBrowserLibrariesError extends Schema.TaggedError<PreviewBrowserLibrariesError>()(
37+
"PreviewBrowserLibrariesError",
38+
{ libraries: Schema.Array(Schema.String) },
39+
) {
40+
override get message(): string {
41+
return [
42+
`T3's headless browser cannot start because this host is missing ${this.libraries.join(", ")}.`,
43+
`Install Chrome's system libraries (on Debian or Ubuntu: ${DEBIAN_PACKAGES}), as described in ${GUIDE}.`,
44+
].join(" ");
45+
}
46+
}
47+
48+
export type PreviewBrowserHostError = PreviewBrowserSandboxError | PreviewBrowserLibrariesError;
49+
50+
/** Chrome's Debian dependencies that minimal images leave out, from the headless shell's deb.deps. */
51+
const DEBIAN_PACKAGES =
52+
"sudo apt-get install libnss3 libglib2.0-0 libatk1.0-0 libatk-bridge2.0-0 libatspi2.0-0 libdbus-1-3 libx11-6 libxcb1 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxrandr2 libxkbcommon0 libgbm1 libasound2 libexpat1";
53+
54+
const MISSING_LIBRARY = /^\s*(\S+) => not found$/gm;
55+
56+
/**
57+
* After a launch fails, names the host setup it is missing: the sandbox, from
58+
* Chrome's own abort message, or shared libraries, from `ldd`. Undefined when
59+
* neither explains it. Linux only; other hosts never need either.
60+
*/
61+
export const diagnoseLaunchFailure = Effect.fn("PreviewBrowserHost.diagnoseLaunchFailure")(
62+
function* (input: { readonly executable: string; readonly output: string }) {
63+
if (input.output.includes(NO_SANDBOX_SIGNATURE)) return new PreviewBrowserSandboxError();
64+
if ((yield* HostProcessPlatform) !== "linux") return undefined;
65+
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
66+
const report = yield* spawner
67+
.string(ChildProcess.make("ldd", [input.executable], { stdin: "ignore", stderr: "ignore" }))
68+
.pipe(
69+
Effect.timeout("5 seconds"),
70+
Effect.orElseSucceed(() => ""),
71+
);
72+
const libraries = [...report.matchAll(MISSING_LIBRARY)].map((match) => match[1]!);
73+
return libraries.length === 0 ? undefined : new PreviewBrowserLibrariesError({ libraries });
74+
},
75+
);

0 commit comments

Comments
 (0)