scripts/prepare-browser.mjs
Rama main · réplica de lectura del repositorio original.
// Download only reviewed vendor assets. Always extract anew from verified
// archives: a previous/partial dist-native tree is never a trusted cache.
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import {
chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync,
readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync,
writeFileSync,
} from "node:fs";
import { isAbsolute, join, relative, resolve, sep, win32 } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { browserBundlePaths, browserBundleSpec } from "../server/browser-bundle-release.ts";
import { executableTarget } from "./prepare-cloudflared.mjs";
export const BROWSER_LICENSE_FILES = [
"agent-browser-LICENSE.txt", "LICENSE-axe-core.txt", "LICENSE-axe-core-THIRD-PARTY.txt", "README.md",
];
const sourceRoot = fileURLToPath(new URL("../", import.meta.url));
export function targetsForPreparation({ current = false, target, platform = process.platform, arch = process.arch } = {}) {
const targets = target ? [target] : current || platform !== "darwin" ? [`${platform}-${arch}`] : ["darwin-arm64", "darwin-x64"];
for (const value of targets) browserBundleSpec(value);
return targets;
}
export function parsePrepareBrowserArgs(args = [], { platform = process.platform, arch = process.arch } = {}) {
const options = { current: false };
for (let index = 0; index < args.length; index += 1) {
if (args[index] === "--current" && !options.current && !options.target) options.current = true;
else if (args[index] === "--target" && !options.target && !options.current && args[index + 1]) options.target = args[++index];
else throw new Error("Usage: node scripts/prepare-browser.mjs [--current | --target PLATFORM-ARCH]");
}
targetsForPreparation({ ...options, platform, arch });
return options;
}
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
export function verifyAssetBytes(bytes, asset) {
if (bytes.length !== asset.bytes || sha256(bytes) !== asset.sha256) {
throw new Error(`${asset.asset} failed pinned size/SHA-256 verification`);
}
}
function inside(root, value) {
const rel = relative(root, value);
return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
}
/** Preserve vendor symlinks, but never permit one to escape the bundle. */
export function bundleInventory(directory) {
const root = realpathSync(directory);
const result = [];
function visit(parent, prefix = "") {
for (const name of readdirSync(parent).sort()) {
const path = prefix ? `${prefix}/${name}` : name;
if (path === "manifest.json") continue;
const file = join(parent, name);
const stat = lstatSync(file);
if (stat.isSymbolicLink()) {
const target = readlinkSync(file);
if (isAbsolute(target) || !inside(root, resolve(parent, target)) || !inside(root, realpathSync(file))) {
throw new Error(`Browser resource symlink escapes its bundle: ${path}`);
}
result.push({ path, kind: "symlink", target });
} else if (stat.isDirectory()) {
result.push({ path, kind: "directory" });
visit(file, path);
} else if (stat.isFile()) {
result.push({ path, kind: "file", bytes: stat.size, sha256: sha256(readFileSync(file)) });
} else throw new Error(`Unsupported browser resource type: ${path}`);
}
}
visit(root);
return result;
}
function regularFile(file) {
const stat = lstatSync(file);
if (!stat.isFile() || stat.isSymbolicLink() || stat.size === 0) throw new Error(`Missing or invalid browser resource: ${file}`);
}
export function verifyBundleInventory(directory, files) {
if (!Array.isArray(files) || JSON.stringify(files) !== JSON.stringify(bundleInventory(directory))) throw new Error("Browser bundle inventory is incomplete or modified");
}
/** Called before signing; Developer ID signing necessarily changes Mach-O bytes. */
export function verifyBrowserBundle(directory, target) {
const spec = browserBundleSpec(target);
const paths = browserBundlePaths(directory, target);
if (!lstatSync(directory).isDirectory() || lstatSync(directory).isSymbolicLink()) throw new Error("Browser bundle must be a real directory");
regularFile(paths.manifest);
const manifest = JSON.parse(readFileSync(paths.manifest, "utf8"));
const { files, ...recordedSpec } = manifest;
if (JSON.stringify(recordedSpec) !== JSON.stringify(spec)) throw new Error("Browser bundle manifest does not match the pinned release");
for (const file of [paths.engine, paths.chrome, ...BROWSER_LICENSE_FILES.map((name) => join(paths.licenses, name)), join(directory, spec.chrome.license), join(directory, spec.chrome.about)]) regularFile(file);
const engine = readFileSync(paths.engine);
const chrome = readFileSync(paths.chrome);
verifyAssetBytes(engine, spec.engine);
if (sha256(chrome) !== spec.chrome.executableSha256) throw new Error("Browser executable failed pinned SHA-256 verification");
if (executableTarget(engine) !== target || executableTarget(chrome) !== target) throw new Error(`Browser executable architecture does not match ${target}`);
verifyBundleInventory(directory, files);
return manifest;
}
export async function releaseBytes(asset, cacheDirectory) {
const cached = cacheDirectory && join(cacheDirectory, asset.asset);
if (cached && existsSync(cached)) {
try {
const bytes = readFileSync(cached);
verifyAssetBytes(bytes, asset);
return bytes;
} catch {
// A cache entry that fails verification is a miss, never a dead end.
rmSync(cached, { force: true });
}
}
const response = await fetch(asset.url, { signal: AbortSignal.timeout(600_000), redirect: "follow" });
if (!response.ok) throw new Error(`Could not download ${asset.asset}: HTTP ${response.status}`);
const parts = [];
let size = 0;
for await (const part of response.body) {
size += part.length;
if (size > asset.bytes) throw new Error(`${asset.asset} exceeded its pinned size`);
parts.push(part);
}
const bytes = Buffer.concat(parts);
verifyAssetBytes(bytes, asset);
if (cached) {
mkdirSync(cacheDirectory, { recursive: true });
// Publish the cache entry atomically: a partial write must never be
// mistaken for a complete archive.
const temporary = join(cacheDirectory, `.${asset.asset}.${process.pid}.tmp`);
try {
writeFileSync(temporary, bytes);
renameSync(temporary, cached);
} catch (error) {
rmSync(temporary, { force: true });
throw error;
}
}
return bytes;
}
export function browserExtractionCommand(archive, directory, { platform = process.platform, systemRoot = process.env.SystemRoot ?? "C:\\Windows" } = {}) {
// Git Bash places GNU tar (no ZIP support) on PATH. Windows ships bsdtar;
// address that exact executable instead of whichever tar appears first.
return platform === "win32"
? { file: win32.join(systemRoot, "System32", "tar.exe"), args: ["-xf", archive, "-C", directory] }
: { file: "unzip", args: ["-q", archive, "-d", directory] };
}
function extract(archive, directory) {
const command = browserExtractionCommand(archive, directory);
const result = spawnSync(command.file, command.args, {
encoding: "utf8", windowsHide: true, timeout: 120_000,
});
if (result.error || result.status !== 0) throw new Error(`Browser archive extraction failed: ${result.error?.message ?? result.stderr ?? result.status}`);
}
export async function stageBrowserTarget(root, target, { cacheDirectory = process.env.OMB_BROWSER_ARCHIVE_DIR ?? join(root, "dist-native", "browser-archives") } = {}) {
const spec = browserBundleSpec(target);
const parent = join(root, "dist-native", "browser");
mkdirSync(parent, { recursive: true });
const scratch = mkdtempSync(join(parent, `.prepare-${target}-`));
const stage = join(scratch, "bundle");
const destination = join(parent, target);
try {
const [engine, chrome] = await Promise.all([releaseBytes(spec.engine, cacheDirectory), releaseBytes(spec.chrome, cacheDirectory)]);
mkdirSync(join(stage, "chrome"), { recursive: true });
const paths = browserBundlePaths(stage, target);
writeFileSync(paths.engine, engine, { mode: 0o755 });
const archive = join(scratch, spec.chrome.asset);
writeFileSync(archive, chrome);
extract(archive, join(stage, "chrome"));
if (process.platform !== "win32") {
chmodSync(paths.engine, 0o755);
chmodSync(paths.chrome, 0o755);
}
mkdirSync(paths.licenses);
for (const name of BROWSER_LICENSE_FILES) copyFileSync(join(sourceRoot, "third_party", "browser", name), join(paths.licenses, name));
writeFileSync(paths.manifest, `${JSON.stringify({ ...spec, files: bundleInventory(stage) }, null, 2)}\n`);
verifyBrowserBundle(stage, target);
// Keep the previous complete tree until the new one passes every check.
const previous = join(scratch, "previous");
if (existsSync(destination)) renameSync(destination, previous);
try { renameSync(stage, destination); }
catch (error) {
if (existsSync(previous)) renameSync(previous, destination);
throw error;
}
console.log(`Browser ready: agent-browser ${spec.engine.version} + Chromium headless ${spec.chrome.version} (${target})`);
return destination;
} finally {
rmSync(scratch, { recursive: true, force: true });
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
for (const target of targetsForPreparation(parsePrepareBrowserArgs(process.argv.slice(2)))) await stageBrowserTarget(sourceRoot, target);
}