feat: add Chrome extension browser relay

This commit is contained in:
Peter Steinberger
2026-01-15 04:50:11 +00:00
parent 5fdaef3646
commit ef78b198cb
40 changed files with 2467 additions and 49 deletions

View File

@@ -20,6 +20,7 @@ const BROWSER_TOOL_ACTIONS = [
"status",
"start",
"stop",
"profiles",
"tabs",
"open",
"focus",

View File

@@ -4,6 +4,7 @@ const browserClientMocks = vi.hoisted(() => ({
browserCloseTab: vi.fn(async () => ({})),
browserFocusTab: vi.fn(async () => ({})),
browserOpenTab: vi.fn(async () => ({})),
browserProfiles: vi.fn(async () => []),
browserSnapshot: vi.fn(async () => ({
ok: true,
format: "ai",
@@ -113,6 +114,13 @@ describe("browser tool snapshot maxChars", () => {
const [, opts] = browserClientMocks.browserSnapshot.mock.calls.at(-1) ?? [];
expect(Object.hasOwn(opts ?? {}, "maxChars")).toBe(false);
});
it("lists profiles", async () => {
const tool = createBrowserTool();
await tool.execute?.(null, { action: "profiles" });
expect(browserClientMocks.browserProfiles).toHaveBeenCalledWith("http://127.0.0.1:18791");
});
});
describe("browser tool snapshot labels", () => {

View File

@@ -2,6 +2,7 @@ import {
browserCloseTab,
browserFocusTab,
browserOpenTab,
browserProfiles,
browserSnapshot,
browserStart,
browserStatus,
@@ -123,7 +124,7 @@ export function createBrowserTool(opts?: {
label: "Browser",
name: "browser",
description: [
"Control clawd's dedicated browser (status/start/stop/tabs/open/snapshot/screenshot/actions).",
"Control clawd's dedicated browser (status/start/stop/profiles/tabs/open/snapshot/screenshot/actions).",
"Use snapshot+act for UI automation. Avoid act:wait by default; use only in exceptional cases when no reliable UI state exists.",
`target selects browser location (sandbox|host|custom). Default: ${targetDefault}.`,
"controlUrl implies target=custom (remote control server).",
@@ -156,6 +157,8 @@ export function createBrowserTool(opts?: {
case "stop":
await browserStop(baseUrl, { profile });
return jsonResult(await browserStatus(baseUrl, { profile }));
case "profiles":
return jsonResult({ profiles: await browserProfiles(baseUrl) });
case "tabs":
return jsonResult({ tabs: await browserTabs(baseUrl, { profile }) });
case "open": {

View File

@@ -21,6 +21,7 @@ export async function startBrowserBridgeServer(params: {
resolved: ResolvedBrowserConfig;
host?: string;
port?: number;
authToken?: string;
onEnsureAttachTarget?: (profile: ProfileContext["profile"]) => Promise<void>;
}): Promise<BrowserBridge> {
const host = params.host ?? "127.0.0.1";
@@ -29,6 +30,15 @@ export async function startBrowserBridgeServer(params: {
const app = express();
app.use(express.json({ limit: "1mb" }));
const authToken = params.authToken?.trim();
if (authToken) {
app.use((req, res, next) => {
const auth = String(req.headers.authorization ?? "").trim();
if (auth === `Bearer ${authToken}`) return next();
res.status(401).send("Unauthorized");
});
}
const state: BrowserServerState = {
server: null as unknown as Server,
port,

View File

@@ -139,7 +139,7 @@ export type AriaSnapshotNode = {
depth: number;
};
type RawAXNode = {
export type RawAXNode = {
nodeId?: string;
role?: { value?: string };
name?: { value?: string };
@@ -159,7 +159,10 @@ function axValue(v: unknown): string {
return "";
}
function formatAriaSnapshot(nodes: RawAXNode[], limit: number): AriaSnapshotNode[] {
export function formatAriaSnapshot(
nodes: RawAXNode[],
limit: number,
): AriaSnapshotNode[] {
const byId = new Map<string, RawAXNode>();
for (const n of nodes) {
if (n.nodeId) byId.set(n.nodeId, n);

View File

@@ -1,4 +1,24 @@
import { extractErrorCode, formatErrorMessage } from "../infra/errors.js";
import { loadConfig } from "../config/config.js";
import { resolveBrowserConfig } from "./config.js";
let cachedConfigToken: string | null | undefined = undefined;
function getBrowserControlToken(): string | null {
const env = process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN?.trim();
if (env) return env;
if (cachedConfigToken !== undefined) return cachedConfigToken;
try {
const cfg = loadConfig();
const resolved = resolveBrowserConfig(cfg.browser);
const token = resolved.controlToken?.trim() || "";
cachedConfigToken = token ? token : null;
} catch {
cachedConfigToken = null;
}
return cachedConfigToken;
}
function unwrapCause(err: unknown): unknown {
if (!err || typeof err !== "object") return null;
@@ -43,7 +63,23 @@ export async function fetchBrowserJson<T>(
const t = setTimeout(() => ctrl.abort(), timeoutMs);
let res: Response;
try {
res = await fetch(url, { ...init, signal: ctrl.signal } as RequestInit);
const token = getBrowserControlToken();
const mergedHeaders = (() => {
if (!token) return init?.headers;
const h = new Headers(init?.headers ?? {});
if (!h.has("Authorization")) {
h.set("Authorization", `Bearer ${token}`);
}
return h;
})();
res = await fetch(
url,
{
...init,
...(mergedHeaders ? { headers: mergedHeaders } : {}),
signal: ctrl.signal,
} as RequestInit,
);
} catch (err) {
throw enhanceBrowserFetchError(url, err, timeoutMs);
} finally {

View File

@@ -34,6 +34,41 @@ describe("browser client", () => {
await expect(browserStatus("http://127.0.0.1:18791")).rejects.toThrow(/timed out/i);
});
it("adds Authorization when CLAWDBOT_BROWSER_CONTROL_TOKEN is set", async () => {
const prev = process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN;
process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN = "t1";
const calls: Array<{ url: string; init?: RequestInit }> = [];
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, init?: RequestInit) => {
calls.push({ url, init });
return {
ok: true,
json: async () => ({
enabled: true,
controlUrl: "http://127.0.0.1:18791",
running: false,
pid: null,
cdpPort: 18792,
chosenBrowser: null,
userDataDir: null,
color: "#FF0000",
headless: true,
attachOnly: false,
}),
} as unknown as Response;
}),
);
await browserStatus("http://127.0.0.1:18791");
const init = calls[0]?.init;
const auth = new Headers(init?.headers ?? {}).get("Authorization");
expect(auth).toBe("Bearer t1");
process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN = prev;
});
it("surfaces non-2xx responses with body text", async () => {
vi.stubGlobal(
"fetch",

View File

@@ -152,18 +152,27 @@ export type BrowserCreateProfileResult = {
export async function browserCreateProfile(
baseUrl: string,
opts: { name: string; color?: string; cdpUrl?: string },
opts: {
name: string;
color?: string;
cdpUrl?: string;
driver?: "clawd" | "extension";
},
): Promise<BrowserCreateProfileResult> {
return await fetchBrowserJson<BrowserCreateProfileResult>(`${baseUrl}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: opts.name,
color: opts.color,
cdpUrl: opts.cdpUrl,
}),
timeoutMs: 10000,
});
return await fetchBrowserJson<BrowserCreateProfileResult>(
`${baseUrl}/profiles/create`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: opts.name,
color: opts.color,
cdpUrl: opts.cdpUrl,
driver: opts.driver,
}),
timeoutMs: 10000,
},
);
}
export type BrowserDeleteProfileResult = {

View File

@@ -16,6 +16,7 @@ export type ResolvedBrowserConfig = {
controlUrl: string;
controlHost: string;
controlPort: number;
controlToken?: string;
cdpProtocol: "http" | "https";
cdpHost: string;
cdpIsLoopback: boolean;
@@ -35,6 +36,7 @@ export type ResolvedBrowserProfile = {
cdpHost: string;
cdpIsLoopback: boolean;
color: string;
driver: "clawd" | "extension";
};
function isLoopbackHost(host: string) {
@@ -105,6 +107,7 @@ function ensureDefaultProfile(
export function resolveBrowserConfig(cfg: BrowserConfig | undefined): ResolvedBrowserConfig {
const enabled = cfg?.enabled ?? DEFAULT_CLAWD_BROWSER_ENABLED;
const envControlUrl = process.env.CLAWDBOT_BROWSER_CONTROL_URL?.trim();
const controlToken = cfg?.controlToken?.trim() || undefined;
const derivedControlPort = (() => {
const raw = process.env.CLAWDBOT_GATEWAY_PORT?.trim();
if (!raw) return null;
@@ -170,6 +173,7 @@ export function resolveBrowserConfig(cfg: BrowserConfig | undefined): ResolvedBr
controlUrl: controlInfo.normalized,
controlHost: controlInfo.parsed.hostname,
controlPort,
...(controlToken ? { controlToken } : {}),
cdpProtocol,
cdpHost: cdpInfo.parsed.hostname,
cdpIsLoopback: isLoopbackHost(cdpInfo.parsed.hostname),
@@ -198,6 +202,7 @@ export function resolveProfile(
let cdpHost = resolved.cdpHost;
let cdpPort = profile.cdpPort ?? 0;
let cdpUrl = "";
const driver = profile.driver === "extension" ? "extension" : "clawd";
if (rawProfileUrl) {
const parsed = parseHttpUrl(rawProfileUrl, `browser.profiles.${profileName}.cdpUrl`);
@@ -217,6 +222,7 @@ export function resolveProfile(
cdpHost,
cdpIsLoopback: isLoopbackHost(cdpHost),
color: profile.color,
driver,
};
}

View File

@@ -0,0 +1,202 @@
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { afterEach, describe, expect, it } from "vitest";
import WebSocket from "ws";
import {
ensureChromeExtensionRelayServer,
stopChromeExtensionRelayServer,
} from "./extension-relay.js";
async function getFreePort(): Promise<number> {
while (true) {
const port = await new Promise<number>((resolve, reject) => {
const s = createServer();
s.once("error", reject);
s.listen(0, "127.0.0.1", () => {
const assigned = (s.address() as AddressInfo).port;
s.close((err) => (err ? reject(err) : resolve(assigned)));
});
});
if (port < 65535) return port;
}
}
function waitForOpen(ws: WebSocket) {
return new Promise<void>((resolve, reject) => {
ws.once("open", () => resolve());
ws.once("error", reject);
});
}
function createMessageQueue(ws: WebSocket) {
const queue: string[] = [];
let waiter: ((value: string) => void) | null = null;
let waiterReject: ((err: Error) => void) | null = null;
let waiterTimer: NodeJS.Timeout | null = null;
const flushWaiter = (value: string) => {
if (!waiter) return false;
const resolve = waiter;
waiter = null;
const reject = waiterReject;
waiterReject = null;
if (waiterTimer) clearTimeout(waiterTimer);
waiterTimer = null;
if (reject) {
// no-op (kept for symmetry)
}
resolve(value);
return true;
};
ws.on("message", (data) => {
const text =
typeof data === "string"
? data
: Buffer.isBuffer(data)
? data.toString("utf8")
: Array.isArray(data)
? Buffer.concat(data).toString("utf8")
: Buffer.from(data).toString("utf8");
if (flushWaiter(text)) return;
queue.push(text);
});
ws.on("error", (err) => {
if (!waiterReject) return;
const reject = waiterReject;
waiterReject = null;
waiter = null;
if (waiterTimer) clearTimeout(waiterTimer);
waiterTimer = null;
reject(err instanceof Error ? err : new Error(String(err)));
});
const next = (timeoutMs = 5000) =>
new Promise<string>((resolve, reject) => {
const existing = queue.shift();
if (existing !== undefined) return resolve(existing);
waiter = resolve;
waiterReject = reject;
waiterTimer = setTimeout(() => {
waiter = null;
waiterReject = null;
waiterTimer = null;
reject(new Error("timeout"));
}, timeoutMs);
});
return { next };
}
describe("chrome extension relay server", () => {
let cdpUrl = "";
afterEach(async () => {
if (cdpUrl) {
await stopChromeExtensionRelayServer({ cdpUrl }).catch(() => {});
cdpUrl = "";
}
});
it("advertises CDP WS only when extension is connected", async () => {
const port = await getFreePort();
cdpUrl = `http://127.0.0.1:${port}`;
await ensureChromeExtensionRelayServer({ cdpUrl });
const v1 = (await fetch(`${cdpUrl}/json/version`).then((r) =>
r.json(),
)) as {
webSocketDebuggerUrl?: string;
};
expect(v1.webSocketDebuggerUrl).toBeUndefined();
const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`);
await waitForOpen(ext);
const v2 = (await fetch(`${cdpUrl}/json/version`).then((r) =>
r.json(),
)) as {
webSocketDebuggerUrl?: string;
};
expect(String(v2.webSocketDebuggerUrl ?? "")).toContain(`/cdp`);
ext.close();
});
it("tracks attached page targets and exposes them via CDP + /json/list", async () => {
const port = await getFreePort();
cdpUrl = `http://127.0.0.1:${port}`;
await ensureChromeExtensionRelayServer({ cdpUrl });
const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`);
await waitForOpen(ext);
// Simulate a tab attach coming from the extension.
ext.send(
JSON.stringify({
method: "forwardCDPEvent",
params: {
method: "Target.attachedToTarget",
params: {
sessionId: "cb-tab-1",
targetInfo: {
targetId: "t1",
type: "page",
title: "Example",
url: "https://example.com",
},
waitingForDebugger: false,
},
},
}),
);
const list = (await fetch(`${cdpUrl}/json/list`).then((r) =>
r.json(),
)) as Array<{
id?: string;
url?: string;
}>;
expect(
list.some((t) => t.id === "t1" && t.url === "https://example.com"),
).toBe(true);
const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`);
await waitForOpen(cdp);
const q = createMessageQueue(cdp);
cdp.send(JSON.stringify({ id: 1, method: "Target.getTargets" }));
const res1 = JSON.parse(await q.next()) as { id: number; result?: unknown };
expect(res1.id).toBe(1);
expect(JSON.stringify(res1.result ?? {})).toContain("t1");
cdp.send(
JSON.stringify({
id: 2,
method: "Target.attachToTarget",
params: { targetId: "t1" },
}),
);
const received: Array<{
id?: number;
method?: string;
result?: unknown;
params?: unknown;
}> = [];
received.push(JSON.parse(await q.next()) as never);
received.push(JSON.parse(await q.next()) as never);
const res2 = received.find((m) => m.id === 2);
expect(res2?.id).toBe(2);
expect(JSON.stringify(res2?.result ?? {})).toContain("cb-tab-1");
const evt = received.find((m) => m.method === "Target.attachedToTarget");
expect(evt?.method).toBe("Target.attachedToTarget");
expect(JSON.stringify(evt?.params ?? {})).toContain("t1");
cdp.close();
ext.close();
}, 15_000);
});

View File

@@ -0,0 +1,680 @@
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import type { Duplex } from "node:stream";
import WebSocket, { WebSocketServer } from "ws";
import { rawDataToString } from "../infra/ws.js";
type CdpCommand = {
id: number;
method: string;
params?: unknown;
sessionId?: string;
};
type CdpResponse = {
id: number;
result?: unknown;
error?: { message: string };
sessionId?: string;
};
type CdpEvent = {
method: string;
params?: unknown;
sessionId?: string;
};
type ExtensionForwardCommandMessage = {
id: number;
method: "forwardCDPCommand";
params: { method: string; params?: unknown; sessionId?: string };
};
type ExtensionResponseMessage = {
id: number;
result?: unknown;
error?: string;
};
type ExtensionForwardEventMessage = {
method: "forwardCDPEvent";
params: { method: string; params?: unknown; sessionId?: string };
};
type ExtensionPingMessage = { method: "ping" };
type ExtensionPongMessage = { method: "pong" };
type ExtensionMessage =
| ExtensionResponseMessage
| ExtensionForwardEventMessage
| ExtensionPongMessage;
type TargetInfo = {
targetId: string;
type?: string;
title?: string;
url?: string;
attached?: boolean;
};
type AttachedToTargetEvent = {
sessionId: string;
targetInfo: TargetInfo;
waitingForDebugger?: boolean;
};
type DetachedFromTargetEvent = {
sessionId: string;
targetId?: string;
};
type ConnectedTarget = {
sessionId: string;
targetId: string;
targetInfo: TargetInfo;
};
export type ChromeExtensionRelayServer = {
host: string;
port: number;
baseUrl: string;
cdpWsUrl: string;
extensionConnected: () => boolean;
stop: () => Promise<void>;
};
function isLoopbackHost(host: string) {
const h = host.trim().toLowerCase();
return (
h === "localhost" ||
h === "127.0.0.1" ||
h === "0.0.0.0" ||
h === "[::1]" ||
h === "::1" ||
h === "[::]" ||
h === "::"
);
}
function isLoopbackAddress(ip: string | undefined): boolean {
if (!ip) return false;
if (ip === "127.0.0.1") return true;
if (ip.startsWith("127.")) return true;
if (ip === "::1") return true;
if (ip.startsWith("::ffff:127.")) return true;
return false;
}
function parseBaseUrl(raw: string): {
host: string;
port: number;
baseUrl: string;
} {
const parsed = new URL(raw.trim().replace(/\/$/, ""));
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(
`extension relay cdpUrl must be http(s), got ${parsed.protocol}`,
);
}
const host = parsed.hostname;
const port =
parsed.port?.trim() !== ""
? Number(parsed.port)
: parsed.protocol === "https:"
? 443
: 80;
if (!Number.isFinite(port) || port <= 0 || port > 65535) {
throw new Error(
`extension relay cdpUrl has invalid port: ${parsed.port || "(empty)"}`,
);
}
return { host, port, baseUrl: parsed.toString().replace(/\/$/, "") };
}
function text(res: Duplex, status: number, bodyText: string) {
const body = Buffer.from(bodyText);
res.write(
`HTTP/1.1 ${status} ${status === 200 ? "OK" : "ERR"}\r\n` +
"Content-Type: text/plain; charset=utf-8\r\n" +
`Content-Length: ${body.length}\r\n` +
"Connection: close\r\n" +
"\r\n",
);
res.write(body);
res.end();
}
function rejectUpgrade(socket: Duplex, status: number, bodyText: string) {
text(socket, status, bodyText);
try {
socket.destroy();
} catch {
// ignore
}
}
const serversByPort = new Map<number, ChromeExtensionRelayServer>();
export async function ensureChromeExtensionRelayServer(opts: {
cdpUrl: string;
}): Promise<ChromeExtensionRelayServer> {
const info = parseBaseUrl(opts.cdpUrl);
if (!isLoopbackHost(info.host)) {
throw new Error(
`extension relay requires loopback cdpUrl host (got ${info.host})`,
);
}
const existing = serversByPort.get(info.port);
if (existing) return existing;
let extensionWs: WebSocket | null = null;
const cdpClients = new Set<WebSocket>();
const connectedTargets = new Map<string, ConnectedTarget>();
const pendingExtension = new Map<
number,
{
resolve: (v: unknown) => void;
reject: (e: Error) => void;
timer: NodeJS.Timeout;
}
>();
let nextExtensionId = 1;
const sendToExtension = async (
payload: ExtensionForwardCommandMessage,
): Promise<unknown> => {
const ws = extensionWs;
if (!ws || ws.readyState !== WebSocket.OPEN) {
throw new Error("Chrome extension not connected");
}
ws.send(JSON.stringify(payload));
return await new Promise<unknown>((resolve, reject) => {
const timer = setTimeout(() => {
pendingExtension.delete(payload.id);
reject(
new Error(`extension request timeout: ${payload.params.method}`),
);
}, 30_000);
pendingExtension.set(payload.id, { resolve, reject, timer });
});
};
const broadcastToCdpClients = (evt: CdpEvent) => {
const msg = JSON.stringify(evt);
for (const ws of cdpClients) {
if (ws.readyState !== WebSocket.OPEN) continue;
ws.send(msg);
}
};
const sendResponseToCdp = (ws: WebSocket, res: CdpResponse) => {
if (ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify(res));
};
const ensureTargetEventsForClient = (
ws: WebSocket,
mode: "autoAttach" | "discover",
) => {
for (const target of connectedTargets.values()) {
if (mode === "autoAttach") {
ws.send(
JSON.stringify({
method: "Target.attachedToTarget",
params: {
sessionId: target.sessionId,
targetInfo: { ...target.targetInfo, attached: true },
waitingForDebugger: false,
},
} satisfies CdpEvent),
);
} else {
ws.send(
JSON.stringify({
method: "Target.targetCreated",
params: { targetInfo: { ...target.targetInfo, attached: true } },
} satisfies CdpEvent),
);
}
}
};
const routeCdpCommand = async (cmd: CdpCommand): Promise<unknown> => {
switch (cmd.method) {
case "Browser.getVersion":
return {
protocolVersion: "1.3",
product: "Chrome/Clawdbot-Extension-Relay",
revision: "0",
userAgent: "Clawdbot-Extension-Relay",
jsVersion: "V8",
};
case "Browser.setDownloadBehavior":
return {};
case "Target.setAutoAttach":
case "Target.setDiscoverTargets":
return {};
case "Target.getTargets":
return {
targetInfos: Array.from(connectedTargets.values()).map((t) => ({
...t.targetInfo,
attached: true,
})),
};
case "Target.getTargetInfo": {
const params = (cmd.params ?? {}) as { targetId?: string };
const targetId =
typeof params.targetId === "string" ? params.targetId : undefined;
if (targetId) {
for (const t of connectedTargets.values()) {
if (t.targetId === targetId) return { targetInfo: t.targetInfo };
}
}
if (cmd.sessionId && connectedTargets.has(cmd.sessionId)) {
const t = connectedTargets.get(cmd.sessionId);
if (t) return { targetInfo: t.targetInfo };
}
const first = Array.from(connectedTargets.values())[0];
return { targetInfo: first?.targetInfo };
}
case "Target.attachToTarget": {
const params = (cmd.params ?? {}) as { targetId?: string };
const targetId =
typeof params.targetId === "string" ? params.targetId : undefined;
if (!targetId) throw new Error("targetId required");
for (const t of connectedTargets.values()) {
if (t.targetId === targetId) return { sessionId: t.sessionId };
}
throw new Error("target not found");
}
default: {
const id = nextExtensionId++;
return await sendToExtension({
id,
method: "forwardCDPCommand",
params: {
method: cmd.method,
sessionId: cmd.sessionId,
params: cmd.params,
},
});
}
}
};
const server = createServer((req, res) => {
const url = new URL(req.url ?? "/", info.baseUrl);
const path = url.pathname;
if (req.method === "HEAD" && path === "/") {
res.writeHead(200);
res.end();
return;
}
if (path === "/") {
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
res.end("OK");
return;
}
if (path === "/extension/status") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ connected: Boolean(extensionWs) }));
return;
}
const hostHeader = req.headers.host?.trim() || `${info.host}:${info.port}`;
const wsHost = `ws://${hostHeader}`;
const cdpWsUrl = `${wsHost}/cdp`;
if (
(path === "/json/version" || path === "/json/version/") &&
(req.method === "GET" || req.method === "PUT")
) {
const payload: Record<string, unknown> = {
Browser: "Clawdbot/extension-relay",
"Protocol-Version": "1.3",
};
// Only advertise the WS URL if a real extension is connected.
if (extensionWs) payload.webSocketDebuggerUrl = cdpWsUrl;
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(payload));
return;
}
const listPaths = new Set(["/json", "/json/", "/json/list", "/json/list/"]);
if (listPaths.has(path) && (req.method === "GET" || req.method === "PUT")) {
const list = Array.from(connectedTargets.values()).map((t) => ({
id: t.targetId,
type: t.targetInfo.type ?? "page",
title: t.targetInfo.title ?? "",
description: t.targetInfo.title ?? "",
url: t.targetInfo.url ?? "",
webSocketDebuggerUrl: cdpWsUrl,
devtoolsFrontendUrl: `/devtools/inspector.html?ws=${cdpWsUrl.replace("ws://", "")}`,
}));
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(list));
return;
}
const activateMatch = path.match(/^\/json\/activate\/(.+)$/);
if (activateMatch && (req.method === "GET" || req.method === "PUT")) {
const targetId = decodeURIComponent(activateMatch[1] ?? "").trim();
if (!targetId) {
res.writeHead(400);
res.end("targetId required");
return;
}
void (async () => {
try {
await sendToExtension({
id: nextExtensionId++,
method: "forwardCDPCommand",
params: { method: "Target.activateTarget", params: { targetId } },
});
} catch {
// ignore
}
})();
res.writeHead(200);
res.end("OK");
return;
}
const closeMatch = path.match(/^\/json\/close\/(.+)$/);
if (closeMatch && (req.method === "GET" || req.method === "PUT")) {
const targetId = decodeURIComponent(closeMatch[1] ?? "").trim();
if (!targetId) {
res.writeHead(400);
res.end("targetId required");
return;
}
void (async () => {
try {
await sendToExtension({
id: nextExtensionId++,
method: "forwardCDPCommand",
params: { method: "Target.closeTarget", params: { targetId } },
});
} catch {
// ignore
}
})();
res.writeHead(200);
res.end("OK");
return;
}
res.writeHead(404);
res.end("not found");
});
const wssExtension = new WebSocketServer({ noServer: true });
const wssCdp = new WebSocketServer({ noServer: true });
server.on("upgrade", (req, socket, head) => {
const url = new URL(req.url ?? "/", info.baseUrl);
const pathname = url.pathname;
const remote = req.socket.remoteAddress;
if (!isLoopbackAddress(remote)) {
rejectUpgrade(socket, 403, "Forbidden");
return;
}
if (pathname === "/extension") {
if (extensionWs) {
rejectUpgrade(socket, 409, "Extension already connected");
return;
}
wssExtension.handleUpgrade(req, socket, head, (ws) => {
wssExtension.emit("connection", ws, req);
});
return;
}
if (pathname === "/cdp") {
if (!extensionWs) {
rejectUpgrade(socket, 503, "Extension not connected");
return;
}
wssCdp.handleUpgrade(req, socket, head, (ws) => {
wssCdp.emit("connection", ws, req);
});
return;
}
rejectUpgrade(socket, 404, "Not Found");
});
wssExtension.on("connection", (ws) => {
extensionWs = ws;
const ping = setInterval(() => {
if (ws.readyState !== WebSocket.OPEN) return;
ws.send(
JSON.stringify({ method: "ping" } satisfies ExtensionPingMessage),
);
}, 5000);
ws.on("message", (data) => {
let parsed: ExtensionMessage | null = null;
try {
parsed = JSON.parse(rawDataToString(data)) as ExtensionMessage;
} catch {
return;
}
if (
parsed &&
typeof parsed === "object" &&
"id" in parsed &&
typeof parsed.id === "number"
) {
const pending = pendingExtension.get(parsed.id);
if (!pending) return;
pendingExtension.delete(parsed.id);
clearTimeout(pending.timer);
if (
"error" in parsed &&
typeof parsed.error === "string" &&
parsed.error.trim()
) {
pending.reject(new Error(parsed.error));
} else {
pending.resolve((parsed as ExtensionResponseMessage).result);
}
return;
}
if (parsed && typeof parsed === "object" && "method" in parsed) {
if ((parsed as ExtensionPongMessage).method === "pong") return;
if (
(parsed as ExtensionForwardEventMessage).method !== "forwardCDPEvent"
)
return;
const evt = parsed as ExtensionForwardEventMessage;
const method = evt.params?.method;
const params = evt.params?.params;
const sessionId = evt.params?.sessionId;
if (!method || typeof method !== "string") return;
if (method === "Target.attachedToTarget") {
const attached = (params ?? {}) as AttachedToTargetEvent;
const targetType = attached?.targetInfo?.type ?? "page";
if (targetType !== "page") return;
if (attached?.sessionId && attached?.targetInfo?.targetId) {
const already = connectedTargets.has(attached.sessionId);
connectedTargets.set(attached.sessionId, {
sessionId: attached.sessionId,
targetId: attached.targetInfo.targetId,
targetInfo: attached.targetInfo,
});
if (!already) {
broadcastToCdpClients({ method, params, sessionId });
}
return;
}
}
if (method === "Target.detachedFromTarget") {
const detached = (params ?? {}) as DetachedFromTargetEvent;
if (detached?.sessionId) connectedTargets.delete(detached.sessionId);
broadcastToCdpClients({ method, params, sessionId });
return;
}
broadcastToCdpClients({ method, params, sessionId });
}
});
ws.on("close", () => {
clearInterval(ping);
extensionWs = null;
for (const [, pending] of pendingExtension) {
clearTimeout(pending.timer);
pending.reject(new Error("extension disconnected"));
}
pendingExtension.clear();
connectedTargets.clear();
for (const client of cdpClients) {
try {
client.close(1011, "extension disconnected");
} catch {
// ignore
}
}
cdpClients.clear();
});
});
wssCdp.on("connection", (ws) => {
cdpClients.add(ws);
ws.on("message", async (data) => {
let cmd: CdpCommand | null = null;
try {
cmd = JSON.parse(rawDataToString(data)) as CdpCommand;
} catch {
return;
}
if (!cmd || typeof cmd !== "object") return;
if (typeof cmd.id !== "number" || typeof cmd.method !== "string") return;
if (!extensionWs) {
sendResponseToCdp(ws, {
id: cmd.id,
sessionId: cmd.sessionId,
error: { message: "Extension not connected" },
});
return;
}
try {
const result = await routeCdpCommand(cmd);
if (cmd.method === "Target.setAutoAttach" && !cmd.sessionId) {
ensureTargetEventsForClient(ws, "autoAttach");
}
if (cmd.method === "Target.setDiscoverTargets") {
const discover = (cmd.params ?? {}) as { discover?: boolean };
if (discover.discover === true) {
ensureTargetEventsForClient(ws, "discover");
}
}
if (cmd.method === "Target.attachToTarget") {
const params = (cmd.params ?? {}) as { targetId?: string };
const targetId =
typeof params.targetId === "string" ? params.targetId : undefined;
if (targetId) {
const target = Array.from(connectedTargets.values()).find(
(t) => t.targetId === targetId,
);
if (target) {
ws.send(
JSON.stringify({
method: "Target.attachedToTarget",
params: {
sessionId: target.sessionId,
targetInfo: { ...target.targetInfo, attached: true },
waitingForDebugger: false,
},
} satisfies CdpEvent),
);
}
}
}
sendResponseToCdp(ws, { id: cmd.id, sessionId: cmd.sessionId, result });
} catch (err) {
sendResponseToCdp(ws, {
id: cmd.id,
sessionId: cmd.sessionId,
error: { message: err instanceof Error ? err.message : String(err) },
});
}
});
ws.on("close", () => {
cdpClients.delete(ws);
});
});
await new Promise<void>((resolve, reject) => {
server.listen(info.port, info.host, () => resolve());
server.once("error", reject);
});
const addr = server.address() as AddressInfo | null;
const port = addr?.port ?? info.port;
const host = info.host;
const baseUrl = `${new URL(info.baseUrl).protocol}//${host}:${port}`;
const relay: ChromeExtensionRelayServer = {
host,
port,
baseUrl,
cdpWsUrl: `ws://${host}:${port}/cdp`,
extensionConnected: () => Boolean(extensionWs),
stop: async () => {
serversByPort.delete(port);
try {
extensionWs?.close(1001, "server stopping");
} catch {
// ignore
}
for (const ws of cdpClients) {
try {
ws.close(1001, "server stopping");
} catch {
// ignore
}
}
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
wssExtension.close();
wssCdp.close();
},
};
serversByPort.set(port, relay);
return relay;
}
export async function stopChromeExtensionRelayServer(opts: {
cdpUrl: string;
}): Promise<boolean> {
const info = parseBaseUrl(opts.cdpUrl);
const existing = serversByPort.get(info.port);
if (!existing) return false;
await existing.stop();
return true;
}

View File

@@ -20,6 +20,7 @@ export type CreateProfileParams = {
name: string;
color?: string;
cdpUrl?: string;
driver?: "clawd" | "extension";
};
export type CreateProfileResult = {
@@ -47,6 +48,7 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
const createProfile = async (params: CreateProfileParams): Promise<CreateProfileResult> => {
const name = params.name.trim();
const rawCdpUrl = params.cdpUrl?.trim() || undefined;
const driver = params.driver === "extension" ? "extension" : undefined;
if (!isValidProfileName(name)) {
throw new Error("invalid profile name: use lowercase letters, numbers, and hyphens only");
@@ -71,7 +73,11 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
let profileConfig: BrowserProfileConfig;
if (rawCdpUrl) {
const parsed = parseHttpUrl(rawCdpUrl, "browser.profiles.cdpUrl");
profileConfig = { cdpUrl: parsed.normalized, color: profileColor };
profileConfig = {
cdpUrl: parsed.normalized,
...(driver ? { driver } : {}),
color: profileColor,
};
} else {
const usedPorts = getUsedPorts(resolvedProfiles);
const range = deriveDefaultBrowserCdpPortRange(state.resolved.controlPort);
@@ -79,7 +85,11 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
if (cdpPort === null) {
throw new Error("no available CDP ports in range");
}
profileConfig = { cdpPort, color: profileColor };
profileConfig = {
cdpPort,
...(driver ? { driver } : {}),
color: profileColor,
};
}
const nextConfig: ClawdbotConfig = {

View File

@@ -41,6 +41,7 @@ export {
setOfflineViaPlaywright,
setTimezoneViaPlaywright,
snapshotAiViaPlaywright,
snapshotAriaViaPlaywright,
snapshotRoleViaPlaywright,
screenshotWithLabelsViaPlaywright,
storageClearViaPlaywright,

View File

@@ -1,5 +1,6 @@
import type { Page } from "playwright-core";
import { type AriaSnapshotNode, formatAriaSnapshot, type RawAXNode } from "./cdp.js";
import {
buildRoleSnapshotFromAriaSnapshot,
getRoleSnapshotStats,
@@ -7,6 +8,30 @@ import {
} from "./pw-role-snapshot.js";
import { ensurePageState, getPageForTargetId, type WithSnapshotForAI } from "./pw-session.js";
export async function snapshotAriaViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
limit?: number;
}): Promise<{ nodes: AriaSnapshotNode[] }> {
const limit = Math.max(1, Math.min(2000, Math.floor(opts.limit ?? 500)));
const page = await getPageForTargetId({
cdpUrl: opts.cdpUrl,
targetId: opts.targetId,
});
ensurePageState(page);
const session = await page.context().newCDPSession(page);
try {
await session.send("Accessibility.enable").catch(() => {});
const res = (await session.send("Accessibility.getFullAXTree")) as {
nodes?: RawAXNode[];
};
const nodes = Array.isArray(res?.nodes) ? res.nodes : [];
return { nodes: formatAriaSnapshot(nodes, limit) };
} finally {
await session.detach().catch(() => {});
}
}
export async function snapshotAiViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;

View File

@@ -95,8 +95,10 @@ export function registerBrowserAgentSnapshotRoutes(app: express.Express, ctx: Br
try {
const tab = await profileCtx.ensureTabAvailable(targetId);
let buffer: Buffer;
if (ref || element) {
const pw = await requirePwAi(res, "element/ref screenshot");
const shouldUsePlaywright =
profileCtx.profile.driver === "extension" || !tab.wsUrl || Boolean(ref) || Boolean(element);
if (shouldUsePlaywright) {
const pw = await requirePwAi(res, "screenshot");
if (!pw) return;
const snap = await pw.takeScreenshotViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
@@ -268,16 +270,30 @@ export function registerBrowserAgentSnapshotRoutes(app: express.Express, ctx: Br
});
}
const snap = await snapshotAria({
wsUrl: tab.wsUrl ?? "",
limit,
});
const snap =
profileCtx.profile.driver === "extension" || !tab.wsUrl
? (() => {
// Extension relay doesn't expose per-page WS URLs; run AX snapshot via Playwright CDP session.
// Also covers cases where wsUrl is missing/unusable.
return requirePwAi(res, "aria snapshot").then(async (pw) => {
if (!pw) return null;
return await pw.snapshotAriaViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
limit,
});
});
})()
: snapshotAria({ wsUrl: tab.wsUrl ?? "", limit });
const resolved = await Promise.resolve(snap);
if (!resolved) return;
return res.json({
ok: true,
format,
targetId: tab.targetId,
url: tab.url,
...snap,
...resolved,
});
} catch (err) {
handleRouteError(ctx, res, err);

View File

@@ -111,6 +111,9 @@ export function registerBrowserBasicRoutes(app: express.Express, ctx: BrowserRou
const name = toStringOrEmpty((req.body as { name?: unknown })?.name);
const color = toStringOrEmpty((req.body as { color?: unknown })?.color);
const cdpUrl = toStringOrEmpty((req.body as { cdpUrl?: unknown })?.cdpUrl);
const driver = toStringOrEmpty(
(req.body as { driver?: unknown })?.driver,
) as "clawd" | "extension" | "";
if (!name) return jsonError(res, 400, "name is required");
@@ -120,6 +123,7 @@ export function registerBrowserBasicRoutes(app: express.Express, ctx: BrowserRou
name,
color: color || undefined,
cdpUrl: cdpUrl || undefined,
driver: driver === "extension" ? "extension" : undefined,
});
res.json(result);
} catch (err) {

View File

@@ -18,6 +18,10 @@ import type {
ProfileRuntimeState,
ProfileStatus,
} from "./server-context.types.js";
import {
ensureChromeExtensionRelayServer,
stopChromeExtensionRelayServer,
} from "./extension-relay.js";
import { resolveTargetIdFromTabs } from "./target-id.js";
import { movePathToTrash } from "./trash.js";
@@ -187,9 +191,35 @@ function createProfileContext(
const ensureBrowserAvailable = async (): Promise<void> => {
const current = state();
const remoteCdp = !profile.cdpIsLoopback;
const isExtension = profile.driver === "extension";
const profileState = getProfileState();
const httpReachable = await isHttpReachable();
if (isExtension && remoteCdp) {
throw new Error(
`Profile "${profile.name}" uses driver=extension but cdpUrl is not loopback (${profile.cdpUrl}).`,
);
}
if (isExtension) {
if (!httpReachable) {
await ensureChromeExtensionRelayServer({ cdpUrl: profile.cdpUrl });
if (await isHttpReachable(1200)) {
// continue: we still need the extension to connect for CDP websocket.
} else {
throw new Error(
`Chrome extension relay for profile "${profile.name}" is not reachable at ${profile.cdpUrl}.`,
);
}
}
if (await isReachable(600)) return;
// Relay server is up, but no attached tab yet. Prompt user to attach.
throw new Error(
`Chrome extension relay is running, but no tab is connected. Click the Clawdbot Chrome extension icon on a tab to attach it (profile "${profile.name}").`,
);
}
if (!httpReachable) {
if ((current.resolved.attachOnly || remoteCdp) && opts.onEnsureAttachTarget) {
await opts.onEnsureAttachTarget(profile);
@@ -297,6 +327,12 @@ function createProfileContext(
};
const stopRunningBrowser = async (): Promise<{ stopped: boolean }> => {
if (profile.driver === "extension") {
const stopped = await stopChromeExtensionRelayServer({
cdpUrl: profile.cdpUrl,
});
return { stopped };
}
const profileState = getProfileState();
if (!profileState.running) return { stopped: false };
await stopClawdChrome(profileState.running);
@@ -305,6 +341,12 @@ function createProfileContext(
};
const resetProfile = async () => {
if (profile.driver === "extension") {
await stopChromeExtensionRelayServer({ cdpUrl: profile.cdpUrl }).catch(
() => {},
);
return { moved: false, from: profile.cdpUrl };
}
if (!profile.cdpIsLoopback) {
throw new Error(
`reset-profile is only supported for local profiles (profile "${profile.name}" is remote).`,

View File

@@ -3,7 +3,12 @@ import express from "express";
import { loadConfig } from "../config/config.js";
import { createSubsystemLogger } from "../logging.js";
import { resolveBrowserConfig, shouldStartLocalBrowserServer } from "./config.js";
import {
resolveBrowserConfig,
resolveProfile,
shouldStartLocalBrowserServer,
} from "./config.js";
import { ensureChromeExtensionRelayServer } from "./extension-relay.js";
import { registerBrowserRoutes } from "./routes/index.js";
import { type BrowserServerState, createBrowserRouteContext } from "./server-context.js";
@@ -51,6 +56,20 @@ export async function startBrowserControlServerFromConfig(): Promise<BrowserServ
profiles: new Map(),
};
// If any profile uses the Chrome extension relay, start the local relay server eagerly
// so the extension can connect before the first browser action.
for (const name of Object.keys(resolved.profiles)) {
const profile = resolveProfile(resolved, name);
if (!profile || profile.driver !== "extension") continue;
await ensureChromeExtensionRelayServer({ cdpUrl: profile.cdpUrl }).catch(
(err) => {
logServer.warn(
`Chrome extension relay init failed for profile "${name}": ${String(err)}`,
);
},
);
}
logServer.info(`Browser control listening on http://127.0.0.1:${port}/`);
return state;
}

View File

@@ -0,0 +1,20 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
describe("browser extension install", () => {
it("installs into the state dir (never node_modules)", async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-ext-"));
const { installChromeExtension } = await import("./browser-cli-extension.js");
const sourceDir = path.resolve(process.cwd(), "assets/chrome-extension");
const result = await installChromeExtension({ stateDir: tmp, sourceDir });
expect(result.path).toBe(path.join(tmp, "browser", "chrome-extension"));
expect(fs.existsSync(path.join(result.path, "manifest.json"))).toBe(true);
expect(result.path.includes("node_modules")).toBe(false);
});
});

View File

@@ -0,0 +1,97 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { Command } from "commander";
import { STATE_DIR_CLAWDBOT } from "../config/paths.js";
import { danger } from "../globals.js";
import { defaultRuntime } from "../runtime.js";
import { movePathToTrash } from "../browser/trash.js";
function bundledExtensionRootDir() {
const here = path.dirname(fileURLToPath(import.meta.url));
return path.resolve(here, "../../assets/chrome-extension");
}
function installedExtensionRootDir() {
return path.join(STATE_DIR_CLAWDBOT, "browser", "chrome-extension");
}
function hasManifest(dir: string) {
return fs.existsSync(path.join(dir, "manifest.json"));
}
export async function installChromeExtension(opts?: {
stateDir?: string;
sourceDir?: string;
}): Promise<{ path: string }> {
const src = opts?.sourceDir ?? bundledExtensionRootDir();
if (!hasManifest(src)) {
throw new Error("Bundled Chrome extension is missing. Reinstall Clawdbot and try again.");
}
const stateDir = opts?.stateDir ?? STATE_DIR_CLAWDBOT;
const dest = path.join(stateDir, "browser", "chrome-extension");
fs.mkdirSync(path.dirname(dest), { recursive: true });
if (fs.existsSync(dest)) {
await movePathToTrash(dest).catch(() => {
const backup = `${dest}.old-${Date.now()}`;
fs.renameSync(dest, backup);
});
}
await fs.promises.cp(src, dest, { recursive: true });
if (!hasManifest(dest)) {
throw new Error("Chrome extension install failed (manifest.json missing). Try again.");
}
return { path: dest };
}
export function registerBrowserExtensionCommands(
browser: Command,
parentOpts: (cmd: Command) => { json?: boolean },
) {
const ext = browser.command("extension").description("Chrome extension helpers");
ext
.command("install")
.description("Install the Chrome extension to a stable local path")
.action(async (_opts, cmd) => {
const parent = parentOpts(cmd);
let installed: { path: string };
try {
installed = await installChromeExtension();
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
if (parent?.json) {
defaultRuntime.log(JSON.stringify({ ok: true, path: installed.path }, null, 2));
return;
}
defaultRuntime.log(installed.path);
});
ext
.command("path")
.description("Print the path to the installed Chrome extension (load unpacked)")
.action((_opts, cmd) => {
const parent = parentOpts(cmd);
const dir = installedExtensionRootDir();
if (!hasManifest(dir)) {
defaultRuntime.error(
danger('Chrome extension is not installed. Run: "clawdbot browser extension install"'),
);
defaultRuntime.exit(1);
}
if (parent?.json) {
defaultRuntime.log(JSON.stringify({ path: dir }, null, 2));
return;
}
defaultRuntime.log(dir);
});
}

View File

@@ -382,28 +382,41 @@ export function registerBrowserManageCommands(
.requiredOption("--name <name>", "Profile name (lowercase, numbers, hyphens)")
.option("--color <hex>", "Profile color (hex format, e.g. #0066CC)")
.option("--cdp-url <url>", "CDP URL for remote Chrome (http/https)")
.action(async (opts: { name: string; color?: string; cdpUrl?: string }, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
try {
const result = await browserCreateProfile(baseUrl, {
name: opts.name,
color: opts.color,
cdpUrl: opts.cdpUrl,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
.option("--driver <driver>", "Profile driver (clawd|extension). Default: clawd")
.action(
async (
opts: { name: string; color?: string; cdpUrl?: string; driver?: string },
cmd,
) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
try {
const result = await browserCreateProfile(baseUrl, {
name: opts.name,
color: opts.color,
cdpUrl: opts.cdpUrl,
driver: opts.driver === "extension" ? "extension" : undefined,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
const loc = result.isRemote
? ` cdpUrl: ${result.cdpUrl}`
: ` port: ${result.cdpPort}`;
defaultRuntime.log(
info(
`🦞 Created profile "${result.profile}"\n${loc}\n color: ${result.color}${
opts.driver === "extension" ? "\n driver: extension" : ""
}`,
),
);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
const loc = result.isRemote ? ` cdpUrl: ${result.cdpUrl}` : ` port: ${result.cdpPort}`;
defaultRuntime.log(
info(`🦞 Created profile "${result.profile}"\n${loc}\n color: ${result.color}`),
);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
},
);
browser
.command("delete-profile")

View File

@@ -0,0 +1,121 @@
import type { Command } from "commander";
import { loadConfig } from "../config/config.js";
import { danger, info } from "../globals.js";
import { defaultRuntime } from "../runtime.js";
import { resolveBrowserConfig, resolveProfile } from "../browser/config.js";
import { startBrowserBridgeServer, stopBrowserBridgeServer } from "../browser/bridge-server.js";
import { ensureChromeExtensionRelayServer } from "../browser/extension-relay.js";
function isLoopbackBindHost(host: string) {
const h = host.trim().toLowerCase();
return h === "localhost" || h === "127.0.0.1" || h === "::1" || h === "[::1]";
}
function parsePort(raw: unknown): number | null {
const v = typeof raw === "string" ? raw.trim() : "";
if (!v) return null;
const n = Number.parseInt(v, 10);
if (!Number.isFinite(n) || n < 0 || n > 65535) return null;
return n;
}
export function registerBrowserServeCommands(
browser: Command,
_parentOpts: (cmd: Command) => unknown,
) {
browser
.command("serve")
.description("Run a standalone browser control server (for remote gateways)")
.option("--bind <host>", "Bind host (default: 127.0.0.1)")
.option("--port <port>", "Bind port (default: from browser.controlUrl)")
.option(
"--token <token>",
"Require Authorization: Bearer <token> (required when binding non-loopback)",
)
.action(async (opts: { bind?: string; port?: string; token?: string }) => {
const cfg = loadConfig();
const resolved = resolveBrowserConfig(cfg.browser);
if (!resolved.enabled) {
defaultRuntime.error(
danger("Browser control is disabled. Set browser.enabled=true and try again."),
);
defaultRuntime.exit(1);
}
const host = (opts.bind ?? "127.0.0.1").trim();
const port = parsePort(opts.port) ?? resolved.controlPort;
const envToken = process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN?.trim();
const authToken = (opts.token ?? envToken ?? resolved.controlToken)?.trim();
if (!isLoopbackBindHost(host) && !authToken) {
defaultRuntime.error(
danger(
`Refusing to bind browser control on ${host} without --token (or CLAWDBOT_BROWSER_CONTROL_TOKEN, or browser.controlToken).`,
),
);
defaultRuntime.exit(1);
}
const bridge = await startBrowserBridgeServer({
resolved,
host,
port,
...(authToken ? { authToken } : {}),
});
// If any profile uses the Chrome extension relay, start the local relay server eagerly
// so the extension can connect before the first browser action.
for (const name of Object.keys(resolved.profiles)) {
const profile = resolveProfile(resolved, name);
if (!profile || profile.driver !== "extension") continue;
await ensureChromeExtensionRelayServer({ cdpUrl: profile.cdpUrl }).catch((err) => {
defaultRuntime.error(
danger(`Chrome extension relay init failed for profile "${name}": ${String(err)}`),
);
});
}
defaultRuntime.log(
info(
[
`🦞 Browser control listening on ${bridge.baseUrl}/`,
authToken ? "Auth: Bearer token required." : "Auth: off (loopback only).",
"",
"Paste on the Gateway (clawdbot.json):",
JSON.stringify(
{
browser: {
enabled: true,
controlUrl: bridge.baseUrl,
...(authToken ? { controlToken: authToken } : {}),
},
},
null,
2,
),
...(authToken
? [
"",
"Or use env on the Gateway (instead of controlToken in config):",
`export CLAWDBOT_BROWSER_CONTROL_TOKEN=${JSON.stringify(authToken)}`,
]
: []),
].join("\n"),
),
);
let shuttingDown = false;
const shutdown = async (signal: string) => {
if (shuttingDown) return;
shuttingDown = true;
defaultRuntime.log(info(`Shutting down (${signal})...`));
await stopBrowserBridgeServer(bridge.server).catch(() => {});
process.exit(0);
};
process.once("SIGINT", () => void shutdown("SIGINT"));
process.once("SIGTERM", () => void shutdown("SIGTERM"));
await new Promise(() => {});
});
}

View File

@@ -8,8 +8,10 @@ import { registerBrowserActionInputCommands } from "./browser-cli-actions-input.
import { registerBrowserActionObserveCommands } from "./browser-cli-actions-observe.js";
import { registerBrowserDebugCommands } from "./browser-cli-debug.js";
import { browserActionExamples, browserCoreExamples } from "./browser-cli-examples.js";
import { registerBrowserExtensionCommands } from "./browser-cli-extension.js";
import { registerBrowserInspectCommands } from "./browser-cli-inspect.js";
import { registerBrowserManageCommands } from "./browser-cli-manage.js";
import { registerBrowserServeCommands } from "./browser-cli-serve.js";
import type { BrowserParentOpts } from "./browser-cli-shared.js";
import { registerBrowserStateCommands } from "./browser-cli-state.js";
@@ -37,6 +39,8 @@ export function registerBrowserCli(program: Command) {
const parentOpts = (cmd: Command) => cmd.parent?.opts?.() as BrowserParentOpts;
registerBrowserManageCommands(browser, parentOpts);
registerBrowserExtensionCommands(browser, parentOpts);
registerBrowserServeCommands(browser, parentOpts);
registerBrowserInspectCommands(browser, parentOpts);
registerBrowserActionInputCommands(browser, parentOpts);
registerBrowserActionObserveCommands(browser, parentOpts);

View File

@@ -3,6 +3,8 @@ export type BrowserProfileConfig = {
cdpPort?: number;
/** CDP URL for this profile (use for remote Chrome). */
cdpUrl?: string;
/** Profile driver (default: clawd). */
driver?: "clawd" | "extension";
/** Profile color (hex). Auto-assigned at creation. */
color: string;
};
@@ -10,6 +12,13 @@ export type BrowserConfig = {
enabled?: boolean;
/** Base URL of the clawd browser control server. Default: http://127.0.0.1:18791 */
controlUrl?: string;
/**
* Shared token for the browser control server.
* If set, clients must send `Authorization: Bearer <token>`.
*
* Prefer `CLAWDBOT_BROWSER_CONTROL_TOKEN` env for ephemeral setups; use this for "works after reboot".
*/
controlToken?: string;
/** Base URL of the CDP endpoint. Default: controlUrl with port + 1. */
cdpUrl?: string;
/** Accent color for the clawd browser profile (hex). Default: #FF4500 */

View File

@@ -65,6 +65,7 @@ export const ClawdbotSchema = z
.object({
enabled: z.boolean().optional(),
controlUrl: z.string().optional(),
controlToken: z.string().optional(),
cdpUrl: z.string().optional(),
color: z.string().optional(),
executablePath: z.string().optional(),
@@ -81,6 +82,9 @@ export const ClawdbotSchema = z
.object({
cdpPort: z.number().int().min(1).max(65535).optional(),
cdpUrl: z.string().optional(),
driver: z
.union([z.literal("clawd"), z.literal("extension")])
.optional(),
color: HexColorSchema,
})
.refine((value) => value.cdpPort || value.cdpUrl, {

View File

@@ -66,6 +66,84 @@ describe("security audit", () => {
);
});
it("flags remote browser control without token as critical", async () => {
const prev = process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN;
delete process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN;
try {
const cfg: ClawdbotConfig = {
browser: {
controlUrl: "http://example.com:18791",
},
};
const res = await runSecurityAudit({
config: cfg,
includeFilesystem: false,
includeChannelSecurity: false,
});
expect(res.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({ checkId: "browser.control_remote_no_token", severity: "critical" }),
]),
);
} finally {
if (prev === undefined) delete process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN;
else process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN = prev;
}
});
it("warns when browser control token matches gateway auth token", async () => {
const token = "0123456789abcdef0123456789abcdef";
const cfg: ClawdbotConfig = {
gateway: { auth: { token } },
browser: { controlUrl: "https://browser.example.com", controlToken: token },
};
const res = await runSecurityAudit({
config: cfg,
includeFilesystem: false,
includeChannelSecurity: false,
});
expect(res.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({
checkId: "browser.control_token_reuse_gateway_token",
severity: "warn",
}),
]),
);
});
it("warns when remote browser control uses HTTP", async () => {
const prev = process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN;
delete process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN;
try {
const cfg: ClawdbotConfig = {
browser: {
controlUrl: "http://example.com:18791",
controlToken: "0123456789abcdef01234567",
},
};
const res = await runSecurityAudit({
config: cfg,
includeFilesystem: false,
includeChannelSecurity: false,
});
expect(res.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({ checkId: "browser.control_remote_http", severity: "warn" }),
]),
);
} finally {
if (prev === undefined) delete process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN;
else process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN = prev;
}
});
it("adds a warning when deep probe fails", async () => {
const cfg: ClawdbotConfig = { gateway: { mode: "local" } };

View File

@@ -4,6 +4,7 @@ import { listChannelPlugins } from "../channels/plugins/index.js";
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
import type { ChannelId } from "../channels/plugins/types.js";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveBrowserConfig } from "../browser/config.js";
import { resolveConfigPath, resolveStateDir } from "../config/paths.js";
import { resolveGatewayAuth } from "../gateway/auth.js";
import { buildGatewayConnectionDetails } from "../gateway/call.js";
@@ -45,9 +46,9 @@ export type SecurityAuditOptions = {
deep?: boolean;
includeFilesystem?: boolean;
includeChannelSecurity?: boolean;
/** Override where to check state (default: CONFIG_DIR). */
/** Override where to check state (default: resolveStateDir()). */
stateDir?: string;
/** Override config path check (default: CONFIG_PATH_CLAWDBOT). */
/** Override config path check (default: resolveConfigPath()). */
configPath?: string;
/** Time limit for deep gateway probe. */
deepTimeoutMs?: number;
@@ -287,6 +288,87 @@ function collectGatewayConfigFindings(cfg: ClawdbotConfig): SecurityAuditFinding
return findings;
}
function isLoopbackClientHost(hostname: string): boolean {
const h = hostname.trim().toLowerCase();
return h === "localhost" || h === "127.0.0.1" || h === "::1";
}
function collectBrowserControlFindings(cfg: ClawdbotConfig): SecurityAuditFinding[] {
const findings: SecurityAuditFinding[] = [];
let resolved: ReturnType<typeof resolveBrowserConfig>;
try {
resolved = resolveBrowserConfig(cfg.browser);
} catch (err) {
findings.push({
checkId: "browser.control_invalid_config",
severity: "warn",
title: "Browser control config looks invalid",
detail: String(err),
remediation: `Fix browser.controlUrl/browser.cdpUrl in ${resolveConfigPath()} and re-run "clawdbot security audit --deep".`,
});
return findings;
}
if (!resolved.enabled) return findings;
const url = new URL(resolved.controlUrl);
const isLoopback = isLoopbackClientHost(url.hostname);
const envToken = process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN?.trim();
const controlToken = (envToken || resolved.controlToken)?.trim() || null;
if (!isLoopback) {
if (!controlToken) {
findings.push({
checkId: "browser.control_remote_no_token",
severity: "critical",
title: "Remote browser control is missing an auth token",
detail: `browser.controlUrl is non-loopback (${resolved.controlUrl}) but no browser.controlToken (or CLAWDBOT_BROWSER_CONTROL_TOKEN) is configured.`,
remediation:
"Set browser.controlToken (or export CLAWDBOT_BROWSER_CONTROL_TOKEN) and prefer serving over Tailscale Serve or HTTPS reverse proxy.",
});
}
if (url.protocol === "http:") {
findings.push({
checkId: "browser.control_remote_http",
severity: "warn",
title: "Remote browser control uses HTTP",
detail: `browser.controlUrl=${resolved.controlUrl} is http; this is OK only if it's tailnet-only (Tailscale) or behind another encrypted tunnel.`,
remediation: `Prefer HTTPS termination (Tailscale Serve) and keep the endpoint tailnet-only.`,
});
}
if (controlToken && controlToken.length < 24) {
findings.push({
checkId: "browser.control_token_too_short",
severity: "warn",
title: "Browser control token looks short",
detail: `browser control token is ${controlToken.length} chars; prefer a long random token.`,
});
}
const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";
const gatewayAuth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, tailscaleMode });
const gatewayToken =
gatewayAuth.mode === "token" && typeof gatewayAuth.token === "string" && gatewayAuth.token.trim()
? gatewayAuth.token.trim()
: null;
if (controlToken && gatewayToken && controlToken === gatewayToken) {
findings.push({
checkId: "browser.control_token_reuse_gateway_token",
severity: "warn",
title: "Browser control token reuses the Gateway token",
detail: `browser.controlToken matches gateway.auth token; compromise of browser control expands blast radius to the Gateway API.`,
remediation: `Use a separate browser.controlToken dedicated to browser control.`,
});
}
}
return findings;
}
function collectLoggingFindings(cfg: ClawdbotConfig): SecurityAuditFinding[] {
const redact = cfg.logging?.redactSensitive;
if (redact !== "off") return [];
@@ -500,6 +582,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise<Secu
const configPath = opts.configPath ?? resolveConfigPath();
findings.push(...collectGatewayConfigFindings(cfg));
findings.push(...collectBrowserControlFindings(cfg));
findings.push(...collectLoggingFindings(cfg));
findings.push(...collectElevatedFindings(cfg));