Skip to content
Open
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
21 changes: 18 additions & 3 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 20 additions & 2 deletions packages/cli/script/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,28 @@ const version = (await $`bun pm pkg get version --cwd ${cliRoot}`.text()).trim()
const target = process.argv[2];

const outfile = target ? `dist/browser-${target.replace("bun-", "")}` : "dist/browser";
const targetFlag = target ? `--target=${target}` : "";
const entrypoint = Bun.fileURLToPath(new URL("../src/index.ts", import.meta.url));
const outfilePath = Bun.fileURLToPath(new URL(`../../../${outfile}`, import.meta.url));

console.log(`Building browser v${version}${target ? ` for ${target}` : ""}`);

await $`bun build --compile ${entrypoint} --outfile ${outfilePath} ${targetFlag} --define "process.env.VERSION='${version}'"`;
const proc = Bun.spawn(
[
"bun",
"build",
"--compile",
entrypoint,
"--outfile",
outfilePath,
...(target ? [`--target=${target}`] : []),
"--define",
`process.env.VERSION='${version}'`,
],
{
stdout: "inherit",
stderr: "inherit",
},
);

const exitCode = await proc.exited;
if (exitCode !== 0) process.exit(exitCode);
17 changes: 15 additions & 2 deletions packages/cli/src/cdp.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import CDP from "chrome-remote-interface";
import { spawn } from "node:child_process";
import { openSync, closeSync } from "node:fs";
import { join } from "node:path";
import { mkdir, rm } from "node:fs/promises";
import { getBrowserPath, getProfileDir, BROWSER_DIR } from "./config";

const STATE_FILE = join(BROWSER_DIR, "state.json");
const LAUNCH_ERROR_FILE = join(BROWSER_DIR, "launch-error.log");
export const CDP_PORT = 9222;
const LAUNCH_TIMEOUT_MS = 5000;
const LAUNCH_POLL_INTERVAL_MS = 100;
Expand Down Expand Up @@ -140,6 +142,10 @@ export function addOnClose(cb: OnCloseCallback): void {
const SOFTWARE_RENDERING_ARGS = ["--disable-gpu", "--use-gl=swiftshader"];
const MANAGED_CHROME_ARGS = ["--remote-debugging-port", "--remote-debugging-pipe", "--user-data-dir"];

function needsNoSandbox(): boolean {
return process.platform === "linux" && typeof process.getuid === "function" && process.getuid() === 0;
}

export function findManagedChromeArg(args: string[]): string | null {
for (const arg of args) {
const flag = arg.split("=")[0];
Expand Down Expand Up @@ -171,12 +177,16 @@ export async function launch(options: {
];
if (options.headless) args.push("--headless=new");
if (options.softwareRendering) args.push(...SOFTWARE_RENDERING_ARGS);
if (needsNoSandbox()) args.push("--no-sandbox");
if (options.extraArgs?.length) args.push(...options.extraArgs);

await rm(LAUNCH_ERROR_FILE, { force: true });
const launchErrorFd = openSync(LAUNCH_ERROR_FILE, "w");
spawn(browserPath, args, {
detached: true,
stdio: "ignore",
stdio: ["ignore", "ignore", launchErrorFd],
}).unref();
closeSync(launchErrorFd);

const maxAttempts = LAUNCH_TIMEOUT_MS / LAUNCH_POLL_INTERVAL_MS;
for (let i = 0; i < maxAttempts; i++) {
Expand All @@ -194,13 +204,16 @@ export async function launch(options: {
mobile: false,
});
await client.close();
await rm(LAUNCH_ERROR_FILE, { force: true });
for (const cb of onLaunchCallbacks) await cb();
return page.id;
}
}
}

throw new Error("Failed to start browser");
const launchError = (await Bun.file(LAUNCH_ERROR_FILE).text()).trim();
await rm(LAUNCH_ERROR_FILE, { force: true });
throw new Error(launchError ? `Failed to start browser:\n${launchError}` : "Failed to start browser");
}

export async function close(): Promise<string | null> {
Expand Down
71 changes: 58 additions & 13 deletions packages/cli/test/browser.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,20 @@
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { mkdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { removeMacosDownloadAttributes } from "../src/commands/update";
import { run, browser, browserFails } from "./helpers";

async function writeFakeBrowser(path: string, message: string): Promise<void> {
if (process.platform === "win32") {
await Bun.write(path, `@echo off\r\necho ${message} 1>&2\r\nexit /b 1\r\n`);
return;
}

await Bun.write(path, `#!/bin/sh\nprintf "%s\\n" "${message}" >&2\nexit 1\n`);
await Bun.spawn(["chmod", "+x", path]).exited;
}

async function expectBrowserStarts(args: string): Promise<void> {
await browser(args);
expect(await browser("active")).toBeTruthy();
Expand Down Expand Up @@ -54,6 +67,29 @@ describe("browser", () => {
const output = await browserFails("start --headless --chrome-arg --user-data-dir=/tmp/evil");
expect(output).toContain("--user-data-dir");
});

test("shows browser launch stderr when Chromium exits immediately", async () => {
const dir = join(tmpdir(), `browser-cli-launch-${crypto.randomUUID()}`);
const fakeBrowserPath = join(dir, process.platform === "win32" ? "fake-browser.cmd" : "fake-browser");
const configuredBrowserPath = await run("config browserPath");
const message = "Running as root without --no-sandbox is not supported.";

await mkdir(dir, { recursive: true });
await writeFakeBrowser(fakeBrowserPath, message);

try {
await browser(`config set browserPath ${fakeBrowserPath}`);
const output = await browserFails("start --headless");
expect(output).toContain(message);
} finally {
if (configuredBrowserPath.exitCode === 0 && configuredBrowserPath.stdout) {
await browser(`config set browserPath ${configuredBrowserPath.stdout}`);
} else {
await run("config unset browserPath");
}
await rm(dir, { recursive: true, force: true });
}
}, 10000);
});

describe("add-skill", () => {
Expand All @@ -65,17 +101,26 @@ describe("add-skill", () => {
});

describe("update", () => {
test("removes macOS download attributes", async () => {
const target = Bun.fileURLToPath(new URL("../../../dist/browser-test", import.meta.url));
const content = await Bun.file(Bun.fileURLToPath(new URL("../../../dist/browser", import.meta.url))).arrayBuffer();
await Bun.write(target, content);
await Bun.spawn(["chmod", "+x", target]).exited;
await Bun.spawn(["xattr", "-w", "com.apple.provenance", "test", target]).exited;

await removeMacosDownloadAttributes(target);

const { exitCode, stdout } = await run("--version");
expect(exitCode).toBe(0);
expect(stdout).toBeTruthy();
});
if (process.platform === "darwin") {
test("removes macOS download attributes", async () => {
const target = Bun.fileURLToPath(new URL("../../../dist/browser-test", import.meta.url));
const content = await Bun.file(
Bun.fileURLToPath(new URL("../../../dist/browser", import.meta.url)),
).arrayBuffer();
await Bun.write(target, content);
await Bun.spawn(["chmod", "+x", target]).exited;
await Bun.spawn(["xattr", "-w", "com.apple.provenance", "test", target]).exited;

await removeMacosDownloadAttributes(target);

const { exitCode, stdout } = await run("--version");
expect(exitCode).toBe(0);
expect(stdout).toBeTruthy();
});
} else {
test("removeMacosDownloadAttributes is a no-op on non-macOS", async () => {
await removeMacosDownloadAttributes("/tmp/browser-test");
expect(true).toBe(true);
});
}
});