Skip to content
Closed
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
5 changes: 3 additions & 2 deletions apps/server/src/cli/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { Command } from "effect/cli";
import * as ChildProcess from "effect/process/ChildProcess";
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";

import * as PreviewBrowser from "../preview/PreviewBrowser.ts";
import * as PreviewBrowserHost from "../preview/PreviewBrowserHost.ts";
import { resolveBaseDir } from "../os-jank.ts";
import { baseDirFlag } from "./config.ts";
Expand Down Expand Up @@ -67,12 +68,12 @@ const aptOffers = Effect.fn("browserSetup.aptOffers")(function* (name: string) {
const installedBrowser = Effect.fn("browserSetup.installedBrowser")(function* (baseDir: string) {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = path.join(baseDir, "tools", "chrome-headless-shell");
const root = path.join(baseDir, "tools", "chrome-for-testing");
for (const platform of yield* fs.readDirectory(root).pipe(Effect.orElseSucceed(() => []))) {
for (const version of yield* fs
.readDirectory(path.join(root, platform))
.pipe(Effect.orElseSucceed(() => []))) {
const executable = path.join(root, platform, version, "chrome-headless-shell");
const executable = path.join(root, platform, version, PreviewBrowser.executableName("linux"));
if (yield* fs.exists(executable).pipe(Effect.orElseSucceed(() => false))) {
return Option.some(executable);
}
Expand Down
58 changes: 51 additions & 7 deletions apps/server/src/preview/PreviewBrowser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,8 @@ import * as PreviewBrowser from "./PreviewBrowser.ts";
// NTFS cannot enforce POSIX modes; choose the host platform.
const hostPlatform: NodeJS.Platform =
HostProcess.Platform.defaultValue() === "win32" ? "win32" : "linux";
const executableName =
hostPlatform === "win32" ? "chrome-headless-shell.exe" : "chrome-headless-shell";
const ROOT = "chrome-headless-shell-fixture/";
const executableName = PreviewBrowser.executableName(hostPlatform);
const ROOT = "chrome-fixture/";

const makeZip = (
entries: ReadonlyArray<{ readonly name: string; readonly data?: string; readonly mode: number }>,
Expand Down Expand Up @@ -95,7 +94,7 @@ const makeHarness = Effect.fn("test.makePreviewBrowser")(function* (
: {
version: "1.2.3",
platform: "fixture",
url: "https://storage.googleapis.com/chrome-headless-shell-fixture.zip",
url: "https://storage.googleapis.com/chrome-fixture.zip",
bytes: archive.byteLength,
sha256,
},
Expand All @@ -116,14 +115,16 @@ const makeHarness = Effect.fn("test.makePreviewBrowser")(function* (
),
),
);
const installRoot = path.join(baseDir, "tools", "chrome-headless-shell", "fixture");
const installRoot = path.join(baseDir, "tools", "chrome-for-testing", "fixture");
return { browser, fs, path, baseDir, installRoot, requests };
});

it.layer(NodeServices.layer)("PreviewBrowser", (it) => {
it.effect("installs a verified download with its file modes and then reuses it", () =>
Effect.gen(function* () {
const { browser, fs, path, installRoot, requests } = yield* makeHarness();
const { browser, fs, path, baseDir, installRoot, requests } = yield* makeHarness();
const headlessShell = path.join(baseDir, "tools", "chrome-headless-shell");
yield* fs.makeDirectory(path.join(headlessShell, "linux64", "156"), { recursive: true });
yield* fs.makeDirectory(path.join(installRoot, "1.0.0"), { recursive: true });
const active = path.join(installRoot, ".install-other");
const abandoned = path.join(installRoot, ".install-abandoned");
Expand Down Expand Up @@ -152,6 +153,49 @@ it.layer(NodeServices.layer)("PreviewBrowser", (it) => {
expect(yield* browser.executable).toBe(executable);
expect(yield* browser.installed).toEqual(Option.some(executable));
expect(requests).toHaveLength(1);
expect(yield* fs.exists(headlessShell)).toBe(false);
}),
);

// macOS framework bundles link inside themselves; links are written as POSIX links.
it.effect.skipIf(hostPlatform === "win32")(
"installs links that stay inside the browser's directory",
() =>
Effect.gen(function* () {
const { browser, fs, path, installRoot } = yield* makeHarness({
archive: makeZip([
{ name: `${ROOT}${executableName}`, data: "#!/bin/sh\n", mode: 0o100755 },
{ name: `${ROOT}Versions/A/library`, data: "library", mode: 0o100644 },
{ name: `${ROOT}Versions/Current`, data: "A", mode: 0o120755 },
{ name: `${ROOT}library`, data: "Versions/Current/library", mode: 0o120755 },
]),
});

yield* browser.executable;

const version = path.join(installRoot, "1.2.3");
expect(yield* fs.readLink(path.join(version, "library"))).toBe("Versions/Current/library");
expect(yield* fs.readFileString(path.join(version, "library"))).toBe("library");
}),
);

it.effect.each([
{ name: "a parent directory", link: "../../escape" },
{ name: "an absolute path", link: "/etc" },
{ name: "nothing", link: "" },
])("rejects an archive with a link to $name", (testCase) =>
Effect.gen(function* () {
const { browser, fs, installRoot } = yield* makeHarness({
archive: makeZip([
{ name: `${ROOT}${executableName}`, data: "#!/bin/sh\n", mode: 0o100755 },
{ name: `${ROOT}link`, data: testCase.link, mode: 0o120755 },
]),
});

const error = yield* browser.executable.pipe(Effect.flip);

expect(error._tag).toBe("PreviewBrowserInstallError");
expect(yield* fs.readDirectory(installRoot)).toEqual([]);
}),
);

Expand Down Expand Up @@ -193,7 +237,7 @@ it.layer(NodeServices.layer)("PreviewBrowser", (it) => {

expect(error._tag).toBe("PreviewBrowserInstallError");
expect(yield* fs.readDirectory(installRoot)).toEqual([]);
expect(yield* fs.exists(path.join(baseDir, "tools", "chrome-headless-shell", "escape"))).toBe(
expect(yield* fs.exists(path.join(baseDir, "tools", "chrome-for-testing", "escape"))).toBe(
false,
);
}),
Expand Down
91 changes: 61 additions & 30 deletions apps/server/src/preview/PreviewBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,38 +21,46 @@ import * as NodeCrypto from "node:crypto";
import * as ServerConfig from "../config.ts";
import { openZipArchive } from "../zipArchive.ts";

// Update the version, sizes and hashes from each chrome-headless-shell zip listed at
// Update from each `chrome` zip (full Chrome: only it can capture a tab for recordings) listed at
// https://googlechromelabs.github.io/chrome-for-testing/known-good-versions-with-downloads.json
// Never pin below 156.0.8078.70: the shell has no CRLSet and older SPKI blocklists trust
// compromised eMudhra intermediates (crrev.com/c/8627687).
// Never pin below 156.0.8078.70: Chrome for Testing has no CRLSet and older SPKI blocklists
// trust compromised eMudhra intermediates (crrev.com/c/8627687).
const VERSION = "156.0.8078.70";
const ARCHIVES = {
linux64: {
bytes: 126_646_931,
sha256: "a313da592261ff1e342c1e507d315f25b1809006c0508f77ce86151d464d4ccb",
bytes: 201_035_256,
sha256: "baac330433bb105012b7429ad77d4ba3d4ec3768810008ce9257d5d930c1b0cd",
},
"linux-arm64": {
bytes: 126_749_876,
sha256: "dca11213d2e0b1db52141edd9f92ec423fd52ca25715846790d84fa1d5d16e8a",
bytes: 200_845_435,
sha256: "46dce94a0790d177927044e83ee7e0f571bc22e90e6fafe21fce5c1e248bc865",
},
"mac-arm64": {
bytes: 103_901_293,
sha256: "1f848385611f2e58122e68969489712661880fc9a92c9dd20428099f9998ba7b",
bytes: 194_910_526,
sha256: "1070e2acc6fec1b80aac9215c9b83a381334a8c556df941e5146efb816121adb",
},
"mac-x64": {
bytes: 109_387_968,
sha256: "ab4cc2949f88439e2241b2712c49268f24982f2d25af87960466cc22bdc2e6e1",
bytes: 205_543_880,
sha256: "3218f6e802588632f2f8055b90035b306cad48874daf6067330e0f012afdb7ab",
},
win32: {
bytes: 119_029_359,
sha256: "37dc9a03f37fd79637fec9f26042bf32f04cfe5b83749d980eb1e7d1a429d47c",
bytes: 188_579_460,
sha256: "853955e981b07b8c44dfc77e658f6bfc806868a74176a44900aecda65ea4c26e",
},
win64: {
bytes: 126_214_071,
sha256: "41c6aa97046d05d015b817a95f7b1512c3a1a41b18364f8da46478ae520400b1",
bytes: 209_092_934,
sha256: "8ab65752ca6106bb8a5bf3afaa710ab4f62a57c27ed39db4977422c929fce0b3",
},
} as const;

/** The browser's executable inside the archive's top directory. */
export const executableName = (platform: NodeJS.Platform) =>
platform === "win32"
? "chrome.exe"
: platform === "darwin"
? "Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"
: "chrome";

const chromePlatform = (platform: NodeJS.Platform, arch: NodeJS.Architecture) => {
switch (platform) {
case "linux":
Expand Down Expand Up @@ -85,7 +93,7 @@ const previewBrowserRelease = (
: {
version: VERSION,
platform: chrome,
url: `https://storage.googleapis.com/chrome-for-testing-public/${VERSION}/${chrome}/chrome-headless-shell-${chrome}.zip`,
url: `https://storage.googleapis.com/chrome-for-testing-public/${VERSION}/${chrome}/chrome-${chrome}.zip`,
...ARCHIVES[chrome],
};
};
Expand Down Expand Up @@ -119,7 +127,7 @@ export class PreviewBrowserUnsupportedError extends Schema.TaggedError<PreviewBr
{ platform: Schema.String, arch: Schema.String },
) {
override get message(): string {
return `T3's headless browser is not available on ${this.platform}-${this.arch}: Chrome for Testing has no headless shell for it.`;
return `T3's headless browser is not available on ${this.platform}-${this.arch}: Chrome for Testing has no build for it.`;
}
}

Expand Down Expand Up @@ -170,24 +178,23 @@ export const makePreviewBrowser = Effect.fn("PreviewBrowser.make")(function* (
const release =
options.release === undefined ? previewBrowserRelease(platform, arch) : options.release;
const wait = options.wait ?? "45 seconds";
const toolsDir = path.join(options.baseDir, "tools");
const installRoot = path.join(
options.baseDir,
"tools",
"chrome-headless-shell",
toolsDir,
"chrome-for-testing",
release?.platform ?? `${platform}-${arch}`,
);
const executableName =
platform === "win32" ? "chrome-headless-shell.exe" : "chrome-headless-shell";
const binary = executableName(platform);
const gate = yield* Semaphore.make(1);
let state: InstallState = { _tag: "idle" };
const progress = { downloadedBytes: 0, unpacking: false };

const installedExecutable = (release: PreviewBrowserRelease) => {
const executable = path.join(installRoot, release.version, executableName);
return fs.stat(executable).pipe(
const installed = path.join(installRoot, release.version, binary);
return fs.stat(installed).pipe(
Effect.map((info) =>
info.type === "File" && (platform === "win32" || (info.mode & 0o111) !== 0)
? Option.some(executable)
? Option.some(installed)
: Option.none<string>(),
),
Effect.orElseSucceed(() => Option.none<string>()),
Expand Down Expand Up @@ -251,7 +258,7 @@ export const makePreviewBrowser = Effect.fn("PreviewBrowser.make")(function* (
const unsafe = new PreviewBrowserInstallError({
detail: "The archive contains an unexpected or unsafe entry.",
});
const root = `chrome-headless-shell-${release.platform}/`;
const root = `chrome-${release.platform}/`;
yield* fs.makeDirectory(destination);
let foundExecutable = false;
for (;;) {
Expand All @@ -260,14 +267,15 @@ export const makePreviewBrowser = Effect.fn("PreviewBrowser.make")(function* (
const unixMode = entry.externalFileAttributes >>> 16;
const unixType = unixMode & 0o170000;
const isDirectory = entry.fileName.endsWith("/");
const isSymlink = unixType === 0o120000;
const relative = entry.fileName.startsWith(root) ? entry.fileName.slice(root.length) : null;
const target = relative === null ? null : path.resolve(destination, relative);
if (
relative === null ||
target === null ||
(relative !== "" && !target.startsWith(`${destination}${path.sep}`)) ||
relative.split("/").includes("..") ||
(unixType !== 0 && unixType !== (isDirectory ? 0o040000 : 0o100000)) ||
(unixType !== 0 && !isSymlink && unixType !== (isDirectory ? 0o040000 : 0o100000)) ||
(entry.generalPurposeBitFlag & 1) !== 0 ||
![0, 8].includes(entry.compressionMethod)
) {
Expand All @@ -278,6 +286,21 @@ export const makePreviewBrowser = Effect.fn("PreviewBrowser.make")(function* (
continue;
}
yield* fs.makeDirectory(path.dirname(target), { recursive: true });
if (isSymlink) {
// macOS framework bundles link within themselves (Versions/Current); nothing may point out.
const linked = yield* Effect.gen(function* () {
const readable = yield* archive.streamEntry(entry);
return yield* EffectNodeStream.fromReadable<Uint8Array, PreviewBrowserInstallError>({
evaluate: () => readable,
onError: wrapFailure("Could not unpack the browser."),
}).pipe(Stream.decodeText(), Stream.mkString);
}).pipe(Effect.scoped);
if (linked === "" || linked.startsWith("/") || linked.split("/").includes("..")) {
return yield* unsafe;
}
yield* fs.symlink(linked, target);
continue;
}
yield* Effect.gen(function* () {
const readable = yield* archive.streamEntry(entry);
yield* EffectNodeStream.fromReadable<Uint8Array, PreviewBrowserInstallError>({
Expand All @@ -289,11 +312,11 @@ export const makePreviewBrowser = Effect.fn("PreviewBrowser.make")(function* (
if (platform !== "win32" && (unixMode & 0o777) !== 0) {
yield* fs.chmod(target, unixMode & 0o777);
}
if (relative === executableName) foundExecutable = true;
if (relative === binary) foundExecutable = true;
}
if (!foundExecutable) {
return yield* new PreviewBrowserInstallError({
detail: "The archive does not contain chrome-headless-shell.",
detail: "The archive does not contain Chrome.",
});
}
},
Expand Down Expand Up @@ -345,7 +368,15 @@ export const makePreviewBrowser = Effect.fn("PreviewBrowser.make")(function* (
),
{ discard: true },
);
return path.join(installRoot, release.version, executableName);
// Earlier versions installed chrome-headless-shell, which this browser replaces.
yield* fs
.remove(path.join(toolsDir, "chrome-headless-shell"), { recursive: true, force: true })
.pipe(
Effect.catch((cause) =>
Effect.logWarning("Could not remove the old headless shell.", { cause }),
),
);
return path.join(installRoot, release.version, binary);
},
Effect.mapError(wrapFailure("Could not save the browser in T3's home directory.")),
);
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/preview/PreviewBrowserHost.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const LDD_IN_BARE_DEBIAN = `\tlinux-vdso.so.1 (0x00007ffd2d1f8000)
// Chrome's own abort on Ubuntu 26.04 with unprivileged user namespaces restricted.

const LOADER_ERROR =
"chrome-headless-shell: error while loading shared libraries: libglib-2.0.so.0: cannot open shared object file: No such file or directory";
"chrome: error while loading shared libraries: libglib-2.0.so.0: cannot open shared object file: No such file or directory";

const hostReporting = (output: { readonly version: string; readonly ldd: string }) => {
const commands: Array<string> = [];
Expand Down Expand Up @@ -60,7 +60,7 @@ const diagnose = (input: {
version: input.version ?? LOADER_ERROR,
});
return PreviewBrowserHost.diagnoseLaunchFailure({
executable: "/home/me/.t3/tools/chrome-headless-shell/linux64/154/chrome-headless-shell",
executable: "/home/me/.t3/tools/chrome-for-testing/linux64/154/chrome",
output: input.output,
setupCommand: "sudo t3 browser setup",
}).pipe(
Expand Down
10 changes: 7 additions & 3 deletions apps/server/src/preview/PreviewBrowserHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,27 @@ export const sandboxDisabled = (env: Readonly<Record<string, string | undefined>

/** Set on Ubuntu 23.10+: unprivileged user namespaces need an AppArmor profile. */
const USERNS_RESTRICTION = "/proc/sys/kernel/apparmor_restrict_unprivileged_userns";
export const APPARMOR_PROFILE_PATH = "/etc/apparmor.d/t3-chrome-headless-shell";
// Named after the browser it allows: hosts set up for the former headless shell need a new profile.
export const APPARMOR_PROFILE_PATH = "/etc/apparmor.d/t3-chrome-for-testing";

/** Match Ubuntu's Chrome profile; unconfined adds nothing beyond userns. */
export const APPARMOR_PROFILE = `# Written by \`t3 browser setup\`: lets T3 Code's headless browser use Chrome's sandbox.
abi <abi/4.0>,
include <tunables/global>

profile t3-chrome-headless-shell /**/tools/chrome-headless-shell/*/*/chrome-headless-shell flags=(unconfined) {
profile t3-chrome-for-testing /**/tools/chrome-for-testing/*/*/chrome flags=(unconfined) {
userns,

include if exists <local/t3-chrome-headless-shell>
include if exists <local/t3-chrome-for-testing>
}
`;

/** Ubuntu 24.04 and Debian 13 use some t64 library names; choose names available through apt. */
export const DEBIAN_PACKAGES: ReadonlyArray<ReadonlyArray<string>> = [
["libnss3"],
["libcairo2"],
["libcups2t64", "libcups2"],
["libpango-1.0-0"],
["libglib2.0-0t64", "libglib2.0-0"],
["libatk1.0-0t64", "libatk1.0-0"],
["libatk-bridge2.0-0t64", "libatk-bridge2.0-0"],
Expand Down
Loading
Loading