diff --git a/CHANGELOG.md b/CHANGELOG.md index 495c2caa8..d97bd0cf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Docs: https://docs.clawd.bot ## 2026.1.23 (Unreleased) ### Changes +- Browser: add node-host proxy auto-routing for remote gateways (configurable per gateway/node). - Plugins: add optional llm-task JSON-only tool for workflows. (#1498) Thanks @vignesh07. - CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it. - CLI: add live auth probes to `clawdbot models status` for per-profile verification. diff --git a/docs/cli/node.md b/docs/cli/node.md index 8f95a54ea..2a1d25b0f 100644 --- a/docs/cli/node.md +++ b/docs/cli/node.md @@ -23,6 +23,24 @@ Common use cases: Execution is still guarded by **exec approvals** and per‑agent allowlists on the node host, so you can keep command access scoped and explicit. +## Browser proxy (zero-config) + +Node hosts automatically advertise a browser proxy if `browser.enabled` is not +disabled on the node. This lets the agent use browser automation on that node +without extra configuration. + +Disable it on the node if needed: + +```json5 +{ + nodeHost: { + browserProxy: { + enabled: false + } + } +} +``` + ## Run (foreground) ```bash diff --git a/docs/tools/browser.md b/docs/tools/browser.md index a897b7412..3bf597e33 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -166,6 +166,19 @@ Clawdbot preserves the auth when calling `/json/*` endpoints and when connecting to the CDP WebSocket. Prefer environment variables or secrets managers for tokens instead of committing them to config files. +### Node browser proxy (zero-config default) + +If you run a **node host** on the machine that has your browser, Clawdbot can +auto-route browser tool calls to that node without any custom `controlUrl` +setup. This is the default path for remote gateways. + +Notes: +- The node host exposes its local browser control server via a **proxy command**. +- Profiles come from the node’s own `browser.profiles` config (same as local). +- Disable if you don’t want it: + - On the node: `nodeHost.browserProxy.enabled=false` + - On the gateway: `gateway.nodes.browser.mode="off"` + ### Browserless (hosted remote CDP) [Browserless](https://browserless.io) is a hosted Chromium service that exposes diff --git a/src/agents/tools/browser-tool.schema.ts b/src/agents/tools/browser-tool.schema.ts index a04b7f8d0..5861f7de4 100644 --- a/src/agents/tools/browser-tool.schema.ts +++ b/src/agents/tools/browser-tool.schema.ts @@ -35,7 +35,7 @@ const BROWSER_TOOL_ACTIONS = [ "act", ] as const; -const BROWSER_TARGETS = ["sandbox", "host", "custom"] as const; +const BROWSER_TARGETS = ["sandbox", "host", "custom", "node"] as const; const BROWSER_SNAPSHOT_FORMATS = ["aria", "ai"] as const; const BROWSER_SNAPSHOT_MODES = ["efficient"] as const; @@ -84,6 +84,7 @@ const BrowserActSchema = Type.Object({ export const BrowserToolSchema = Type.Object({ action: stringEnum(BROWSER_TOOL_ACTIONS), target: optionalStringEnum(BROWSER_TARGETS), + node: Type.Optional(Type.String()), profile: Type.Optional(Type.String()), controlUrl: Type.Optional(Type.String()), targetUrl: Type.Optional(Type.String()), diff --git a/src/agents/tools/browser-tool.test.ts b/src/agents/tools/browser-tool.test.ts index 286589fcd..e50082d66 100644 --- a/src/agents/tools/browser-tool.test.ts +++ b/src/agents/tools/browser-tool.test.ts @@ -49,6 +49,25 @@ const browserConfigMocks = vi.hoisted(() => ({ })); vi.mock("../../browser/config.js", () => browserConfigMocks); +const nodesUtilsMocks = vi.hoisted(() => ({ + listNodes: vi.fn(async () => []), +})); +vi.mock("./nodes-utils.js", async () => { + const actual = await vi.importActual("./nodes-utils.js"); + return { + ...actual, + listNodes: nodesUtilsMocks.listNodes, + }; +}); + +const gatewayMocks = vi.hoisted(() => ({ + callGatewayTool: vi.fn(async () => ({ + ok: true, + payload: { result: { ok: true, running: true } }, + })), +})); +vi.mock("./gateway.js", () => gatewayMocks); + const configMocks = vi.hoisted(() => ({ loadConfig: vi.fn(() => ({ browser: {} })), })); @@ -72,6 +91,7 @@ describe("browser tool snapshot maxChars", () => { afterEach(() => { vi.clearAllMocks(); configMocks.loadConfig.mockReturnValue({ browser: {} }); + nodesUtilsMocks.listNodes.mockResolvedValue([]); }); it("applies the default ai snapshot limit", async () => { @@ -175,6 +195,70 @@ describe("browser tool snapshot maxChars", () => { }), ); }); + + it("routes to node proxy when target=node", async () => { + nodesUtilsMocks.listNodes.mockResolvedValue([ + { + nodeId: "node-1", + displayName: "Browser Node", + connected: true, + caps: ["browser"], + commands: ["browser.proxy"], + }, + ]); + const tool = createBrowserTool(); + await tool.execute?.(null, { action: "status", target: "node" }); + + expect(gatewayMocks.callGatewayTool).toHaveBeenCalledWith( + "node.invoke", + { timeoutMs: 20000 }, + expect.objectContaining({ + nodeId: "node-1", + command: "browser.proxy", + }), + ); + expect(browserClientMocks.browserStatus).not.toHaveBeenCalled(); + }); + + it("keeps sandbox control url when node proxy is available", async () => { + nodesUtilsMocks.listNodes.mockResolvedValue([ + { + nodeId: "node-1", + displayName: "Browser Node", + connected: true, + caps: ["browser"], + commands: ["browser.proxy"], + }, + ]); + const tool = createBrowserTool({ defaultControlUrl: "http://127.0.0.1:9999" }); + await tool.execute?.(null, { action: "status" }); + + expect(browserClientMocks.browserStatus).toHaveBeenCalledWith( + "http://127.0.0.1:9999", + expect.objectContaining({ profile: undefined }), + ); + expect(gatewayMocks.callGatewayTool).not.toHaveBeenCalled(); + }); + + it("keeps chrome profile on host when node proxy is available", async () => { + nodesUtilsMocks.listNodes.mockResolvedValue([ + { + nodeId: "node-1", + displayName: "Browser Node", + connected: true, + caps: ["browser"], + commands: ["browser.proxy"], + }, + ]); + const tool = createBrowserTool(); + await tool.execute?.(null, { action: "status", profile: "chrome" }); + + expect(browserClientMocks.browserStatus).toHaveBeenCalledWith( + "http://127.0.0.1:18791", + expect.objectContaining({ profile: "chrome" }), + ); + expect(gatewayMocks.callGatewayTool).not.toHaveBeenCalled(); + }); }); describe("browser tool snapshot labels", () => { diff --git a/src/agents/tools/browser-tool.ts b/src/agents/tools/browser-tool.ts index 7513d171a..998059b54 100644 --- a/src/agents/tools/browser-tool.ts +++ b/src/agents/tools/browser-tool.ts @@ -18,11 +18,173 @@ import { browserPdfSave, browserScreenshotAction, } from "../../browser/client-actions.js"; +import crypto from "node:crypto"; + import { resolveBrowserConfig } from "../../browser/config.js"; import { DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "../../browser/constants.js"; import { loadConfig } from "../../config/config.js"; +import { saveMediaBuffer } from "../../media/store.js"; +import { listNodes, resolveNodeIdFromList, type NodeListNode } from "./nodes-utils.js"; import { BrowserToolSchema } from "./browser-tool.schema.js"; import { type AnyAgentTool, imageResultFromFile, jsonResult, readStringParam } from "./common.js"; +import { callGatewayTool } from "./gateway.js"; + +type BrowserProxyFile = { + path: string; + base64: string; + mimeType?: string; +}; + +type BrowserProxyResult = { + result: unknown; + files?: BrowserProxyFile[]; +}; + +const DEFAULT_BROWSER_PROXY_TIMEOUT_MS = 20_000; + +type BrowserNodeTarget = { + nodeId: string; + label?: string; +}; + +function isBrowserNode(node: NodeListNode) { + const caps = Array.isArray(node.caps) ? node.caps : []; + const commands = Array.isArray(node.commands) ? node.commands : []; + return caps.includes("browser") || commands.includes("browser.proxy"); +} + +async function resolveBrowserNodeTarget(params: { + requestedNode?: string; + target?: "sandbox" | "host" | "custom" | "node"; + controlUrl?: string; + defaultControlUrl?: string; +}): Promise { + const cfg = loadConfig(); + const policy = cfg.gateway?.nodes?.browser; + const mode = policy?.mode ?? "auto"; + if (mode === "off") { + if (params.target === "node" || params.requestedNode) { + throw new Error("Node browser proxy is disabled (gateway.nodes.browser.mode=off)."); + } + return null; + } + if (params.defaultControlUrl?.trim() && params.target !== "node" && !params.requestedNode) { + return null; + } + if (params.controlUrl?.trim()) return null; + if (params.target && params.target !== "node") return null; + if (mode === "manual" && params.target !== "node" && !params.requestedNode) { + return null; + } + + const nodes = await listNodes({}); + const browserNodes = nodes.filter((node) => node.connected && isBrowserNode(node)); + if (browserNodes.length === 0) { + if (params.target === "node" || params.requestedNode) { + throw new Error("No connected browser-capable nodes."); + } + return null; + } + + const requested = params.requestedNode?.trim() || policy?.node?.trim(); + if (requested) { + const nodeId = resolveNodeIdFromList(browserNodes, requested, false); + const node = browserNodes.find((entry) => entry.nodeId === nodeId); + return { nodeId, label: node?.displayName ?? node?.remoteIp ?? nodeId }; + } + + if (params.target === "node") { + if (browserNodes.length === 1) { + const node = browserNodes[0]!; + return { nodeId: node.nodeId, label: node.displayName ?? node.remoteIp ?? node.nodeId }; + } + throw new Error( + `Multiple browser-capable nodes connected (${browserNodes.length}). Set gateway.nodes.browser.node or pass node=.`, + ); + } + + if (mode === "manual") return null; + + if (browserNodes.length === 1) { + const node = browserNodes[0]!; + return { nodeId: node.nodeId, label: node.displayName ?? node.remoteIp ?? node.nodeId }; + } + return null; +} + +async function callBrowserProxy(params: { + nodeId: string; + method: string; + path: string; + query?: Record; + body?: unknown; + timeoutMs?: number; + profile?: string; +}): Promise { + const gatewayTimeoutMs = + typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs) + ? Math.max(1, Math.floor(params.timeoutMs)) + : DEFAULT_BROWSER_PROXY_TIMEOUT_MS; + const payload = (await callGatewayTool( + "node.invoke", + { timeoutMs: gatewayTimeoutMs }, + { + nodeId: params.nodeId, + command: "browser.proxy", + params: { + method: params.method, + path: params.path, + query: params.query, + body: params.body, + timeoutMs: params.timeoutMs, + profile: params.profile, + }, + idempotencyKey: crypto.randomUUID(), + }, + )) as { + ok?: boolean; + payload?: BrowserProxyResult; + payloadJSON?: string | null; + }; + const parsed = + payload?.payload ?? + (typeof payload?.payloadJSON === "string" && payload.payloadJSON + ? (JSON.parse(payload.payloadJSON) as BrowserProxyResult) + : null); + if (!parsed || typeof parsed !== "object") { + throw new Error("browser proxy failed"); + } + return parsed; +} + +async function persistProxyFiles(files: BrowserProxyFile[] | undefined) { + if (!files || files.length === 0) return new Map(); + const mapping = new Map(); + for (const file of files) { + const buffer = Buffer.from(file.base64, "base64"); + const saved = await saveMediaBuffer(buffer, file.mimeType, "browser", buffer.byteLength); + mapping.set(file.path, saved.path); + } + return mapping; +} + +function applyProxyPaths(result: unknown, mapping: Map) { + if (!result || typeof result !== "object") return; + const obj = result as Record; + if (typeof obj.path === "string" && mapping.has(obj.path)) { + obj.path = mapping.get(obj.path); + } + if (typeof obj.imagePath === "string" && mapping.has(obj.imagePath)) { + obj.imagePath = mapping.get(obj.imagePath); + } + const download = obj.download; + if (download && typeof download === "object") { + const d = download as Record; + if (typeof d.path === "string" && mapping.has(d.path)) { + d.path = mapping.get(d.path); + } + } +} function resolveBrowserBaseUrl(params: { target?: "sandbox" | "host" | "custom"; @@ -127,11 +289,12 @@ export function createBrowserTool(opts?: { "Control the browser via Clawdbot's browser control server (status/start/stop/profiles/tabs/open/snapshot/screenshot/actions).", 'Profiles: use profile="chrome" for Chrome extension relay takeover (your existing Chrome tabs). Use profile="clawd" for the isolated clawd-managed browser.', 'If the user mentions the Chrome extension / Browser Relay / toolbar button / “attach tab”, ALWAYS use profile="chrome" (do not ask which profile).', + 'When a node-hosted browser proxy is available, the tool may auto-route to it. Pin a node with node= or target="node".', "Chrome extension relay needs an attached tab: user must click the Clawdbot Browser Relay toolbar icon on the tab (badge ON). If no tab is connected, ask them to attach it.", "When using refs from snapshot (e.g. e12), keep the same tab: prefer passing targetId from the snapshot response into subsequent actions (act/click/type/etc).", 'For stable, self-resolving refs across calls, use snapshot with refs="aria" (Playwright aria-ref ids). Default refs="role" are role+name-based.', "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}.`, + `target selects browser location (sandbox|host|custom|node). Default: ${targetDefault}.`, "controlUrl implies target=custom (remote control server).", hostHint, allowlistHint, @@ -142,49 +305,184 @@ export function createBrowserTool(opts?: { const action = readStringParam(params, "action", { required: true }); const controlUrl = readStringParam(params, "controlUrl"); const profile = readStringParam(params, "profile"); - let target = readStringParam(params, "target") as "sandbox" | "host" | "custom" | undefined; - if (profile === "chrome" && !target && !controlUrl?.trim()) { - // Chrome extension relay takeover is a host Chrome feature; default to host even in sandboxed sessions. + const requestedNode = readStringParam(params, "node"); + let target = readStringParam(params, "target") as + | "sandbox" + | "host" + | "custom" + | "node" + | undefined; + + if (controlUrl?.trim() && (target === "node" || requestedNode)) { + throw new Error('controlUrl is not supported with target="node".'); + } + if (target === "custom" && requestedNode) { + throw new Error('node is not supported with target="custom".'); + } + + if (!target && !controlUrl?.trim() && !requestedNode && profile === "chrome") { + // Chrome extension relay takeover is a host Chrome feature; prefer host unless explicitly targeting a node. target = "host"; } - const baseUrl = resolveBrowserBaseUrl({ + + const nodeTarget = await resolveBrowserNodeTarget({ + requestedNode: requestedNode ?? undefined, target, controlUrl, defaultControlUrl: opts?.defaultControlUrl, - allowHostControl: opts?.allowHostControl, - allowedControlUrls: opts?.allowedControlUrls, - allowedControlHosts: opts?.allowedControlHosts, - allowedControlPorts: opts?.allowedControlPorts, }); + const resolvedTarget = target === "node" ? undefined : target; + const baseUrl = nodeTarget + ? "" + : resolveBrowserBaseUrl({ + target: resolvedTarget, + controlUrl, + defaultControlUrl: opts?.defaultControlUrl, + allowHostControl: opts?.allowHostControl, + allowedControlUrls: opts?.allowedControlUrls, + allowedControlHosts: opts?.allowedControlHosts, + allowedControlPorts: opts?.allowedControlPorts, + }); + + const proxyRequest = nodeTarget + ? async (opts: { + method: string; + path: string; + query?: Record; + body?: unknown; + timeoutMs?: number; + profile?: string; + }) => { + const proxy = await callBrowserProxy({ + nodeId: nodeTarget.nodeId, + method: opts.method, + path: opts.path, + query: opts.query, + body: opts.body, + timeoutMs: opts.timeoutMs, + profile: opts.profile, + }); + const mapping = await persistProxyFiles(proxy.files); + applyProxyPaths(proxy.result, mapping); + return proxy.result; + } + : null; + switch (action) { case "status": + if (proxyRequest) { + return jsonResult( + await proxyRequest({ + method: "GET", + path: "/", + profile, + }), + ); + } return jsonResult(await browserStatus(baseUrl, { profile })); case "start": + if (proxyRequest) { + await proxyRequest({ + method: "POST", + path: "/start", + profile, + }); + return jsonResult( + await proxyRequest({ + method: "GET", + path: "/", + profile, + }), + ); + } await browserStart(baseUrl, { profile }); return jsonResult(await browserStatus(baseUrl, { profile })); case "stop": + if (proxyRequest) { + await proxyRequest({ + method: "POST", + path: "/stop", + profile, + }); + return jsonResult( + await proxyRequest({ + method: "GET", + path: "/", + profile, + }), + ); + } await browserStop(baseUrl, { profile }); return jsonResult(await browserStatus(baseUrl, { profile })); case "profiles": + if (proxyRequest) { + const result = await proxyRequest({ + method: "GET", + path: "/profiles", + }); + return jsonResult(result); + } return jsonResult({ profiles: await browserProfiles(baseUrl) }); case "tabs": + if (proxyRequest) { + const result = await proxyRequest({ + method: "GET", + path: "/tabs", + profile, + }); + const tabs = (result as { tabs?: unknown[] }).tabs ?? []; + return jsonResult({ tabs }); + } return jsonResult({ tabs: await browserTabs(baseUrl, { profile }) }); case "open": { const targetUrl = readStringParam(params, "targetUrl", { required: true, }); + if (proxyRequest) { + const result = await proxyRequest({ + method: "POST", + path: "/tabs/open", + profile, + body: { url: targetUrl }, + }); + return jsonResult(result); + } return jsonResult(await browserOpenTab(baseUrl, targetUrl, { profile })); } case "focus": { const targetId = readStringParam(params, "targetId", { required: true, }); + if (proxyRequest) { + const result = await proxyRequest({ + method: "POST", + path: "/tabs/focus", + profile, + body: { targetId }, + }); + return jsonResult(result); + } await browserFocusTab(baseUrl, targetId, { profile }); return jsonResult({ ok: true }); } case "close": { const targetId = readStringParam(params, "targetId"); + if (proxyRequest) { + const result = targetId + ? await proxyRequest({ + method: "DELETE", + path: `/tabs/${encodeURIComponent(targetId)}`, + profile, + }) + : await proxyRequest({ + method: "POST", + path: "/act", + profile, + body: { kind: "close" }, + }); + return jsonResult(result); + } if (targetId) await browserCloseTab(baseUrl, targetId, { profile }); else await browserAct(baseUrl, { kind: "close" }, { profile }); return jsonResult({ ok: true }); @@ -232,21 +530,41 @@ export function createBrowserTool(opts?: { : undefined; const selector = typeof params.selector === "string" ? params.selector.trim() : undefined; const frame = typeof params.frame === "string" ? params.frame.trim() : undefined; - const snapshot = await browserSnapshot(baseUrl, { - format, - targetId, - limit, - ...(typeof resolvedMaxChars === "number" ? { maxChars: resolvedMaxChars } : {}), - refs, - interactive, - compact, - depth, - selector, - frame, - labels, - mode, - profile, - }); + const snapshot = proxyRequest + ? ((await proxyRequest({ + method: "GET", + path: "/snapshot", + profile, + query: { + format, + targetId, + limit, + ...(typeof resolvedMaxChars === "number" ? { maxChars: resolvedMaxChars } : {}), + refs, + interactive, + compact, + depth, + selector, + frame, + labels, + mode, + }, + })) as Awaited>) + : await browserSnapshot(baseUrl, { + format, + targetId, + limit, + ...(typeof resolvedMaxChars === "number" ? { maxChars: resolvedMaxChars } : {}), + refs, + interactive, + compact, + depth, + selector, + frame, + labels, + mode, + profile, + }); if (snapshot.format === "ai") { if (labels && snapshot.imagePath) { return await imageResultFromFile({ @@ -269,14 +587,27 @@ export function createBrowserTool(opts?: { const ref = readStringParam(params, "ref"); const element = readStringParam(params, "element"); const type = params.type === "jpeg" ? "jpeg" : "png"; - const result = await browserScreenshotAction(baseUrl, { - targetId, - fullPage, - ref, - element, - type, - profile, - }); + const result = proxyRequest + ? ((await proxyRequest({ + method: "POST", + path: "/screenshot", + profile, + body: { + targetId, + fullPage, + ref, + element, + type, + }, + })) as Awaited>) + : await browserScreenshotAction(baseUrl, { + targetId, + fullPage, + ref, + element, + type, + profile, + }); return await imageResultFromFile({ label: "browser:screenshot", path: result.path, @@ -288,6 +619,18 @@ export function createBrowserTool(opts?: { required: true, }); const targetId = readStringParam(params, "targetId"); + if (proxyRequest) { + const result = await proxyRequest({ + method: "POST", + path: "/navigate", + profile, + body: { + url: targetUrl, + targetId, + }, + }); + return jsonResult(result); + } return jsonResult( await browserNavigate(baseUrl, { url: targetUrl, @@ -299,11 +642,30 @@ export function createBrowserTool(opts?: { case "console": { const level = typeof params.level === "string" ? params.level.trim() : undefined; const targetId = typeof params.targetId === "string" ? params.targetId.trim() : undefined; + if (proxyRequest) { + const result = await proxyRequest({ + method: "GET", + path: "/console", + profile, + query: { + level, + targetId, + }, + }); + return jsonResult(result); + } return jsonResult(await browserConsoleMessages(baseUrl, { level, targetId, profile })); } case "pdf": { const targetId = typeof params.targetId === "string" ? params.targetId.trim() : undefined; - const result = await browserPdfSave(baseUrl, { targetId, profile }); + const result = proxyRequest + ? ((await proxyRequest({ + method: "POST", + path: "/pdf", + profile, + body: { targetId }, + })) as Awaited>) + : await browserPdfSave(baseUrl, { targetId, profile }); return { content: [{ type: "text", text: `FILE:${result.path}` }], details: result, @@ -320,6 +682,22 @@ export function createBrowserTool(opts?: { typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs) ? params.timeoutMs : undefined; + if (proxyRequest) { + const result = await proxyRequest({ + method: "POST", + path: "/hooks/file-chooser", + profile, + body: { + paths, + ref, + inputRef, + element, + targetId, + timeoutMs, + }, + }); + return jsonResult(result); + } return jsonResult( await browserArmFileChooser(baseUrl, { paths, @@ -340,6 +718,20 @@ export function createBrowserTool(opts?: { typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs) ? params.timeoutMs : undefined; + if (proxyRequest) { + const result = await proxyRequest({ + method: "POST", + path: "/hooks/dialog", + profile, + body: { + accept, + promptText, + targetId, + timeoutMs, + }, + }); + return jsonResult(result); + } return jsonResult( await browserArmDialog(baseUrl, { accept, @@ -356,14 +748,29 @@ export function createBrowserTool(opts?: { throw new Error("request required"); } try { - const result = await browserAct(baseUrl, request as Parameters[1], { - profile, - }); + const result = proxyRequest + ? await proxyRequest({ + method: "POST", + path: "/act", + profile, + body: request, + }) + : await browserAct(baseUrl, request as Parameters[1], { + profile, + }); return jsonResult(result); } catch (err) { const msg = String(err); if (msg.includes("404:") && msg.includes("tab not found") && profile === "chrome") { - const tabs = await browserTabs(baseUrl, { profile }).catch(() => []); + const tabs = proxyRequest + ? (( + (await proxyRequest({ + method: "GET", + path: "/tabs", + profile, + })) as { tabs?: unknown[] } + ).tabs ?? []) + : await browserTabs(baseUrl, { profile }).catch(() => []); if (!tabs.length) { throw new Error( "No Chrome tabs are attached via the Clawdbot Browser Relay extension. Click the toolbar icon on the tab you want to control (badge ON), then retry.", diff --git a/src/config/schema.ts b/src/config/schema.ts index 79a03c8e6..f9601962f 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -50,6 +50,7 @@ const GROUP_LABELS: Record = { diagnostics: "Diagnostics", logging: "Logging", gateway: "Gateway", + nodeHost: "Node Host", agents: "Agents", tools: "Tools", bindings: "Bindings", @@ -76,6 +77,7 @@ const GROUP_ORDER: Record = { update: 25, diagnostics: 27, gateway: 30, + nodeHost: 35, agents: 40, tools: 50, bindings: 55, @@ -193,8 +195,12 @@ const FIELD_LABELS: Record = { "gateway.http.endpoints.chatCompletions.enabled": "OpenAI Chat Completions Endpoint", "gateway.reload.mode": "Config Reload Mode", "gateway.reload.debounceMs": "Config Reload Debounce (ms)", + "gateway.nodes.browser.mode": "Gateway Node Browser Mode", + "gateway.nodes.browser.node": "Gateway Node Browser Pin", "gateway.nodes.allowCommands": "Gateway Node Allowlist (Extra Commands)", "gateway.nodes.denyCommands": "Gateway Node Denylist", + "nodeHost.browserProxy.enabled": "Node Browser Proxy Enabled", + "nodeHost.browserProxy.allowProfiles": "Node Browser Proxy Allowed Profiles", "skills.load.watch": "Watch Skills", "skills.load.watchDebounceMs": "Skills Watch Debounce (ms)", "agents.defaults.workspace": "Workspace", @@ -366,10 +372,16 @@ const FIELD_HELP: Record = { "Enable the OpenAI-compatible `POST /v1/chat/completions` endpoint (default: false).", "gateway.reload.mode": 'Hot reload strategy for config changes ("hybrid" recommended).', "gateway.reload.debounceMs": "Debounce window (ms) before applying config changes.", + "gateway.nodes.browser.mode": + 'Node browser routing ("auto" = pick single connected browser node, "manual" = require node param, "off" = disable).', + "gateway.nodes.browser.node": "Pin browser routing to a specific node id or name (optional).", "gateway.nodes.allowCommands": "Extra node.invoke commands to allow beyond the gateway defaults (array of command strings).", "gateway.nodes.denyCommands": "Commands to block even if present in node claims or default allowlist.", + "nodeHost.browserProxy.enabled": "Expose the local browser control server via node proxy.", + "nodeHost.browserProxy.allowProfiles": + "Optional allowlist of browser profile names exposed via the node proxy.", "diagnostics.cacheTrace.enabled": "Log cache trace snapshots for embedded agent runs (default: false).", "diagnostics.cacheTrace.filePath": diff --git a/src/config/types.clawdbot.ts b/src/config/types.clawdbot.ts index 102febcf2..1a95ca2e9 100644 --- a/src/config/types.clawdbot.ts +++ b/src/config/types.clawdbot.ts @@ -18,6 +18,7 @@ import type { MessagesConfig, } from "./types.messages.js"; import type { ModelsConfig } from "./types.models.js"; +import type { NodeHostConfig } from "./types.node-host.js"; import type { PluginsConfig } from "./types.plugins.js"; import type { SkillsConfig } from "./types.skills.js"; import type { ToolsConfig } from "./types.tools.js"; @@ -75,6 +76,7 @@ export type ClawdbotConfig = { skills?: SkillsConfig; plugins?: PluginsConfig; models?: ModelsConfig; + nodeHost?: NodeHostConfig; agents?: AgentsConfig; tools?: ToolsConfig; bindings?: AgentBinding[]; diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index cb57d72ce..bcf1a23aa 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -175,6 +175,13 @@ export type GatewayHttpConfig = { }; export type GatewayNodesConfig = { + /** Browser routing policy for node-hosted browser proxies. */ + browser?: { + /** Routing mode (default: auto). */ + mode?: "auto" | "manual" | "off"; + /** Pin to a specific node id/name (optional). */ + node?: string; + }; /** Additional node.invoke commands to allow on the gateway. */ allowCommands?: string[]; /** Commands to deny even if they appear in the defaults or node claims. */ diff --git a/src/config/types.node-host.ts b/src/config/types.node-host.ts new file mode 100644 index 000000000..aa13e6e37 --- /dev/null +++ b/src/config/types.node-host.ts @@ -0,0 +1,11 @@ +export type NodeHostBrowserProxyConfig = { + /** Enable the browser proxy on the node host (default: true). */ + enabled?: boolean; + /** Optional allowlist of profile names exposed via the proxy. */ + allowProfiles?: string[]; +}; + +export type NodeHostConfig = { + /** Browser proxy settings for node hosts. */ + browserProxy?: NodeHostBrowserProxyConfig; +}; diff --git a/src/config/types.ts b/src/config/types.ts index 368618262..ecb722ef1 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -14,6 +14,7 @@ export * from "./types.hooks.js"; export * from "./types.imessage.js"; export * from "./types.messages.js"; export * from "./types.models.js"; +export * from "./types.node-host.js"; export * from "./types.msteams.js"; export * from "./types.plugins.js"; export * from "./types.queue.js"; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index b44ce119c..b8233d14c 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -13,6 +13,19 @@ const BrowserSnapshotDefaultsSchema = z .strict() .optional(); +const NodeHostSchema = z + .object({ + browserProxy: z + .object({ + enabled: z.boolean().optional(), + allowProfiles: z.array(z.string()).optional(), + }) + .strict() + .optional(), + }) + .strict() + .optional(); + export const ClawdbotSchema = z .object({ meta: z @@ -193,6 +206,7 @@ export const ClawdbotSchema = z .strict() .optional(), models: ModelsConfigSchema, + nodeHost: NodeHostSchema, agents: AgentsSchema, tools: ToolsSchema, bindings: BindingsSchema, @@ -403,6 +417,15 @@ export const ClawdbotSchema = z .optional(), nodes: z .object({ + browser: z + .object({ + mode: z + .union([z.literal("auto"), z.literal("manual"), z.literal("off")]) + .optional(), + node: z.string().optional(), + }) + .strict() + .optional(), allowCommands: z.array(z.string()).optional(), denyCommands: z.array(z.string()).optional(), }) diff --git a/src/gateway/node-command-policy.ts b/src/gateway/node-command-policy.ts index 117a5eef9..f6f09c789 100644 --- a/src/gateway/node-command-policy.ts +++ b/src/gateway/node-command-policy.ts @@ -26,6 +26,7 @@ const SYSTEM_COMMANDS = [ "system.notify", "system.execApprovals.get", "system.execApprovals.set", + "browser.proxy", ]; const PLATFORM_DEFAULTS: Record = { diff --git a/src/node-host/runner.ts b/src/node-host/runner.ts index 1274d83a5..151659713 100644 --- a/src/node-host/runner.ts +++ b/src/node-host/runner.ts @@ -1,6 +1,7 @@ import crypto from "node:crypto"; import { spawn } from "node:child_process"; import fs from "node:fs"; +import fsPromises from "node:fs/promises"; import path from "node:path"; import { @@ -30,6 +31,8 @@ import { import { getMachineDisplayName } from "../infra/machine-name.js"; import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js"; import { loadConfig } from "../config/config.js"; +import { resolveBrowserConfig, shouldStartLocalBrowserServer } from "../browser/config.js"; +import { detectMime } from "../media/mime.js"; import { resolveAgentConfig } from "../agents/agent-scope.js"; import { ensureClawdbotCliOnPath } from "../infra/path-env.js"; import { VERSION } from "../version.js"; @@ -65,6 +68,26 @@ type SystemWhichParams = { bins: string[]; }; +type BrowserProxyParams = { + method?: string; + path?: string; + query?: Record; + body?: unknown; + timeoutMs?: number; + profile?: string; +}; + +type BrowserProxyFile = { + path: string; + base64: string; + mimeType?: string; +}; + +type BrowserProxyResult = { + result: unknown; + files?: BrowserProxyFile[]; +}; + type SystemExecApprovalsSetParams = { file: ExecApprovalsFile; baseHash?: string | null; @@ -111,6 +134,7 @@ type NodeInvokeRequestPayload = { const OUTPUT_CAP = 200_000; const OUTPUT_EVENT_TAIL = 20_000; const DEFAULT_NODE_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; +const BROWSER_PROXY_MAX_FILE_BYTES = 10 * 1024 * 1024; const execHostEnforced = process.env.CLAWDBOT_NODE_EXEC_HOST?.trim().toLowerCase() === "app"; const execHostFallbackAllowed = @@ -187,6 +211,72 @@ function sanitizeEnv( return merged; } +function normalizeProfileAllowlist(raw?: string[]): string[] { + return Array.isArray(raw) ? raw.map((entry) => entry.trim()).filter(Boolean) : []; +} + +function resolveBrowserProxyConfig() { + const cfg = loadConfig(); + const proxy = cfg.nodeHost?.browserProxy; + const allowProfiles = normalizeProfileAllowlist(proxy?.allowProfiles); + const enabled = proxy?.enabled !== false; + return { enabled, allowProfiles }; +} + +let browserControlReady: Promise | null = null; + +async function ensureBrowserControlServer(): Promise { + if (browserControlReady) return browserControlReady; + browserControlReady = (async () => { + const cfg = loadConfig(); + const resolved = resolveBrowserConfig(cfg.browser); + if (!resolved.enabled) { + throw new Error("browser control disabled"); + } + if (!shouldStartLocalBrowserServer(resolved)) { + throw new Error("browser control URL is non-loopback"); + } + const mod = await import("../browser/server.js"); + await mod.startBrowserControlServerFromConfig(); + })(); + return browserControlReady; +} + +function isProfileAllowed(params: { allowProfiles: string[]; profile?: string | null }) { + const { allowProfiles, profile } = params; + if (!allowProfiles.length) return true; + if (!profile) return false; + return allowProfiles.includes(profile.trim()); +} + +function collectBrowserProxyPaths(payload: unknown): string[] { + const paths = new Set(); + const obj = + typeof payload === "object" && payload !== null ? (payload as Record) : null; + if (!obj) return []; + if (typeof obj.path === "string" && obj.path.trim()) paths.add(obj.path.trim()); + if (typeof obj.imagePath === "string" && obj.imagePath.trim()) paths.add(obj.imagePath.trim()); + const download = obj.download; + if (download && typeof download === "object") { + const dlPath = (download as Record).path; + if (typeof dlPath === "string" && dlPath.trim()) paths.add(dlPath.trim()); + } + return [...paths]; +} + +async function readBrowserProxyFile(filePath: string): Promise { + const stat = await fsPromises.stat(filePath).catch(() => null); + if (!stat || !stat.isFile()) return null; + if (stat.size > BROWSER_PROXY_MAX_FILE_BYTES) { + throw new Error( + `browser proxy file exceeds ${Math.round(BROWSER_PROXY_MAX_FILE_BYTES / (1024 * 1024))}MB`, + ); + } + const buffer = await fsPromises.readFile(filePath); + const mimeType = await detectMime({ buffer, filePath }); + return { path: filePath, base64: buffer.toString("base64"), mimeType }; +} + function formatCommand(argv: string[]): string { return argv .map((arg) => { @@ -387,6 +477,12 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise { await saveNodeHostConfig(config); const cfg = loadConfig(); + const browserProxy = resolveBrowserProxyConfig(); + const resolvedBrowser = resolveBrowserConfig(cfg.browser); + const browserProxyEnabled = + browserProxy.enabled && + resolvedBrowser.enabled && + shouldStartLocalBrowserServer(resolvedBrowser); const isRemoteMode = cfg.gateway?.mode === "remote"; const token = process.env.CLAWDBOT_GATEWAY_TOKEN?.trim() || @@ -415,12 +511,13 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise { mode: GATEWAY_CLIENT_MODES.NODE, role: "node", scopes: [], - caps: ["system"], + caps: ["system", ...(browserProxyEnabled ? ["browser"] : [])], commands: [ "system.run", "system.which", "system.execApprovals.get", "system.execApprovals.set", + ...(browserProxyEnabled ? ["browser.proxy"] : []), ], pathEnv, permissions: undefined, @@ -549,6 +646,123 @@ async function handleInvoke( return; } + if (command === "browser.proxy") { + try { + const params = decodeParams(frame.paramsJSON); + const pathValue = typeof params.path === "string" ? params.path.trim() : ""; + if (!pathValue) { + throw new Error("INVALID_REQUEST: path required"); + } + const proxyConfig = resolveBrowserProxyConfig(); + if (!proxyConfig.enabled) { + throw new Error("UNAVAILABLE: node browser proxy disabled"); + } + await ensureBrowserControlServer(); + const resolved = resolveBrowserConfig(loadConfig().browser); + const requestedProfile = typeof params.profile === "string" ? params.profile.trim() : ""; + const allowedProfiles = proxyConfig.allowProfiles; + if (allowedProfiles.length > 0) { + if (pathValue !== "/profiles") { + const profileToCheck = requestedProfile || resolved.defaultProfile; + if (!isProfileAllowed({ allowProfiles: allowedProfiles, profile: profileToCheck })) { + throw new Error("INVALID_REQUEST: browser profile not allowed"); + } + } else if (requestedProfile) { + if (!isProfileAllowed({ allowProfiles: allowedProfiles, profile: requestedProfile })) { + throw new Error("INVALID_REQUEST: browser profile not allowed"); + } + } + } + + const url = new URL( + pathValue.startsWith("/") ? pathValue : `/${pathValue}`, + resolved.controlUrl, + ); + if (requestedProfile) { + url.searchParams.set("profile", requestedProfile); + } + const query = params.query ?? {}; + for (const [key, value] of Object.entries(query)) { + if (value === undefined || value === null) continue; + url.searchParams.set(key, String(value)); + } + const method = typeof params.method === "string" ? params.method.toUpperCase() : "GET"; + const body = params.body; + const ctrl = new AbortController(); + const timeoutMs = + typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs) + ? Math.max(1, Math.floor(params.timeoutMs)) + : 20_000; + const timer = setTimeout(() => ctrl.abort(), timeoutMs); + const headers = new Headers(); + let bodyJson: string | undefined; + if (body !== undefined) { + headers.set("Content-Type", "application/json"); + bodyJson = JSON.stringify(body); + } + const token = + process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN?.trim() || resolved.controlToken?.trim(); + if (token) { + headers.set("Authorization", `Bearer ${token}`); + } + let res: Response; + try { + res = await fetch(url.toString(), { + method, + headers, + body: bodyJson, + signal: ctrl.signal, + }); + } finally { + clearTimeout(timer); + } + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(text ? `${res.status}: ${text}` : `HTTP ${res.status}`); + } + const result = (await res.json()) as unknown; + if (allowedProfiles.length > 0 && url.pathname === "/profiles") { + const obj = + typeof result === "object" && result !== null ? (result as Record) : {}; + const profiles = Array.isArray(obj.profiles) ? obj.profiles : []; + obj.profiles = profiles.filter((entry) => { + if (!entry || typeof entry !== "object") return false; + const name = (entry as Record).name; + return typeof name === "string" && allowedProfiles.includes(name); + }); + } + let files: BrowserProxyFile[] | undefined; + const paths = collectBrowserProxyPaths(result); + if (paths.length > 0) { + const loaded = await Promise.all( + paths.map(async (p) => { + try { + const file = await readBrowserProxyFile(p); + if (!file) { + throw new Error("file not found"); + } + return file; + } catch (err) { + throw new Error(`browser proxy file read failed for ${p}: ${String(err)}`); + } + }), + ); + if (loaded.length > 0) files = loaded; + } + const payload: BrowserProxyResult = files ? { result, files } : { result }; + await sendInvokeResult(client, frame, { + ok: true, + payloadJSON: JSON.stringify(payload), + }); + } catch (err) { + await sendInvokeResult(client, frame, { + ok: false, + error: { code: "INVALID_REQUEST", message: String(err) }, + }); + } + return; + } + if (command !== "system.run") { await sendInvokeResult(client, frame, { ok: false,