aboutsummaryrefslogtreecommitdiff
path: root/src/daemon.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/daemon.ts')
-rw-r--r--src/daemon.ts551
1 files changed, 551 insertions, 0 deletions
diff --git a/src/daemon.ts b/src/daemon.ts
new file mode 100644
index 0000000..babae15
--- /dev/null
+++ b/src/daemon.ts
@@ -0,0 +1,551 @@
+/**
+ * BrowserDaemon — manages the browser-harness daemon process lifecycle.
+ *
+ * Architecture:
+ * Node.js → Unix socket (/tmp/bu-<namespace>.sock) → daemon.py → CDP WS → Chrome
+ *
+ * The daemon is spawned as a child process on start() and communicates via
+ * the protocol defined in protocol.ts. All CDP calls go through this class.
+ */
+
+import { spawn, type ChildProcess } from "node:child_process";
+import { existsSync } from "node:fs";
+import { access, readFile, unlink, writeFile } from "node:fs/promises";
+import { homedir, tmpdir } from "node:os";
+import { basename, join } from "node:path";
+import {
+ sendRequest,
+ daemonAlive,
+ healthProbe,
+ isInternalUrl,
+ type CDPEvent,
+ type DaemonRequest,
+ type DaemonResponse,
+ type DaemonStatus,
+ type DialogInfo,
+ type PageInfo,
+ type PageInfoResult,
+ type RemoteConfig,
+ type TabInfo,
+} from "./protocol";
+
+// ── Constants ────────────────────────────────────────────────────────────────
+
+/** Common locations where browser-harness might be cloned */
+const BH_SEARCH_PATHS = [
+ join(homedir(), "Developer", "browser-harness"),
+ join(homedir(), "src", "browser-harness"),
+ join(homedir(), "browser-harness"),
+ join(homedir(), "dev", "browser-harness"),
+ join(homedir(), "Projects", "browser-harness"),
+];
+
+const DAEMON_START_TIMEOUT_MS = 30_000;
+const DAEMON_POLL_INTERVAL_MS = 200;
+const DAEMON_SHUTDOWN_TIMEOUT_MS = 5_000;
+const SESSION_ATTACH_TIMEOUT_MS = 5_000;
+
+const VIRTUAL_KEY_CODES: Record<string, number> = {
+ Enter: 13,
+ Tab: 9,
+ Backspace: 8,
+ Escape: 27,
+ Delete: 46,
+ " ": 32,
+ ArrowLeft: 37,
+ ArrowUp: 38,
+ ArrowRight: 39,
+ ArrowDown: 40,
+ Home: 36,
+ End: 35,
+ PageUp: 33,
+ PageDown: 34,
+};
+
+// ── BrowserDaemon ────────────────────────────────────────────────────────────
+
+export class BrowserDaemon {
+ readonly namespace: string;
+ readonly socketPath: string;
+ readonly pidPath: string;
+ readonly logPath: string;
+
+ private proc: ChildProcess | null = null;
+ private _sessionId: string | null = null;
+ private _pid: number | null = null;
+ private remote: RemoteConfig | null = null;
+
+ constructor(namespace = "default", remote?: RemoteConfig) {
+ this.namespace = namespace;
+ this.socketPath = `/tmp/bu-${namespace}.sock`;
+ this.pidPath = `/tmp/bu-${namespace}.pid`;
+ this.logPath = `/tmp/bu-${namespace}.log`;
+ this.remote = remote ?? null;
+ }
+
+ // ── Public getters ───────────────────────────────────────────────────────
+
+ get sessionId(): string | null {
+ return this._sessionId;
+ }
+
+ get pid(): number | null {
+ return this._pid;
+ }
+
+ getStatus(): DaemonStatus {
+ return {
+ alive: this._sessionId !== null,
+ sessionId: this._sessionId,
+ pid: this._pid,
+ namespace: this.namespace,
+ socketPath: this.socketPath,
+ remoteBrowserId: this.remote?.browserId,
+ };
+ }
+
+ // ── Lifecycle ────────────────────────────────────────────────────────────
+
+ /**
+ * Start the daemon process and wait for it to become ready.
+ * Idempotent — returns immediately if already alive and healthy.
+ */
+ async start(): Promise<void> {
+ // Already healthy?
+ if (await healthProbe(this.socketPath)) {
+ await this.refreshSessionId();
+ return;
+ }
+
+ // Stale socket but daemon alive? Stop it first.
+ if (await daemonAlive(this.socketPath)) {
+ await this.stop();
+ }
+
+ const bhDir = await findBrowserHarnessDir();
+ const env = this.buildEnv();
+
+ this.proc = spawn("uv", ["run", "daemon.py"], {
+ cwd: bhDir,
+ env: { ...process.env, ...env },
+ stdio: ["ignore", "ignore", "ignore"],
+ detached: true,
+ });
+ this.proc.unref();
+
+ if (this.proc.pid) {
+ this._pid = this.proc.pid;
+ }
+
+ // Poll until socket accepts connections
+ const deadline = Date.now() + DAEMON_START_TIMEOUT_MS;
+ let lastError = "";
+
+ while (Date.now() < deadline) {
+ if (this.proc.exitCode !== null) {
+ lastError = await this.readLogTail();
+ throw new Error(
+ `Browser daemon exited (code ${this.proc.exitCode}): ${lastError || "unknown error"}`,
+ );
+ }
+
+ if (await daemonAlive(this.socketPath)) {
+ // Verify CDP is healthy (not just socket)
+ if (await healthProbe(this.socketPath)) {
+ await this.refreshSessionId();
+ return;
+ }
+ // Socket alive but CDP not ready — keep polling
+ }
+
+ await sleep(DAEMON_POLL_INTERVAL_MS);
+ }
+
+ lastError = await this.readLogTail();
+ // Check if Chrome needs remote debugging setup
+ if (this.needsChromeSetup(lastError)) {
+ throw new Error(
+ `Chrome remote debugging not enabled. Start Chrome with --remote-debugging-port=9222, then retry.\n\nDaemon log: ${lastError}`,
+ );
+ }
+
+ throw new Error(
+ `Daemon "${this.namespace}" failed to start after ${DAEMON_START_TIMEOUT_MS / 1000}s. ` +
+ `Log: ${lastError || "no log output"}`,
+ );
+ }
+
+ /**
+ * Gracefully stop the daemon: send shutdown, wait for exit, force-kill if needed.
+ */
+ async stop(): Promise<void> {
+ // Send shutdown via socket
+ try {
+ await sendRequest(this.socketPath, { meta: "shutdown" }, 3000);
+ } catch {
+ // Socket may already be dead — that's fine
+ }
+
+ // Wait for process to exit
+ if (this.proc && this.proc.exitCode === null) {
+ const deadline = Date.now() + DAEMON_SHUTDOWN_TIMEOUT_MS;
+ while (Date.now() < deadline) {
+ if (this.proc.exitCode !== null) break;
+ await sleep(100);
+ }
+
+ // Force kill if still alive
+ if (this.proc.exitCode === null) {
+ try {
+ this.proc.kill("SIGTERM");
+ } catch {
+ // already dead
+ }
+ }
+ }
+
+ // Clean up files
+ for (const f of [this.socketPath, this.pidPath]) {
+ try {
+ await unlink(f);
+ } catch {
+ // file doesn't exist
+ }
+ }
+
+ this.proc = null;
+ this._sessionId = null;
+ this._pid = null;
+ }
+
+ /**
+ * Ensure daemon is alive and healthy. Restarts if stale.
+ * Call before every tool execution.
+ */
+ async ensureAlive(): Promise<void> {
+ if (!(await healthProbe(this.socketPath))) {
+ await this.stop();
+ await this.start();
+ }
+ }
+
+ // ── CDP Communication ────────────────────────────────────────────────────
+
+ /**
+ * Send a raw request to the daemon and get the response.
+ */
+ async send(request: DaemonRequest): Promise<DaemonResponse> {
+ await this.ensureAlive();
+
+ try {
+ const response = await sendRequest(this.socketPath, request);
+ return response;
+ } catch (err) {
+ // Socket died mid-request — restart and retry once
+ const message = err instanceof Error ? err.message : String(err);
+ if (message.includes("ENOENT") || message.includes("ECONNREFUSED") || message.includes("EPIPE")) {
+ await this.stop();
+ await this.start();
+ return sendRequest(this.socketPath, request);
+ }
+ throw err;
+ }
+ }
+
+ /**
+ * Convenience: send a CDP method and return the result.
+ * Handles session management — Target.* methods skip session,
+ * others use explicit sessionId or the daemon's default session.
+ */
+ async cdp(
+ method: string,
+ params?: Record<string, unknown>,
+ sessionId?: string | null,
+ ): Promise<unknown> {
+ // Browser-level Target.* calls must NOT use a session
+ const sid = method.startsWith("Target.") ? null : (sessionId ?? this._sessionId);
+
+ const response = await this.send({
+ method,
+ params: params ?? {},
+ session_id: sid,
+ });
+
+ if (response.error) {
+ // Auto-recover from stale session
+ if (
+ response.error.includes("Session with given id not found") &&
+ sid === this._sessionId
+ ) {
+ await this.attachFirstPage();
+ const retry = await this.send({
+ method,
+ params: params ?? {},
+ session_id: this._sessionId,
+ });
+ if (retry.error) {
+ throw new Error(`CDP error after session recovery (${method}): ${retry.error}`);
+ }
+ return retry.result;
+ }
+ throw new Error(`CDP error (${method}): ${response.error}`);
+ }
+
+ return response.result;
+ }
+
+ // ── High-level Browser Helpers ────────────────────────────────────────────
+
+ /** Get page state or dialog info */
+ async getPageInfo(): Promise<PageInfoResult> {
+ // Check for JS dialog first
+ try {
+ const dialogResp = await sendRequest(this.socketPath, { meta: "pending_dialog" });
+ if (dialogResp.dialog) {
+ return { dialog: dialogResp.dialog };
+ }
+ } catch {
+ // ignore — dialog check is best-effort
+ }
+
+ const js = `JSON.stringify({url:location.href,title:document.title,w:innerWidth,h:innerHeight,sx:scrollX,sy:scrollY,pw:document.documentElement.scrollWidth,ph:document.documentElement.scrollHeight})`;
+ const raw = await this.evaluateJS(js);
+ return JSON.parse(raw as string) as PageInfo;
+ }
+
+ /** Capture a PNG screenshot, write to path, return path */
+ async captureScreenshot(outputPath: string, fullPage = false): Promise<string> {
+ const result = (await this.cdp("Page.captureScreenshot", {
+ format: "png",
+ captureBeyondViewport: fullPage,
+ })) as { data: string };
+
+ const buf = Buffer.from(result.data, "base64");
+ await writeFile(outputPath, buf);
+ return outputPath;
+ }
+
+ /** List browser tabs */
+ async listTabs(includeChrome = true): Promise<TabInfo[]> {
+ const result = (await this.cdp("Target.getTargets")) as {
+ targetInfos: Array<{ targetId: string; type: string; title: string; url: string }>;
+ };
+
+ const tabs: TabInfo[] = [];
+ for (const t of result.targetInfos) {
+ if (t.type !== "page") continue;
+ if (!includeChrome && isInternalUrl(t.url)) continue;
+ tabs.push({ targetId: t.targetId, title: t.title || "", url: t.url || "" });
+ }
+ return tabs;
+ }
+
+ /** Get current tab info */
+ async currentTab(): Promise<TabInfo> {
+ const result = (await this.cdp("Target.getTargetInfo")) as {
+ targetInfo: { targetId: string; url: string; title: string };
+ };
+ const t = result.targetInfo;
+ return { targetId: t.targetId, url: t.url || "", title: t.title || "" };
+ }
+
+ /** Create a new tab and switch to it. Optionally navigate. */
+ async newTab(url?: string): Promise<string> {
+ const result = (await this.cdp("Target.createTarget", {
+ url: "about:blank",
+ })) as { targetId: string };
+
+ await this.switchTab(result.targetId);
+ if (url && url !== "about:blank") {
+ await this.cdp("Page.navigate", { url });
+ }
+ return result.targetId;
+ }
+
+ /** Activate and attach to a tab */
+ async switchTab(targetId: string): Promise<void> {
+ await this.cdp("Target.activateTarget", { targetId });
+ const result = (await this.cdp("Target.attachToTarget", {
+ targetId,
+ flatten: true,
+ })) as { sessionId: string };
+
+ // Tell the daemon to use this as default session
+ this._sessionId = result.sessionId;
+ await sendRequest(this.socketPath, {
+ meta: "set_session",
+ session_id: result.sessionId,
+ });
+
+ // Mark the tab with a green circle for user visibility
+ try {
+ await this.cdp("Runtime.evaluate", {
+ expression: `if(!document.title.startsWith('🟢'))document.title='🟢 '+document.title`,
+ });
+ } catch {
+ // best-effort
+ }
+ }
+
+ /** Evaluate JavaScript in the page (or a specific iframe target) */
+ async evaluateJS(expression: string, targetId?: string): Promise<unknown> {
+ // Auto-wrap return statements in IIFE
+ if (expression.includes("return ") && !expression.trim().startsWith("(")) {
+ expression = `(function(){${expression}})()`;
+ }
+
+ const sid = targetId || null; // explicit target or daemon default
+ const result = (await this.cdp(
+ "Runtime.evaluate",
+ {
+ expression,
+ returnByValue: true,
+ awaitPromise: true,
+ },
+ targetId ? sid : undefined,
+ )) as { result?: { value?: unknown }; exceptionDetails?: unknown };
+
+ if (result.exceptionDetails) {
+ throw new Error(`JS evaluation failed: ${JSON.stringify(result.exceptionDetails)}`);
+ }
+ return result.result?.value;
+ }
+
+ /** Drain buffered CDP events from the daemon */
+ async drainEvents(): Promise<CDPEvent[]> {
+ const resp = await sendRequest(this.socketPath, { meta: "drain_events" });
+ return resp.events ?? [];
+ }
+
+ /** Get virtual key code for a key name or character */
+ getVirtualKeyCode(key: string): number {
+ return VIRTUAL_KEY_CODES[key] ?? (key.length === 1 ? key.charCodeAt(0) : 0);
+ }
+
+ /** Get the key code string for CDP (uses the key name or the key itself) */
+ getKeyCode(key: string): string {
+ return key.length === 1 && !VIRTUAL_KEY_CODES[key] ? key : key;
+ }
+
+ // ── Internal Helpers ──────────────────────────────────────────────────────
+
+ /** Attach to the first real page target. Creates about:blank if none exist. */
+ private async attachFirstPage(): Promise<string> {
+ const result = (await this.cdp("Target.getTargets")) as {
+ targetInfos: Array<{ targetId: string; type: string; url: string }>;
+ };
+
+ const pages = result.targetInfos.filter(
+ (t) => t.type === "page" && !isInternalUrl(t.url),
+ );
+
+ if (pages.length === 0) {
+ const created = (await this.cdp("Target.createTarget", {
+ url: "about:blank",
+ })) as { targetId: string };
+ pages.push({ targetId: created.targetId, type: "page", url: "about:blank" });
+ }
+
+ const attachResult = (await this.cdp("Target.attachToTarget", {
+ targetId: pages[0].targetId,
+ flatten: true,
+ })) as { sessionId: string };
+
+ this._sessionId = attachResult.sessionId;
+
+ // Enable core domains
+ for (const domain of ["Page", "DOM", "Runtime", "Network"]) {
+ try {
+ await this.cdp(`${domain}.enable`);
+ } catch {
+ // best-effort
+ }
+ }
+
+ return attachResult.sessionId;
+ }
+
+ /** Refresh session ID from the daemon's current session */
+ private async refreshSessionId(): Promise<void> {
+ try {
+ const resp = await sendRequest(this.socketPath, { meta: "session" });
+ if (resp.session_id) {
+ this._sessionId = resp.session_id;
+ }
+ } catch {
+ // will be set on first attach
+ }
+ }
+
+ /** Build environment variables for the daemon process */
+ private buildEnv(): Record<string, string> {
+ const env: Record<string, string> = {
+ BU_NAME: this.namespace,
+ };
+
+ if (this.remote) {
+ env.BU_CDP_WS = this.remote.cdpUrl;
+ env.BU_BROWSER_ID = this.remote.browserId;
+ }
+
+ return env;
+ }
+
+ /** Read the last line of the daemon log */
+ private async readLogTail(): Promise<string> {
+ try {
+ const content = await readFile(this.logPath, "utf-8");
+ const lines = content.trim().split("\n");
+ return lines[lines.length - 1] || "";
+ } catch {
+ return "";
+ }
+ }
+
+ /** Check if the error message suggests Chrome needs remote debugging setup */
+ private needsChromeSetup(errorMsg: string): boolean {
+ const lower = errorMsg.toLowerCase();
+ return (
+ lower.includes("devtoolsactiveport not found") ||
+ lower.includes("enable chrome://inspect") ||
+ lower.includes("not live yet") ||
+ (lower.includes("ws handshake failed") &&
+ (lower.includes("403") || lower.includes("timed out")))
+ );
+ }
+}
+
+// ── Utility ──────────────────────────────────────────────────────────────────
+
+function sleep(ms: number): Promise<void> {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+/**
+ * Find the browser-harness repository directory.
+ * Searches common locations and falls back to cwd.
+ */
+async function findBrowserHarnessDir(): Promise<string> {
+ // Check common locations
+ for (const dir of BH_SEARCH_PATHS) {
+ if (
+ existsSync(join(dir, "daemon.py")) &&
+ existsSync(join(dir, "helpers.py"))
+ ) {
+ return dir;
+ }
+ }
+
+ // Check cwd
+ const cwd = process.cwd();
+ if (existsSync(join(cwd, "daemon.py")) && existsSync(join(cwd, "helpers.py"))) {
+ return cwd;
+ }
+
+ throw new Error(
+ "browser-harness not found. Clone it first:\n" +
+ " git clone https://github.com/browser-use/browser-harness ~/Developer/browser-harness\n" +
+ " cd ~/Developer/browser-harness && uv sync\n\n" +
+ `Searched: ${BH_SEARCH_PATHS.join(", ")}`,
+ );
+}