feat(browser): add clawd browser control

This commit is contained in:
Peter Steinberger
2025-12-13 15:15:09 +00:00
parent 4cdb21c5cd
commit 208ba02a4a
16 changed files with 1553 additions and 0 deletions

108
src/browser/cdp.ts Normal file
View File

@@ -0,0 +1,108 @@
import WebSocket from "ws";
type CdpResponse = {
id: number;
result?: unknown;
error?: { message?: string };
};
type Pending = {
resolve: (value: unknown) => void;
reject: (err: Error) => void;
};
export async function captureScreenshotPng(opts: {
wsUrl: string;
fullPage?: boolean;
}): Promise<Buffer> {
const ws = new WebSocket(opts.wsUrl, { handshakeTimeout: 5000 });
let nextId = 1;
const pending = new Map<number, Pending>();
const send = (method: string, params?: Record<string, unknown>) => {
const id = nextId++;
const msg = { id, method, params };
ws.send(JSON.stringify(msg));
return new Promise<unknown>((resolve, reject) => {
pending.set(id, { resolve, reject });
});
};
const closeWithError = (err: Error) => {
for (const [, p] of pending) p.reject(err);
pending.clear();
try {
ws.close();
} catch {
// ignore
}
};
const openPromise = new Promise<void>((resolve, reject) => {
ws.once("open", () => resolve());
ws.once("error", (err) => reject(err));
});
ws.on("message", (data) => {
try {
const parsed = JSON.parse(String(data)) as CdpResponse;
if (typeof parsed.id !== "number") return;
const p = pending.get(parsed.id);
if (!p) return;
pending.delete(parsed.id);
if (parsed.error?.message) {
p.reject(new Error(parsed.error.message));
return;
}
p.resolve(parsed.result);
} catch {
// ignore
}
});
ws.on("close", () => {
closeWithError(new Error("CDP socket closed"));
});
await openPromise;
await send("Page.enable");
let clip:
| { x: number; y: number; width: number; height: number; scale: number }
| undefined;
if (opts.fullPage) {
const metrics = (await send("Page.getLayoutMetrics")) as {
cssContentSize?: { width?: number; height?: number };
contentSize?: { width?: number; height?: number };
};
const size = metrics?.cssContentSize ?? metrics?.contentSize;
const width = Number(size?.width ?? 0);
const height = Number(size?.height ?? 0);
if (width > 0 && height > 0) {
clip = { x: 0, y: 0, width, height, scale: 1 };
}
}
const result = (await send("Page.captureScreenshot", {
format: "png",
fromSurface: true,
captureBeyondViewport: true,
...(clip ? { clip } : {}),
})) as { data?: string };
const base64 = result?.data;
if (!base64) {
closeWithError(new Error("Screenshot failed: missing data"));
throw new Error("Screenshot failed: missing data");
}
try {
ws.close();
} catch {
// ignore
}
return Buffer.from(base64, "base64");
}

346
src/browser/chrome.ts Normal file
View File

@@ -0,0 +1,346 @@
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { ensurePortAvailable } from "../infra/ports.js";
import { logInfo, logWarn } from "../logger.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { CONFIG_DIR } from "../utils.js";
import type { ResolvedBrowserConfig } from "./config.js";
import {
DEFAULT_CLAWD_BROWSER_COLOR,
DEFAULT_CLAWD_BROWSER_PROFILE_NAME,
} from "./constants.js";
export type BrowserExecutable = {
kind: "canary" | "chromium" | "chrome";
path: string;
};
export type RunningChrome = {
pid: number;
exe: BrowserExecutable;
userDataDir: string;
cdpPort: number;
startedAt: number;
proc: ChildProcessWithoutNullStreams;
};
function exists(filePath: string) {
try {
return fs.existsSync(filePath);
} catch {
return false;
}
}
export function findChromeExecutableMac(): BrowserExecutable | null {
const candidates: Array<BrowserExecutable> = [
{
kind: "canary",
path: "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
},
{
kind: "canary",
path: path.join(
os.homedir(),
"Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
),
},
{
kind: "chromium",
path: "/Applications/Chromium.app/Contents/MacOS/Chromium",
},
{
kind: "chromium",
path: path.join(
os.homedir(),
"Applications/Chromium.app/Contents/MacOS/Chromium",
),
},
{
kind: "chrome",
path: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
},
{
kind: "chrome",
path: path.join(
os.homedir(),
"Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
),
},
];
for (const candidate of candidates) {
if (exists(candidate.path)) return candidate;
}
return null;
}
export function resolveClawdUserDataDir() {
return path.join(
CONFIG_DIR,
"browser",
DEFAULT_CLAWD_BROWSER_PROFILE_NAME,
"user-data",
);
}
function decoratedMarkerPath(userDataDir: string) {
return path.join(userDataDir, ".clawd-profile-decorated");
}
function safeReadJson(filePath: string): Record<string, unknown> | null {
try {
if (!exists(filePath)) return null;
const raw = fs.readFileSync(filePath, "utf-8");
const parsed = JSON.parse(raw) as unknown;
if (typeof parsed !== "object" || parsed === null) return null;
return parsed as Record<string, unknown>;
} catch {
return null;
}
}
function safeWriteJson(filePath: string, data: Record<string, unknown>) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
}
function setDeep(obj: Record<string, unknown>, keys: string[], value: unknown) {
let node: Record<string, unknown> = obj;
for (const key of keys.slice(0, -1)) {
const next = node[key];
if (typeof next !== "object" || next === null || Array.isArray(next)) {
node[key] = {};
}
node = node[key] as Record<string, unknown>;
}
node[keys[keys.length - 1] ?? ""] = value;
}
/**
* Best-effort profile decoration (name + lobster-orange). Chrome preference keys
* vary by version; we keep this conservative and idempotent.
*/
export function decorateClawdProfile(
userDataDir: string,
opts?: { color?: string },
) {
const desiredName = DEFAULT_CLAWD_BROWSER_PROFILE_NAME;
const desiredColor = (
opts?.color ?? DEFAULT_CLAWD_BROWSER_COLOR
).toUpperCase();
const localStatePath = path.join(userDataDir, "Local State");
const preferencesPath = path.join(userDataDir, "Default", "Preferences");
const localState = safeReadJson(localStatePath) ?? {};
// Common-ish shape: profile.info_cache.Default
setDeep(
localState,
["profile", "info_cache", "Default", "name"],
desiredName,
);
setDeep(
localState,
["profile", "info_cache", "Default", "shortcut_name"],
desiredName,
);
setDeep(
localState,
["profile", "info_cache", "Default", "user_name"],
desiredName,
);
// Color keys are best-effort (Chrome changes these frequently).
setDeep(
localState,
["profile", "info_cache", "Default", "profile_color"],
desiredColor,
);
setDeep(
localState,
["profile", "info_cache", "Default", "user_color"],
desiredColor,
);
safeWriteJson(localStatePath, localState);
const prefs = safeReadJson(preferencesPath) ?? {};
setDeep(prefs, ["profile", "name"], desiredName);
setDeep(prefs, ["profile", "profile_color"], desiredColor);
setDeep(prefs, ["profile", "user_color"], desiredColor);
safeWriteJson(preferencesPath, prefs);
try {
fs.writeFileSync(
decoratedMarkerPath(userDataDir),
`${Date.now()}\n`,
"utf-8",
);
} catch {
// ignore
}
}
export async function isChromeReachable(
cdpPort: number,
timeoutMs = 500,
): Promise<boolean> {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const res = await fetch(`http://127.0.0.1:${cdpPort}/json/version`, {
signal: ctrl.signal,
});
return res.ok;
} catch {
return false;
} finally {
clearTimeout(t);
}
}
export async function launchClawdChrome(
resolved: ResolvedBrowserConfig,
runtime: RuntimeEnv = defaultRuntime,
): Promise<RunningChrome> {
await ensurePortAvailable(resolved.cdpPort);
const exe = process.platform === "darwin" ? findChromeExecutableMac() : null;
if (!exe) {
throw new Error(
"No supported browser found (Chrome Canary/Chromium/Chrome on macOS).",
);
}
const userDataDir = resolveClawdUserDataDir();
fs.mkdirSync(userDataDir, { recursive: true });
const marker = decoratedMarkerPath(userDataDir);
const needsDecorate = !exists(marker);
// First launch to create preference files if missing, then decorate and relaunch.
const spawnOnce = () => {
const args: string[] = [
`--remote-debugging-port=${resolved.cdpPort}`,
`--user-data-dir=${userDataDir}`,
"--no-first-run",
"--no-default-browser-check",
"--disable-sync",
"--disable-background-networking",
"--disable-component-update",
"--disable-features=Translate,MediaRouter",
"--password-store=basic",
];
if (resolved.headless) {
// Best-effort; older Chromes may ignore.
args.push("--headless=new");
args.push("--disable-gpu");
}
// Always open a blank tab to ensure a target exists.
args.push("about:blank");
return spawn(exe.path, args, {
stdio: "pipe",
env: {
...process.env,
// Reduce accidental sharing with the user's env.
HOME: os.homedir(),
},
});
};
const startedAt = Date.now();
let proc = spawnOnce();
// If this is the first run, let Chrome create prefs, then decorate + restart.
if (needsDecorate) {
const deadline = Date.now() + 5000;
while (Date.now() < deadline) {
const localStatePath = path.join(userDataDir, "Local State");
const preferencesPath = path.join(userDataDir, "Default", "Preferences");
if (exists(localStatePath) && exists(preferencesPath)) break;
await new Promise((r) => setTimeout(r, 100));
}
try {
proc.kill("SIGTERM");
} catch {
// ignore
}
await new Promise((r) => setTimeout(r, 300));
try {
decorateClawdProfile(userDataDir, { color: resolved.color });
logInfo(
`🦞 clawd browser profile decorated (${resolved.color})`,
runtime,
);
} catch (err) {
logWarn(
`clawd browser profile decoration failed: ${String(err)}`,
runtime,
);
}
proc = spawnOnce();
}
// Wait for CDP to come up.
const readyDeadline = Date.now() + 15_000;
while (Date.now() < readyDeadline) {
if (await isChromeReachable(resolved.cdpPort, 500)) break;
await new Promise((r) => setTimeout(r, 200));
}
if (!(await isChromeReachable(resolved.cdpPort, 500))) {
try {
proc.kill("SIGKILL");
} catch {
// ignore
}
throw new Error(`Failed to start Chrome CDP on port ${resolved.cdpPort}.`);
}
const pid = proc.pid ?? -1;
logInfo(
`🦞 clawd browser started (${exe.kind}) on 127.0.0.1:${resolved.cdpPort} (pid ${pid})`,
runtime,
);
return {
pid,
exe,
userDataDir,
cdpPort: resolved.cdpPort,
startedAt,
proc,
};
}
export async function stopClawdChrome(
running: RunningChrome,
timeoutMs = 2500,
) {
const proc = running.proc;
if (proc.killed) return;
try {
proc.kill("SIGTERM");
} catch {
// ignore
}
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (!proc.exitCode && proc.killed) break;
if (!(await isChromeReachable(running.cdpPort, 200))) return;
await new Promise((r) => setTimeout(r, 100));
}
try {
proc.kill("SIGKILL");
} catch {
// ignore
}
}

122
src/browser/client.ts Normal file
View File

@@ -0,0 +1,122 @@
import { loadConfig } from "../config/config.js";
import { resolveBrowserConfig } from "./config.js";
export type BrowserStatus = {
enabled: boolean;
controlUrl: string;
running: boolean;
pid: number | null;
cdpPort: number;
chosenBrowser: string | null;
userDataDir: string | null;
color: string;
headless: boolean;
attachOnly: boolean;
};
export type BrowserTab = {
targetId: string;
title: string;
url: string;
type?: string;
};
export type ScreenshotResult = {
ok: true;
path: string;
targetId: string;
url: string;
};
async function fetchJson<T>(
url: string,
init?: RequestInit & { timeoutMs?: number },
): Promise<T> {
const timeoutMs = init?.timeoutMs ?? 5000;
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), timeoutMs);
const res = await fetch(url, { ...init, signal: ctrl.signal } as RequestInit);
clearTimeout(t);
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(text ? `${res.status}: ${text}` : `HTTP ${res.status}`);
}
return (await res.json()) as T;
}
export function resolveBrowserControlUrl(overrideUrl?: string) {
const cfg = loadConfig();
const resolved = resolveBrowserConfig(cfg.browser);
const url = overrideUrl?.trim() ? overrideUrl.trim() : resolved.controlUrl;
return url.replace(/\/$/, "");
}
export async function browserStatus(baseUrl: string): Promise<BrowserStatus> {
return await fetchJson<BrowserStatus>(`${baseUrl}/`, { timeoutMs: 1500 });
}
export async function browserStart(baseUrl: string): Promise<void> {
await fetchJson(`${baseUrl}/start`, { method: "POST", timeoutMs: 15000 });
}
export async function browserStop(baseUrl: string): Promise<void> {
await fetchJson(`${baseUrl}/stop`, { method: "POST", timeoutMs: 15000 });
}
export async function browserTabs(baseUrl: string): Promise<BrowserTab[]> {
const res = await fetchJson<{ running: boolean; tabs: BrowserTab[] }>(
`${baseUrl}/tabs`,
{ timeoutMs: 3000 },
);
return res.tabs ?? [];
}
export async function browserOpenTab(
baseUrl: string,
url: string,
): Promise<BrowserTab> {
return await fetchJson<BrowserTab>(`${baseUrl}/tabs/open`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url }),
timeoutMs: 15000,
});
}
export async function browserFocusTab(
baseUrl: string,
targetId: string,
): Promise<void> {
await fetchJson(`${baseUrl}/tabs/focus`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId }),
timeoutMs: 5000,
});
}
export async function browserCloseTab(
baseUrl: string,
targetId: string,
): Promise<void> {
await fetchJson(`${baseUrl}/tabs/${encodeURIComponent(targetId)}`, {
method: "DELETE",
timeoutMs: 5000,
});
}
export async function browserScreenshot(
baseUrl: string,
opts: {
targetId?: string;
fullPage?: boolean;
},
): Promise<ScreenshotResult> {
const q = new URLSearchParams();
if (opts.targetId) q.set("targetId", opts.targetId);
if (opts.fullPage) q.set("fullPage", "true");
const suffix = q.toString() ? `?${q.toString()}` : "";
return await fetchJson<ScreenshotResult>(`${baseUrl}/screenshot${suffix}`, {
timeoutMs: 20000,
});
}

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import {
resolveBrowserConfig,
shouldStartLocalBrowserServer,
} from "./config.js";
describe("browser config", () => {
it("defaults to enabled with loopback control url and lobster-orange color", () => {
const resolved = resolveBrowserConfig(undefined);
expect(resolved.enabled).toBe(true);
expect(resolved.controlPort).toBe(18790);
expect(resolved.cdpPort).toBe(18791);
expect(resolved.controlHost).toBe("127.0.0.1");
expect(resolved.color).toBe("#FF4500");
expect(shouldStartLocalBrowserServer(resolved)).toBe(true);
});
it("normalizes hex colors", () => {
const resolved = resolveBrowserConfig({
controlUrl: "http://localhost:18790",
color: "ff4500",
});
expect(resolved.color).toBe("#FF4500");
});
it("falls back to default color for invalid hex", () => {
const resolved = resolveBrowserConfig({
controlUrl: "http://localhost:18790",
color: "#GGGGGG",
});
expect(resolved.color).toBe("#FF4500");
});
it("treats non-loopback control urls as remote", () => {
const resolved = resolveBrowserConfig({
controlUrl: "http://example.com:18790",
});
expect(shouldStartLocalBrowserServer(resolved)).toBe(false);
});
it("rejects unsupported protocols", () => {
expect(() =>
resolveBrowserConfig({ controlUrl: "ws://127.0.0.1:18790" }),
).toThrow(/must be http/i);
});
});

82
src/browser/config.ts Normal file
View File

@@ -0,0 +1,82 @@
import type { BrowserConfig } from "../config/config.js";
import {
DEFAULT_CLAWD_BROWSER_CDP_PORT,
DEFAULT_CLAWD_BROWSER_COLOR,
DEFAULT_CLAWD_BROWSER_CONTROL_URL,
DEFAULT_CLAWD_BROWSER_ENABLED,
} from "./constants.js";
export type ResolvedBrowserConfig = {
enabled: boolean;
controlUrl: string;
controlHost: string;
controlPort: number;
cdpPort: number;
color: string;
headless: boolean;
attachOnly: boolean;
};
function isLoopbackHost(host: string) {
const h = host.trim().toLowerCase();
return h === "localhost" || h === "127.0.0.1" || h === "[::1]" || h === "::1";
}
function normalizeHexColor(raw: string | undefined) {
const value = (raw ?? "").trim();
if (!value) return DEFAULT_CLAWD_BROWSER_COLOR;
const normalized = value.startsWith("#") ? value : `#${value}`;
if (!/^#[0-9a-fA-F]{6}$/.test(normalized)) return DEFAULT_CLAWD_BROWSER_COLOR;
return normalized.toUpperCase();
}
export function resolveBrowserConfig(
cfg: BrowserConfig | undefined,
): ResolvedBrowserConfig {
const enabled = cfg?.enabled ?? DEFAULT_CLAWD_BROWSER_ENABLED;
const controlUrl = (
cfg?.controlUrl ?? DEFAULT_CLAWD_BROWSER_CONTROL_URL
).trim();
const parsed = new URL(controlUrl);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(
`browser.controlUrl must be http(s), got: ${parsed.protocol.replace(":", "")}`,
);
}
const port =
parsed.port && Number.parseInt(parsed.port, 10) > 0
? Number.parseInt(parsed.port, 10)
: parsed.protocol === "https:"
? 443
: 80;
if (Number.isNaN(port) || port <= 0 || port > 65535) {
throw new Error(`browser.controlUrl has invalid port: ${parsed.port}`);
}
const cdpPort = DEFAULT_CLAWD_BROWSER_CDP_PORT;
if (port === cdpPort) {
throw new Error(
`browser.controlUrl port (${port}) must not equal CDP port (${cdpPort})`,
);
}
const headless = cfg?.headless === true;
const attachOnly = cfg?.attachOnly === true;
return {
enabled,
controlUrl: parsed.toString().replace(/\/$/, ""),
controlHost: parsed.hostname,
controlPort: port,
cdpPort,
color: normalizeHexColor(cfg?.color),
headless,
attachOnly,
};
}
export function shouldStartLocalBrowserServer(resolved: ResolvedBrowserConfig) {
return isLoopbackHost(resolved.controlHost);
}

5
src/browser/constants.ts Normal file
View File

@@ -0,0 +1,5 @@
export const DEFAULT_CLAWD_BROWSER_ENABLED = true;
export const DEFAULT_CLAWD_BROWSER_CONTROL_URL = "http://127.0.0.1:18790";
export const DEFAULT_CLAWD_BROWSER_CDP_PORT = 18791;
export const DEFAULT_CLAWD_BROWSER_COLOR = "#FF4500";
export const DEFAULT_CLAWD_BROWSER_PROFILE_NAME = "clawd";

311
src/browser/server.ts Normal file
View File

@@ -0,0 +1,311 @@
import type { Server } from "node:http";
import path from "node:path";
import express from "express";
import { loadConfig } from "../config/config.js";
import { logError, logInfo, logWarn } from "../logger.js";
import { ensureMediaDir, saveMediaBuffer } from "../media/store.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { captureScreenshotPng } from "./cdp.js";
import {
isChromeReachable,
launchClawdChrome,
type RunningChrome,
stopClawdChrome,
} from "./chrome.js";
import {
resolveBrowserConfig,
shouldStartLocalBrowserServer,
} from "./config.js";
export type BrowserTab = {
targetId: string;
title: string;
url: string;
wsUrl?: string;
type?: string;
};
type BrowserServerState = {
server: Server;
port: number;
cdpPort: number;
running: RunningChrome | null;
resolved: ReturnType<typeof resolveBrowserConfig>;
};
let state: BrowserServerState | null = null;
function jsonError(res: express.Response, status: number, message: string) {
res.status(status).json({ error: message });
}
async function fetchJson<T>(url: string, timeoutMs = 1500): Promise<T> {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const res = await fetch(url, { signal: ctrl.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()) as T;
} finally {
clearTimeout(t);
}
}
async function listTabs(cdpPort: number): Promise<BrowserTab[]> {
const raw = await fetchJson<
Array<{
id?: string;
title?: string;
url?: string;
webSocketDebuggerUrl?: string;
type?: string;
}>
>(`http://127.0.0.1:${cdpPort}/json/list`);
return raw
.map((t) => ({
targetId: t.id ?? "",
title: t.title ?? "",
url: t.url ?? "",
wsUrl: t.webSocketDebuggerUrl,
type: t.type,
}))
.filter((t) => Boolean(t.targetId));
}
async function openTab(cdpPort: number, url: string): Promise<BrowserTab> {
const encoded = encodeURIComponent(url);
const created = await fetchJson<{
id?: string;
title?: string;
url?: string;
webSocketDebuggerUrl?: string;
type?: string;
}>(`http://127.0.0.1:${cdpPort}/json/new?${encoded}`);
if (!created.id) throw new Error("Failed to open tab (missing id)");
return {
targetId: created.id,
title: created.title ?? "",
url: created.url ?? url,
wsUrl: created.webSocketDebuggerUrl,
type: created.type,
};
}
async function activateTab(cdpPort: number, targetId: string): Promise<void> {
await fetchJson(`http://127.0.0.1:${cdpPort}/json/activate/${targetId}`);
}
async function closeTab(cdpPort: number, targetId: string): Promise<void> {
await fetchJson(`http://127.0.0.1:${cdpPort}/json/close/${targetId}`);
}
async function ensureBrowserAvailable(runtime: RuntimeEnv): Promise<void> {
if (!state) throw new Error("Browser server not started");
if (await isChromeReachable(state.cdpPort)) return;
if (state.resolved.attachOnly) {
throw new Error("Browser attachOnly is enabled and no browser is running.");
}
const launched = await launchClawdChrome(state.resolved, runtime);
state.running = launched;
launched.proc.on("exit", () => {
if (state?.running?.pid === launched.pid) {
state.running = null;
}
});
return;
}
export async function startBrowserControlServerFromConfig(
runtime: RuntimeEnv = defaultRuntime,
): Promise<BrowserServerState | null> {
if (state) return state;
const cfg = loadConfig();
const resolved = resolveBrowserConfig(cfg.browser);
if (!resolved.enabled) return null;
if (!shouldStartLocalBrowserServer(resolved)) {
logInfo(
`browser control URL is non-loopback (${resolved.controlUrl}); skipping local server start`,
runtime,
);
return null;
}
const app = express();
app.use(express.json({ limit: "1mb" }));
app.get("/", async (_req, res) => {
if (!state) return jsonError(res, 503, "browser server not started");
const reachable = await isChromeReachable(state.cdpPort, 300);
res.json({
enabled: state.resolved.enabled,
controlUrl: state.resolved.controlUrl,
running: reachable,
pid: state.running?.pid ?? null,
cdpPort: state.cdpPort,
chosenBrowser: state.running?.exe.kind ?? null,
userDataDir: state.running?.userDataDir ?? null,
color: state.resolved.color,
headless: state.resolved.headless,
attachOnly: state.resolved.attachOnly,
});
});
app.post("/start", async (_req, res) => {
try {
await ensureBrowserAvailable(runtime);
res.json({ ok: true });
} catch (err) {
jsonError(res, 500, String(err));
}
});
app.post("/stop", async (_req, res) => {
if (!state) return jsonError(res, 503, "browser server not started");
if (!state.running) return res.json({ ok: true, stopped: false });
try {
await stopClawdChrome(state.running);
state.running = null;
res.json({ ok: true, stopped: true });
} catch (err) {
jsonError(res, 500, String(err));
}
});
app.get("/tabs", async (_req, res) => {
if (!state) return jsonError(res, 503, "browser server not started");
const reachable = await isChromeReachable(state.cdpPort, 300);
if (!reachable)
return res.json({ running: false, tabs: [] as BrowserTab[] });
try {
const tabs = await listTabs(state.cdpPort);
res.json({ running: true, tabs });
} catch (err) {
jsonError(res, 500, String(err));
}
});
app.post("/tabs/open", async (req, res) => {
if (!state) return jsonError(res, 503, "browser server not started");
const url = String((req.body as { url?: unknown })?.url ?? "").trim();
if (!url) return jsonError(res, 400, "url is required");
try {
await ensureBrowserAvailable(runtime);
const tab = await openTab(state.cdpPort, url);
res.json(tab);
} catch (err) {
jsonError(res, 500, String(err));
}
});
app.post("/tabs/focus", async (req, res) => {
if (!state) return jsonError(res, 503, "browser server not started");
const targetId = String(
(req.body as { targetId?: unknown })?.targetId ?? "",
).trim();
if (!targetId) return jsonError(res, 400, "targetId is required");
const reachable = await isChromeReachable(state.cdpPort, 300);
if (!reachable) return jsonError(res, 409, "browser not running");
try {
await activateTab(state.cdpPort, targetId);
res.json({ ok: true });
} catch (err) {
jsonError(res, 500, String(err));
}
});
app.delete("/tabs/:targetId", async (req, res) => {
if (!state) return jsonError(res, 503, "browser server not started");
const targetId = String(req.params.targetId ?? "").trim();
if (!targetId) return jsonError(res, 400, "targetId is required");
const reachable = await isChromeReachable(state.cdpPort, 300);
if (!reachable) return jsonError(res, 409, "browser not running");
try {
await closeTab(state.cdpPort, targetId);
res.json({ ok: true });
} catch (err) {
jsonError(res, 500, String(err));
}
});
app.get("/screenshot", async (req, res) => {
if (!state) return jsonError(res, 503, "browser server not started");
const targetId =
typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
const fullPage =
req.query.fullPage === "true" || req.query.fullPage === "1";
const reachable = await isChromeReachable(state.cdpPort, 300);
if (!reachable) return jsonError(res, 409, "browser not running");
try {
const tabs = await listTabs(state.cdpPort);
const chosen = targetId
? tabs.find((t) => t.targetId === targetId)
: tabs.at(0);
if (!chosen?.wsUrl) return jsonError(res, 404, "tab not found");
const png = await captureScreenshotPng({ wsUrl: chosen.wsUrl, fullPage });
await ensureMediaDir();
const saved = await saveMediaBuffer(png, "image/png", "browser");
const filePath = path.resolve(saved.path);
res.json({
ok: true,
path: filePath,
targetId: chosen.targetId,
url: chosen.url,
});
} catch (err) {
jsonError(res, 500, String(err));
}
});
const port = resolved.controlPort;
const server = await new Promise<Server>((resolve, reject) => {
const s = app.listen(port, "127.0.0.1", () => resolve(s));
s.once("error", reject);
}).catch((err) => {
logError(
`clawd browser server failed to bind 127.0.0.1:${port}: ${String(err)}`,
);
return null;
});
if (!server) return null;
state = {
server,
port,
cdpPort: resolved.cdpPort,
running: null,
resolved,
};
logInfo(
`🦞 clawd browser control listening on http://127.0.0.1:${port}/`,
runtime,
);
return state;
}
export async function stopBrowserControlServer(
runtime: RuntimeEnv = defaultRuntime,
) {
if (!state) return;
const current = state;
state = null;
try {
if (current.running) {
await stopClawdChrome(current.running).catch((err) =>
logWarn(`clawd browser stop failed: ${String(err)}`, runtime),
);
}
} catch {
// ignore
}
await new Promise<void>((resolve) => current.server.close(() => resolve()));
}

View File

@@ -1,5 +1,16 @@
import chalk from "chalk";
import { Command } from "commander";
import {
browserCloseTab,
browserFocusTab,
browserOpenTab,
browserScreenshot,
browserStart,
browserStatus,
browserStop,
browserTabs,
resolveBrowserControlUrl,
} from "../browser/client.js";
import { agentCommand } from "../commands/agent.js";
import { healthCommand } from "../commands/health.js";
import { sendCommand } from "../commands/send.js";
@@ -360,5 +371,218 @@ Shows token usage per session when the agent reports it; set inbound.reply.agent
}
});
const browser = program
.command("browser")
.description("Manage clawd's dedicated browser (Chrome/Chromium)")
.option(
"--url <url>",
"Override browser control URL (default from ~/.clawdis/clawdis.json)",
)
.option("--json", "Output machine-readable JSON", false)
.addHelpText(
"after",
`
Examples:
clawdis browser status
clawdis browser start
clawdis browser tabs
clawdis browser open https://example.com
clawdis browser screenshot # emits MEDIA:<path>
clawdis browser screenshot <targetId> --full-page
`,
)
.action(() => {
defaultRuntime.error(
danger('Missing subcommand. Try: "clawdis browser status"'),
);
defaultRuntime.exit(1);
});
const parentOpts = (cmd: Command) =>
cmd.parent?.opts?.() as { url?: string; json?: boolean };
browser
.command("status")
.description("Show browser status")
.action(async (_opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
try {
const status = await browserStatus(baseUrl);
if (parent?.json) {
defaultRuntime.log(JSON.stringify(status, null, 2));
return;
}
defaultRuntime.log(
[
`enabled: ${status.enabled}`,
`running: ${status.running}`,
`controlUrl: ${status.controlUrl}`,
`cdpPort: ${status.cdpPort}`,
`browser: ${status.chosenBrowser ?? "unknown"}`,
`profileColor: ${status.color}`,
].join("\n"),
);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
browser
.command("start")
.description("Start the clawd browser (no-op if already running)")
.action(async (_opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
try {
await browserStart(baseUrl);
const status = await browserStatus(baseUrl);
if (parent?.json) {
defaultRuntime.log(JSON.stringify(status, null, 2));
return;
}
defaultRuntime.log(info(`🦞 clawd browser running: ${status.running}`));
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
browser
.command("stop")
.description("Stop the clawd browser (best-effort)")
.action(async (_opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
try {
await browserStop(baseUrl);
const status = await browserStatus(baseUrl);
if (parent?.json) {
defaultRuntime.log(JSON.stringify(status, null, 2));
return;
}
defaultRuntime.log(info(`🦞 clawd browser running: ${status.running}`));
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
browser
.command("tabs")
.description("List open tabs")
.action(async (_opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
try {
const tabs = await browserTabs(baseUrl);
if (parent?.json) {
defaultRuntime.log(JSON.stringify({ tabs }, null, 2));
return;
}
if (tabs.length === 0) {
defaultRuntime.log("No tabs (browser closed or no targets).");
return;
}
defaultRuntime.log(
tabs
.map(
(t, i) =>
`${i + 1}. ${t.title || "(untitled)"}\n ${t.url}\n id: ${t.targetId}`,
)
.join("\n"),
);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
browser
.command("open")
.description("Open a URL in a new tab")
.argument("<url>", "URL to open")
.action(async (url: string, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
try {
const tab = await browserOpenTab(baseUrl, url);
if (parent?.json) {
defaultRuntime.log(JSON.stringify(tab, null, 2));
return;
}
defaultRuntime.log(`opened: ${tab.url}\nid: ${tab.targetId}`);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
browser
.command("focus")
.description("Focus/activate a tab by target id")
.argument("<targetId>", "CDP target id")
.action(async (targetId: string, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
try {
await browserFocusTab(baseUrl, targetId);
if (parent?.json) {
defaultRuntime.log(JSON.stringify({ ok: true }, null, 2));
return;
}
defaultRuntime.log("ok");
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
browser
.command("close")
.description("Close a tab by target id")
.argument("<targetId>", "CDP target id")
.action(async (targetId: string, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
try {
await browserCloseTab(baseUrl, targetId);
if (parent?.json) {
defaultRuntime.log(JSON.stringify({ ok: true }, null, 2));
return;
}
defaultRuntime.log("ok");
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
browser
.command("screenshot")
.description("Capture a screenshot (defaults to first tab)")
.argument("[targetId]", "CDP target id")
.option("--full-page", "Capture full page (best-effort)", false)
.action(async (targetId: string | undefined, opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
try {
const result = await browserScreenshot(baseUrl, {
targetId: targetId?.trim() || undefined,
fullPage: Boolean(opts.fullPage),
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
// Print MEDIA: token so the agent can forward the image as an attachment.
defaultRuntime.log(`MEDIA:${result.path}`);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
return program;
}

View File

@@ -49,6 +49,18 @@ export type WebChatConfig = {
port?: number;
};
export type BrowserConfig = {
enabled?: boolean;
/** Base URL of the clawd browser control server. Default: http://127.0.0.1:18790 */
controlUrl?: string;
/** Accent color for the clawd browser profile (hex). Default: #FF4500 */
color?: string;
/** Start Chrome headless (best-effort). Default: false */
headless?: boolean;
/** If true: never launch; only attach to an existing browser. Default: false */
attachOnly?: boolean;
};
export type CronConfig = {
enabled?: boolean;
store?: string;
@@ -74,6 +86,7 @@ export type GroupChatConfig = {
export type ClawdisConfig = {
logging?: LoggingConfig;
browser?: BrowserConfig;
inbound?: {
allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:)
messagePrefix?: string; // Prefix added to all inbound messages (default: "[clawdis]" if no allowFrom, else "")
@@ -203,6 +216,15 @@ const ClawdisSchema = z.object({
file: z.string().optional(),
})
.optional(),
browser: z
.object({
enabled: z.boolean().optional(),
controlUrl: z.string().optional(),
color: z.string().optional(),
headless: z.boolean().optional(),
attachOnly: z.boolean().optional(),
})
.optional(),
inbound: z
.object({
allowFrom: z.array(z.string()).optional(),

View File

@@ -8,6 +8,10 @@ import os from "node:os";
import path from "node:path";
import chalk from "chalk";
import { type WebSocket, WebSocketServer } from "ws";
import {
startBrowserControlServerFromConfig,
stopBrowserControlServer,
} from "../browser/server.js";
import { createDefaultDeps } from "../cli/deps.js";
import { agentCommand } from "../commands/agent.js";
import { getHealthSnapshot, type HealthSummary } from "../commands/health.js";
@@ -2109,6 +2113,11 @@ export async function startGatewayServer(
logError(`gateway: webchat failed to start: ${String(err)}`);
});
// Start clawd browser control server (unless disabled via config).
void startBrowserControlServerFromConfig(defaultRuntime).catch((err) => {
logError(`gateway: clawd browser server failed to start: ${String(err)}`);
});
// Launch configured providers (WhatsApp Web, Telegram) so gateway replies via the
// surface the message came from. Tests can opt out via CLAWDIS_SKIP_PROVIDERS.
if (process.env.CLAWDIS_SKIP_PROVIDERS !== "1") {
@@ -2168,6 +2177,7 @@ export async function startGatewayServer(
}
}
clients.clear();
await stopBrowserControlServer().catch(() => {});
await Promise.allSettled(providerTasks);
await new Promise<void>((resolve) => wss.close(() => resolve()));
await new Promise<void>((resolve, reject) =>