Skip to content

Commit ebab39f

Browse files
feat(server): t3 browser setup fixes the host for the shared browser
Browser host errors named a docs page. Now every one of them names a command that fixes it, rendered for how the server was launched: `sudo npx t3 browser setup`, or with PATH carried through when Node is installed only for the user, since sudo drops it. The command installs the AppArmor profile that allows Chrome's sandbox on Ubuntu 23.10+, and installs any libraries the installed browser cannot load, picking each package's t64 name where apt only offers that. Without root it says what it would change and prints the sudo line. The server also checks at startup, without root, and logs the same line before anyone opens a tab. Viewers get the command in the stream's close reason. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 4b79be9 commit ebab39f

11 files changed

Lines changed: 432 additions & 55 deletions

File tree

‎apps/server/src/binCli.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import packageJson from "../package.json" with { type: "json" };
1010
import { acpMcpBridgeCommand, acpMcpCallCommand } from "./cli/acpMcpBridge.ts";
1111
import { authCommand } from "./cli/auth.ts";
1212
import { appCommand } from "./cli/app.ts";
13+
import { browserCommand } from "./cli/browser.ts";
1314
import { connectCommand } from "./cli/connect.ts";
1415
import { pairCommand } from "./cli/pair.ts";
1516
import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
@@ -71,6 +72,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) =>
7172
appCommand,
7273
pairCommand,
7374
authCommand,
75+
browserCommand,
7476
projectCommand,
7577
serviceCommand,
7678
updateCommand,

‎apps/server/src/cli/browser.ts‎

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
/**
2+
* `t3 browser setup` - prepares a Linux host for T3's headless browser, which
3+
* server browser tabs and HTML render previews share. It is the fix every
4+
* browser host error names, so it does the whole job in one run:
5+
*
6+
* - installs the AppArmor profile that lets Chrome's sandbox run where the host
7+
* restricts unprivileged user namespaces (Ubuntu 23.10+), and
8+
* - installs the Debian packages for any libraries the browser cannot load.
9+
*
10+
* Both need root. Without it, the command prints what it would change and the
11+
* `sudo` line to run. It is safe to run again; it skips what is already done.
12+
*/
13+
import {
14+
HostProcessEnvironment,
15+
HostProcessPlatform,
16+
HostProcessUserId,
17+
} from "@t3tools/shared/hostProcess";
18+
import * as Console from "effect/Console";
19+
import * as Effect from "effect/Effect";
20+
import * as FileSystem from "effect/FileSystem";
21+
import * as Option from "effect/Option";
22+
import * as Path from "effect/Path";
23+
import * as Schema from "effect/Schema";
24+
import { Command } from "effect/cli";
25+
import * as ChildProcess from "effect/process/ChildProcess";
26+
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
27+
28+
import * as PreviewBrowserHost from "../preview/PreviewBrowserHost.ts";
29+
import { resolveBaseDir } from "../os-jank.ts";
30+
import { baseDirFlag } from "./config.ts";
31+
import { resolveRootCliCommand } from "./invocation.ts";
32+
33+
export class BrowserSetupStepError extends Schema.TaggedError<BrowserSetupStepError>()(
34+
"BrowserSetupStepError",
35+
{ step: Schema.String, detail: Schema.String },
36+
) {
37+
override get message(): string {
38+
return `Could not ${this.step}: ${this.detail}`;
39+
}
40+
}
41+
42+
/** Runs one setup command, streaming its output, and fails with its exit code. */
43+
const runStep = Effect.fn("browserSetup.runStep")(function* (
44+
step: string,
45+
command: string,
46+
args: ReadonlyArray<string>,
47+
) {
48+
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
49+
const exitCode = yield* spawner
50+
.exitCode(
51+
ChildProcess.make(command, args, { stdin: "inherit", stdout: "inherit", stderr: "inherit" }),
52+
)
53+
.pipe(Effect.mapError((cause) => new BrowserSetupStepError({ step, detail: String(cause) })));
54+
if (exitCode !== 0) {
55+
return yield* new BrowserSetupStepError({ step, detail: `${command} exited with ${exitCode}` });
56+
}
57+
});
58+
59+
/**
60+
* The T3 home to check. Under `sudo` the process home is root's, so an
61+
* unspecified home falls back to the invoking user's `~/.t3`.
62+
*/
63+
const setupBaseDir = Effect.fn("browserSetup.baseDir")(function* (explicit: Option.Option<string>) {
64+
const env = yield* HostProcessEnvironment;
65+
const raw = Option.getOrUndefined(explicit) ?? env.T3CODE_HOME;
66+
if (raw !== undefined || env.SUDO_USER === undefined) return yield* resolveBaseDir(raw);
67+
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
68+
const entry = yield* spawner
69+
.string(
70+
ChildProcess.make("getent", ["passwd", env.SUDO_USER], { stdin: "ignore", stderr: "ignore" }),
71+
)
72+
.pipe(Effect.orElseSucceed(() => ""));
73+
const home = entry.trim().split(":")[5];
74+
const path = yield* Path.Path;
75+
return home ? path.join(home, ".t3") : yield* resolveBaseDir(undefined);
76+
});
77+
78+
/** Whether apt has an installable candidate for `name`. */
79+
const aptOffers = Effect.fn("browserSetup.aptOffers")(function* (name: string) {
80+
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
81+
const policy = yield* spawner
82+
.string(ChildProcess.make("apt-cache", ["policy", name], { stdin: "ignore", stderr: "ignore" }))
83+
.pipe(Effect.orElseSucceed(() => ""));
84+
const candidate = /Candidate:\s*(\S+)/.exec(policy)?.[1];
85+
return candidate !== undefined && candidate !== "(none)";
86+
});
87+
88+
/** The installed browser in this T3 home, if any, to check its libraries. */
89+
const installedBrowser = Effect.fn("browserSetup.installedBrowser")(function* (baseDir: string) {
90+
const fs = yield* FileSystem.FileSystem;
91+
const path = yield* Path.Path;
92+
const root = path.join(baseDir, "tools", "chrome-headless-shell");
93+
for (const platform of yield* fs.readDirectory(root).pipe(Effect.orElseSucceed(() => []))) {
94+
for (const version of yield* fs
95+
.readDirectory(path.join(root, platform))
96+
.pipe(Effect.orElseSucceed(() => []))) {
97+
const executable = path.join(root, platform, version, "chrome-headless-shell");
98+
if (yield* fs.exists(executable).pipe(Effect.orElseSucceed(() => false))) {
99+
return Option.some(executable);
100+
}
101+
}
102+
}
103+
return Option.none<string>();
104+
});
105+
106+
const browserSetupCommand = Command.make("setup", { baseDir: baseDirFlag }).pipe(
107+
Command.withDescription(
108+
"Set up this Linux host for T3's browser: allow Chrome's sandbox and install its libraries.",
109+
),
110+
Command.withHandler(({ baseDir }) =>
111+
Effect.gen(function* () {
112+
if ((yield* HostProcessPlatform) !== "linux") {
113+
return yield* Console.log("Nothing to set up: T3's browser runs as is on this system.");
114+
}
115+
const fs = yield* FileSystem.FileSystem;
116+
const isRoot = (yield* HostProcessUserId) === 0;
117+
const setupCommand = yield* resolveRootCliCommand(PreviewBrowserHost.SETUP_SUBCOMMAND);
118+
119+
const needsProfile = yield* PreviewBrowserHost.sandboxBlocked;
120+
const browser = yield* installedBrowser(yield* setupBaseDir(baseDir));
121+
const missing = Option.isSome(browser)
122+
? yield* PreviewBrowserHost.missingLibraries(browser.value)
123+
: [];
124+
const hasApt = yield* fs.exists("/usr/bin/apt-get").pipe(Effect.orElseSucceed(() => false));
125+
126+
if (!needsProfile && missing.length === 0) {
127+
return yield* Console.log(
128+
Option.isSome(browser)
129+
? "This host is ready for T3's browser."
130+
: "Chrome's sandbox is allowed here. T3's browser installs on first use; if it then reports missing libraries, run this again.",
131+
);
132+
}
133+
134+
if (!isRoot) {
135+
if (needsProfile) {
136+
yield* Console.log(
137+
`This host blocks the sandbox T3's browser runs in. Setup installs an AppArmor profile at ${PreviewBrowserHost.APPARMOR_PROFILE_PATH} that allows it.`,
138+
);
139+
}
140+
if (missing.length > 0) {
141+
yield* Console.log(`T3's browser is missing ${missing.join(", ")}; setup installs them.`);
142+
}
143+
return yield* Console.log(`\nThis needs root. Run:\n\n ${setupCommand}\n`);
144+
}
145+
146+
if (needsProfile) {
147+
yield* fs
148+
.writeFileString(
149+
PreviewBrowserHost.APPARMOR_PROFILE_PATH,
150+
PreviewBrowserHost.APPARMOR_PROFILE,
151+
)
152+
.pipe(
153+
Effect.mapError(
154+
(cause) =>
155+
new BrowserSetupStepError({
156+
step: `write ${PreviewBrowserHost.APPARMOR_PROFILE_PATH}`,
157+
detail: cause.message,
158+
}),
159+
),
160+
);
161+
yield* runStep("load the AppArmor profile", "apparmor_parser", [
162+
"-r",
163+
PreviewBrowserHost.APPARMOR_PROFILE_PATH,
164+
]);
165+
yield* Console.log("Allowed Chrome's sandbox for T3's browser.");
166+
}
167+
168+
if (missing.length > 0) {
169+
if (!hasApt) {
170+
return yield* Console.log(
171+
`T3's browser is missing ${missing.join(", ")}. Install them with your package manager, then run this again.`,
172+
);
173+
}
174+
yield* runStep("refresh the package lists", "apt-get", ["update"]);
175+
const packages = yield* Effect.forEach(PreviewBrowserHost.DEBIAN_PACKAGES, (names) =>
176+
Effect.findFirst(names, aptOffers).pipe(Effect.map(Option.getOrElse(() => names[0]!))),
177+
);
178+
yield* runStep("install the browser's libraries", "apt-get", [
179+
"install",
180+
"-y",
181+
"--no-install-recommends",
182+
...packages,
183+
]);
184+
yield* Console.log("Installed the browser's libraries.");
185+
}
186+
187+
yield* Console.log("This host is ready for T3's browser.");
188+
}),
189+
),
190+
);
191+
192+
export const browserCommand = Command.make("browser").pipe(
193+
Command.withDescription("Manage T3's headless browser on this host."),
194+
Command.withSubcommands([browserSetupCommand]),
195+
);

‎apps/server/src/cli/invocation.test.ts‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ import * as Effect from "effect/Effect";
1010
import * as FileSystem from "effect/FileSystem";
1111
import * as Path from "effect/Path";
1212

13-
import { formatCliCommand, resolveServerInstallation } from "./invocation.ts";
13+
import {
14+
formatCliCommand,
15+
resolveRootCliCommand,
16+
resolveServerInstallation,
17+
} from "./invocation.ts";
1418

1519
it("formats package runner commands from their cache entry paths", () => {
1620
for (const [entryPath, expected] of [
@@ -98,6 +102,29 @@ it("formats serve suggestions to match the launching command", () => {
98102
);
99103
});
100104

105+
it.effect("keeps a user-installed Node reachable when the command runs under sudo", () =>
106+
Effect.gen(function* () {
107+
const command = (node: string, entry: string) =>
108+
resolveRootCliCommand("browser setup").pipe(
109+
Effect.provideService(HostProcessExecutablePath, node),
110+
Effect.provideService(HostProcessArguments, [node, entry]),
111+
);
112+
const npx = "/home/theo/.npm/_npx/abc/node_modules/t3/dist/bin.mjs";
113+
// sudo's secure_path already has a system Node.
114+
expect(yield* command("/usr/bin/node", npx)).toBe("sudo npx t3 browser setup");
115+
// nvm, fnm, and tarball installs are dropped by sudo's PATH reset.
116+
expect(yield* command("/home/theo/.nvm/versions/node/v24/bin/node", npx)).toBe(
117+
'sudo env "PATH=$PATH" npx t3 browser setup',
118+
);
119+
expect(
120+
yield* command(
121+
"/home/theo/.local/node/bin/node",
122+
"/home/theo/.local/lib/node_modules/t3/dist/bin.mjs",
123+
),
124+
).toBe('sudo env "PATH=$PATH" t3 browser setup');
125+
}),
126+
);
127+
101128
it.layer(NodeServices.layer)("manual server installation ownership", (it) => {
102129
it.effect("recognizes runner caches for both script and executable packages", () =>
103130
Effect.gen(function* () {

‎apps/server/src/cli/invocation.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,3 +145,29 @@ export const resolveCliCommand = (subcommand: string) =>
145145
version: packageJson.version,
146146
}),
147147
);
148+
149+
/**
150+
* `t3 <subcommand>` as root, for setup a person runs once on the host. `sudo`
151+
* resets PATH on most distributions, which drops a user-installed Node (nvm,
152+
* fnm, a tarball) and with it `npx` or a global `t3`, so the command carries
153+
* PATH through unless Node is on root's PATH too.
154+
*/
155+
export const resolveRootCliCommand = (subcommand: string) =>
156+
Effect.gen(function* () {
157+
const command = yield* resolveCliCommand(subcommand);
158+
const executablePath = yield* HostProcessExecutablePath;
159+
const systemNode = ROOT_PATH_DIRECTORIES.some((directory) =>
160+
executablePath.startsWith(`${directory}/`),
161+
);
162+
return systemNode ? `sudo ${command}` : `sudo env "PATH=$PATH" ${command}`;
163+
});
164+
165+
/** Debian and Ubuntu's sudo `secure_path`, minus snap. */
166+
const ROOT_PATH_DIRECTORIES = [
167+
"/usr/local/sbin",
168+
"/usr/local/bin",
169+
"/usr/sbin",
170+
"/usr/bin",
171+
"/sbin",
172+
"/bin",
173+
];

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import type * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
2929

3030
import { resolveAttachmentRelativePath } from "../attachmentPaths.ts";
3131
import { createAttachmentId } from "../attachmentStore.ts";
32+
import { resolveRootCliCommand } from "../cli/invocation.ts";
3233
import * as ServerConfig from "../config.ts";
3334
import * as HeadlessChrome from "./headlessChrome.ts";
3435
import * as PreviewBrowser from "../preview/PreviewBrowser.ts";
@@ -341,6 +342,7 @@ const make = Effect.gen(function* () {
341342
// Chrome's sandbox stays on unless the operator explicitly turns it off.
342343
// Chrome also refuses it as root, where that opt-out is the only way to run.
343344
const noSandbox = PreviewBrowserHost.sandboxDisabled(yield* HostProcessEnvironment);
345+
const setupCommand = yield* resolveRootCliCommand(PreviewBrowserHost.SETUP_SUBCOMMAND);
344346

345347
/** Runs one browser launch; a host that cannot start it gets setup steps instead. */
346348
const launching = <A>(
@@ -352,7 +354,11 @@ const make = Effect.gen(function* () {
352354
Effect.catchTag("HtmlRenderBrowserError", (error) =>
353355
error.output === undefined
354356
? Effect.fail(error)
355-
: PreviewBrowserHost.diagnoseLaunchFailure({ executable, output: error.output }).pipe(
357+
: PreviewBrowserHost.diagnoseLaunchFailure({
358+
executable,
359+
setupCommand,
360+
output: error.output,
361+
}).pipe(
356362
Effect.provideContext(services),
357363
Effect.flatMap((hostError) => Effect.fail(hostError ?? error)),
358364
),

‎apps/server/src/preview/PreviewBrowserHost.test.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ const diagnose = (input: { platform: NodeJS.Platform; output: string; ldd: strin
5252
return PreviewBrowserHost.diagnoseLaunchFailure({
5353
executable: "/home/me/.t3/tools/chrome-headless-shell/linux64/154/chrome-headless-shell",
5454
output: input.output,
55+
setupCommand: "sudo t3 browser setup",
5556
}).pipe(
5657
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
5758
Effect.provideService(HostProcessPlatform, input.platform),
@@ -68,8 +69,7 @@ describe("diagnoseLaunchFailure", () => {
6869
ldd: "",
6970
});
7071
expect(error?._tag).toBe("PreviewBrowserSandboxError");
71-
expect(error?.message).toContain("AppArmor");
72-
expect(error?.message).toContain("T3CODE_SERVER_BROWSER_SANDBOX=0");
72+
expect(error?.message).toContain("Run `sudo t3 browser setup` on the host");
7373
expect(commands).toEqual([]);
7474
}),
7575
);
@@ -85,7 +85,7 @@ describe("diagnoseLaunchFailure", () => {
8585
_tag: "PreviewBrowserLibrariesError",
8686
libraries: ["libglib-2.0.so.0", "libnss3.so", "libX11.so.6"],
8787
});
88-
expect(error?.message).toContain("apt-get install");
88+
expect(error?.message).toContain("Run `sudo t3 browser setup` on the host");
8989
}),
9090
);
9191

0 commit comments

Comments
 (0)