feat: add node browser proxy routing
parent
dd06028827
commit
c3cb26f7ca
|
|
@ -5,6 +5,7 @@ Docs: https://docs.clawd.bot
|
||||||
## 2026.1.23 (Unreleased)
|
## 2026.1.23 (Unreleased)
|
||||||
|
|
||||||
### Changes
|
### 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.
|
- 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: 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.
|
- CLI: add live auth probes to `clawdbot models status` for per-profile verification.
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,24 @@ Common use cases:
|
||||||
Execution is still guarded by **exec approvals** and per‑agent allowlists on the
|
Execution is still guarded by **exec approvals** and per‑agent allowlists on the
|
||||||
node host, so you can keep command access scoped and explicit.
|
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)
|
## Run (foreground)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -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
|
to the CDP WebSocket. Prefer environment variables or secrets managers for
|
||||||
tokens instead of committing them to config files.
|
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 (hosted remote CDP)
|
||||||
|
|
||||||
[Browserless](https://browserless.io) is a hosted Chromium service that exposes
|
[Browserless](https://browserless.io) is a hosted Chromium service that exposes
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ const BROWSER_TOOL_ACTIONS = [
|
||||||
"act",
|
"act",
|
||||||
] as const;
|
] 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_FORMATS = ["aria", "ai"] as const;
|
||||||
const BROWSER_SNAPSHOT_MODES = ["efficient"] as const;
|
const BROWSER_SNAPSHOT_MODES = ["efficient"] as const;
|
||||||
|
|
@ -84,6 +84,7 @@ const BrowserActSchema = Type.Object({
|
||||||
export const BrowserToolSchema = Type.Object({
|
export const BrowserToolSchema = Type.Object({
|
||||||
action: stringEnum(BROWSER_TOOL_ACTIONS),
|
action: stringEnum(BROWSER_TOOL_ACTIONS),
|
||||||
target: optionalStringEnum(BROWSER_TARGETS),
|
target: optionalStringEnum(BROWSER_TARGETS),
|
||||||
|
node: Type.Optional(Type.String()),
|
||||||
profile: Type.Optional(Type.String()),
|
profile: Type.Optional(Type.String()),
|
||||||
controlUrl: Type.Optional(Type.String()),
|
controlUrl: Type.Optional(Type.String()),
|
||||||
targetUrl: Type.Optional(Type.String()),
|
targetUrl: Type.Optional(Type.String()),
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,25 @@ const browserConfigMocks = vi.hoisted(() => ({
|
||||||
}));
|
}));
|
||||||
vi.mock("../../browser/config.js", () => browserConfigMocks);
|
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<typeof import("./nodes-utils.js")>("./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(() => ({
|
const configMocks = vi.hoisted(() => ({
|
||||||
loadConfig: vi.fn(() => ({ browser: {} })),
|
loadConfig: vi.fn(() => ({ browser: {} })),
|
||||||
}));
|
}));
|
||||||
|
|
@ -72,6 +91,7 @@ describe("browser tool snapshot maxChars", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
configMocks.loadConfig.mockReturnValue({ browser: {} });
|
configMocks.loadConfig.mockReturnValue({ browser: {} });
|
||||||
|
nodesUtilsMocks.listNodes.mockResolvedValue([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies the default ai snapshot limit", async () => {
|
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", () => {
|
describe("browser tool snapshot labels", () => {
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,173 @@ import {
|
||||||
browserPdfSave,
|
browserPdfSave,
|
||||||
browserScreenshotAction,
|
browserScreenshotAction,
|
||||||
} from "../../browser/client-actions.js";
|
} from "../../browser/client-actions.js";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
import { resolveBrowserConfig } from "../../browser/config.js";
|
import { resolveBrowserConfig } from "../../browser/config.js";
|
||||||
import { DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "../../browser/constants.js";
|
import { DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "../../browser/constants.js";
|
||||||
import { loadConfig } from "../../config/config.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 { BrowserToolSchema } from "./browser-tool.schema.js";
|
||||||
import { type AnyAgentTool, imageResultFromFile, jsonResult, readStringParam } from "./common.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<BrowserNodeTarget | null> {
|
||||||
|
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=<id>.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, string | number | boolean | undefined>;
|
||||||
|
body?: unknown;
|
||||||
|
timeoutMs?: number;
|
||||||
|
profile?: string;
|
||||||
|
}): Promise<BrowserProxyResult> {
|
||||||
|
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<string, string>();
|
||||||
|
const mapping = new Map<string, string>();
|
||||||
|
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<string, string>) {
|
||||||
|
if (!result || typeof result !== "object") return;
|
||||||
|
const obj = result as Record<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
if (typeof d.path === "string" && mapping.has(d.path)) {
|
||||||
|
d.path = mapping.get(d.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function resolveBrowserBaseUrl(params: {
|
function resolveBrowserBaseUrl(params: {
|
||||||
target?: "sandbox" | "host" | "custom";
|
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).",
|
"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.',
|
'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).',
|
'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=<id|name> 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.",
|
"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).",
|
"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.',
|
'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.",
|
"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).",
|
"controlUrl implies target=custom (remote control server).",
|
||||||
hostHint,
|
hostHint,
|
||||||
allowlistHint,
|
allowlistHint,
|
||||||
|
|
@ -142,49 +305,184 @@ export function createBrowserTool(opts?: {
|
||||||
const action = readStringParam(params, "action", { required: true });
|
const action = readStringParam(params, "action", { required: true });
|
||||||
const controlUrl = readStringParam(params, "controlUrl");
|
const controlUrl = readStringParam(params, "controlUrl");
|
||||||
const profile = readStringParam(params, "profile");
|
const profile = readStringParam(params, "profile");
|
||||||
let target = readStringParam(params, "target") as "sandbox" | "host" | "custom" | undefined;
|
const requestedNode = readStringParam(params, "node");
|
||||||
if (profile === "chrome" && !target && !controlUrl?.trim()) {
|
let target = readStringParam(params, "target") as
|
||||||
// Chrome extension relay takeover is a host Chrome feature; default to host even in sandboxed sessions.
|
| "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";
|
target = "host";
|
||||||
}
|
}
|
||||||
const baseUrl = resolveBrowserBaseUrl({
|
|
||||||
|
const nodeTarget = await resolveBrowserNodeTarget({
|
||||||
|
requestedNode: requestedNode ?? undefined,
|
||||||
target,
|
target,
|
||||||
controlUrl,
|
controlUrl,
|
||||||
defaultControlUrl: opts?.defaultControlUrl,
|
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<string, string | number | boolean | undefined>;
|
||||||
|
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) {
|
switch (action) {
|
||||||
case "status":
|
case "status":
|
||||||
|
if (proxyRequest) {
|
||||||
|
return jsonResult(
|
||||||
|
await proxyRequest({
|
||||||
|
method: "GET",
|
||||||
|
path: "/",
|
||||||
|
profile,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
return jsonResult(await browserStatus(baseUrl, { profile }));
|
return jsonResult(await browserStatus(baseUrl, { profile }));
|
||||||
case "start":
|
case "start":
|
||||||
|
if (proxyRequest) {
|
||||||
|
await proxyRequest({
|
||||||
|
method: "POST",
|
||||||
|
path: "/start",
|
||||||
|
profile,
|
||||||
|
});
|
||||||
|
return jsonResult(
|
||||||
|
await proxyRequest({
|
||||||
|
method: "GET",
|
||||||
|
path: "/",
|
||||||
|
profile,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
await browserStart(baseUrl, { profile });
|
await browserStart(baseUrl, { profile });
|
||||||
return jsonResult(await browserStatus(baseUrl, { profile }));
|
return jsonResult(await browserStatus(baseUrl, { profile }));
|
||||||
case "stop":
|
case "stop":
|
||||||
|
if (proxyRequest) {
|
||||||
|
await proxyRequest({
|
||||||
|
method: "POST",
|
||||||
|
path: "/stop",
|
||||||
|
profile,
|
||||||
|
});
|
||||||
|
return jsonResult(
|
||||||
|
await proxyRequest({
|
||||||
|
method: "GET",
|
||||||
|
path: "/",
|
||||||
|
profile,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
await browserStop(baseUrl, { profile });
|
await browserStop(baseUrl, { profile });
|
||||||
return jsonResult(await browserStatus(baseUrl, { profile }));
|
return jsonResult(await browserStatus(baseUrl, { profile }));
|
||||||
case "profiles":
|
case "profiles":
|
||||||
|
if (proxyRequest) {
|
||||||
|
const result = await proxyRequest({
|
||||||
|
method: "GET",
|
||||||
|
path: "/profiles",
|
||||||
|
});
|
||||||
|
return jsonResult(result);
|
||||||
|
}
|
||||||
return jsonResult({ profiles: await browserProfiles(baseUrl) });
|
return jsonResult({ profiles: await browserProfiles(baseUrl) });
|
||||||
case "tabs":
|
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 }) });
|
return jsonResult({ tabs: await browserTabs(baseUrl, { profile }) });
|
||||||
case "open": {
|
case "open": {
|
||||||
const targetUrl = readStringParam(params, "targetUrl", {
|
const targetUrl = readStringParam(params, "targetUrl", {
|
||||||
required: true,
|
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 }));
|
return jsonResult(await browserOpenTab(baseUrl, targetUrl, { profile }));
|
||||||
}
|
}
|
||||||
case "focus": {
|
case "focus": {
|
||||||
const targetId = readStringParam(params, "targetId", {
|
const targetId = readStringParam(params, "targetId", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
|
if (proxyRequest) {
|
||||||
|
const result = await proxyRequest({
|
||||||
|
method: "POST",
|
||||||
|
path: "/tabs/focus",
|
||||||
|
profile,
|
||||||
|
body: { targetId },
|
||||||
|
});
|
||||||
|
return jsonResult(result);
|
||||||
|
}
|
||||||
await browserFocusTab(baseUrl, targetId, { profile });
|
await browserFocusTab(baseUrl, targetId, { profile });
|
||||||
return jsonResult({ ok: true });
|
return jsonResult({ ok: true });
|
||||||
}
|
}
|
||||||
case "close": {
|
case "close": {
|
||||||
const targetId = readStringParam(params, "targetId");
|
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 });
|
if (targetId) await browserCloseTab(baseUrl, targetId, { profile });
|
||||||
else await browserAct(baseUrl, { kind: "close" }, { profile });
|
else await browserAct(baseUrl, { kind: "close" }, { profile });
|
||||||
return jsonResult({ ok: true });
|
return jsonResult({ ok: true });
|
||||||
|
|
@ -232,21 +530,41 @@ export function createBrowserTool(opts?: {
|
||||||
: undefined;
|
: undefined;
|
||||||
const selector = typeof params.selector === "string" ? params.selector.trim() : undefined;
|
const selector = typeof params.selector === "string" ? params.selector.trim() : undefined;
|
||||||
const frame = typeof params.frame === "string" ? params.frame.trim() : undefined;
|
const frame = typeof params.frame === "string" ? params.frame.trim() : undefined;
|
||||||
const snapshot = await browserSnapshot(baseUrl, {
|
const snapshot = proxyRequest
|
||||||
format,
|
? ((await proxyRequest({
|
||||||
targetId,
|
method: "GET",
|
||||||
limit,
|
path: "/snapshot",
|
||||||
...(typeof resolvedMaxChars === "number" ? { maxChars: resolvedMaxChars } : {}),
|
profile,
|
||||||
refs,
|
query: {
|
||||||
interactive,
|
format,
|
||||||
compact,
|
targetId,
|
||||||
depth,
|
limit,
|
||||||
selector,
|
...(typeof resolvedMaxChars === "number" ? { maxChars: resolvedMaxChars } : {}),
|
||||||
frame,
|
refs,
|
||||||
labels,
|
interactive,
|
||||||
mode,
|
compact,
|
||||||
profile,
|
depth,
|
||||||
});
|
selector,
|
||||||
|
frame,
|
||||||
|
labels,
|
||||||
|
mode,
|
||||||
|
},
|
||||||
|
})) as Awaited<ReturnType<typeof browserSnapshot>>)
|
||||||
|
: 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 (snapshot.format === "ai") {
|
||||||
if (labels && snapshot.imagePath) {
|
if (labels && snapshot.imagePath) {
|
||||||
return await imageResultFromFile({
|
return await imageResultFromFile({
|
||||||
|
|
@ -269,14 +587,27 @@ export function createBrowserTool(opts?: {
|
||||||
const ref = readStringParam(params, "ref");
|
const ref = readStringParam(params, "ref");
|
||||||
const element = readStringParam(params, "element");
|
const element = readStringParam(params, "element");
|
||||||
const type = params.type === "jpeg" ? "jpeg" : "png";
|
const type = params.type === "jpeg" ? "jpeg" : "png";
|
||||||
const result = await browserScreenshotAction(baseUrl, {
|
const result = proxyRequest
|
||||||
targetId,
|
? ((await proxyRequest({
|
||||||
fullPage,
|
method: "POST",
|
||||||
ref,
|
path: "/screenshot",
|
||||||
element,
|
profile,
|
||||||
type,
|
body: {
|
||||||
profile,
|
targetId,
|
||||||
});
|
fullPage,
|
||||||
|
ref,
|
||||||
|
element,
|
||||||
|
type,
|
||||||
|
},
|
||||||
|
})) as Awaited<ReturnType<typeof browserScreenshotAction>>)
|
||||||
|
: await browserScreenshotAction(baseUrl, {
|
||||||
|
targetId,
|
||||||
|
fullPage,
|
||||||
|
ref,
|
||||||
|
element,
|
||||||
|
type,
|
||||||
|
profile,
|
||||||
|
});
|
||||||
return await imageResultFromFile({
|
return await imageResultFromFile({
|
||||||
label: "browser:screenshot",
|
label: "browser:screenshot",
|
||||||
path: result.path,
|
path: result.path,
|
||||||
|
|
@ -288,6 +619,18 @@ export function createBrowserTool(opts?: {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const targetId = readStringParam(params, "targetId");
|
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(
|
return jsonResult(
|
||||||
await browserNavigate(baseUrl, {
|
await browserNavigate(baseUrl, {
|
||||||
url: targetUrl,
|
url: targetUrl,
|
||||||
|
|
@ -299,11 +642,30 @@ export function createBrowserTool(opts?: {
|
||||||
case "console": {
|
case "console": {
|
||||||
const level = typeof params.level === "string" ? params.level.trim() : undefined;
|
const level = typeof params.level === "string" ? params.level.trim() : undefined;
|
||||||
const targetId = typeof params.targetId === "string" ? params.targetId.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 }));
|
return jsonResult(await browserConsoleMessages(baseUrl, { level, targetId, profile }));
|
||||||
}
|
}
|
||||||
case "pdf": {
|
case "pdf": {
|
||||||
const targetId = typeof params.targetId === "string" ? params.targetId.trim() : undefined;
|
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<ReturnType<typeof browserPdfSave>>)
|
||||||
|
: await browserPdfSave(baseUrl, { targetId, profile });
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: `FILE:${result.path}` }],
|
content: [{ type: "text", text: `FILE:${result.path}` }],
|
||||||
details: result,
|
details: result,
|
||||||
|
|
@ -320,6 +682,22 @@ export function createBrowserTool(opts?: {
|
||||||
typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs)
|
typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs)
|
||||||
? params.timeoutMs
|
? params.timeoutMs
|
||||||
: undefined;
|
: 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(
|
return jsonResult(
|
||||||
await browserArmFileChooser(baseUrl, {
|
await browserArmFileChooser(baseUrl, {
|
||||||
paths,
|
paths,
|
||||||
|
|
@ -340,6 +718,20 @@ export function createBrowserTool(opts?: {
|
||||||
typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs)
|
typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs)
|
||||||
? params.timeoutMs
|
? params.timeoutMs
|
||||||
: undefined;
|
: undefined;
|
||||||
|
if (proxyRequest) {
|
||||||
|
const result = await proxyRequest({
|
||||||
|
method: "POST",
|
||||||
|
path: "/hooks/dialog",
|
||||||
|
profile,
|
||||||
|
body: {
|
||||||
|
accept,
|
||||||
|
promptText,
|
||||||
|
targetId,
|
||||||
|
timeoutMs,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return jsonResult(result);
|
||||||
|
}
|
||||||
return jsonResult(
|
return jsonResult(
|
||||||
await browserArmDialog(baseUrl, {
|
await browserArmDialog(baseUrl, {
|
||||||
accept,
|
accept,
|
||||||
|
|
@ -356,14 +748,29 @@ export function createBrowserTool(opts?: {
|
||||||
throw new Error("request required");
|
throw new Error("request required");
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await browserAct(baseUrl, request as Parameters<typeof browserAct>[1], {
|
const result = proxyRequest
|
||||||
profile,
|
? await proxyRequest({
|
||||||
});
|
method: "POST",
|
||||||
|
path: "/act",
|
||||||
|
profile,
|
||||||
|
body: request,
|
||||||
|
})
|
||||||
|
: await browserAct(baseUrl, request as Parameters<typeof browserAct>[1], {
|
||||||
|
profile,
|
||||||
|
});
|
||||||
return jsonResult(result);
|
return jsonResult(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = String(err);
|
const msg = String(err);
|
||||||
if (msg.includes("404:") && msg.includes("tab not found") && profile === "chrome") {
|
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) {
|
if (!tabs.length) {
|
||||||
throw new Error(
|
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.",
|
"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.",
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ const GROUP_LABELS: Record<string, string> = {
|
||||||
diagnostics: "Diagnostics",
|
diagnostics: "Diagnostics",
|
||||||
logging: "Logging",
|
logging: "Logging",
|
||||||
gateway: "Gateway",
|
gateway: "Gateway",
|
||||||
|
nodeHost: "Node Host",
|
||||||
agents: "Agents",
|
agents: "Agents",
|
||||||
tools: "Tools",
|
tools: "Tools",
|
||||||
bindings: "Bindings",
|
bindings: "Bindings",
|
||||||
|
|
@ -76,6 +77,7 @@ const GROUP_ORDER: Record<string, number> = {
|
||||||
update: 25,
|
update: 25,
|
||||||
diagnostics: 27,
|
diagnostics: 27,
|
||||||
gateway: 30,
|
gateway: 30,
|
||||||
|
nodeHost: 35,
|
||||||
agents: 40,
|
agents: 40,
|
||||||
tools: 50,
|
tools: 50,
|
||||||
bindings: 55,
|
bindings: 55,
|
||||||
|
|
@ -193,8 +195,12 @@ const FIELD_LABELS: Record<string, string> = {
|
||||||
"gateway.http.endpoints.chatCompletions.enabled": "OpenAI Chat Completions Endpoint",
|
"gateway.http.endpoints.chatCompletions.enabled": "OpenAI Chat Completions Endpoint",
|
||||||
"gateway.reload.mode": "Config Reload Mode",
|
"gateway.reload.mode": "Config Reload Mode",
|
||||||
"gateway.reload.debounceMs": "Config Reload Debounce (ms)",
|
"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.allowCommands": "Gateway Node Allowlist (Extra Commands)",
|
||||||
"gateway.nodes.denyCommands": "Gateway Node Denylist",
|
"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.watch": "Watch Skills",
|
||||||
"skills.load.watchDebounceMs": "Skills Watch Debounce (ms)",
|
"skills.load.watchDebounceMs": "Skills Watch Debounce (ms)",
|
||||||
"agents.defaults.workspace": "Workspace",
|
"agents.defaults.workspace": "Workspace",
|
||||||
|
|
@ -366,10 +372,16 @@ const FIELD_HELP: Record<string, string> = {
|
||||||
"Enable the OpenAI-compatible `POST /v1/chat/completions` endpoint (default: false).",
|
"Enable the OpenAI-compatible `POST /v1/chat/completions` endpoint (default: false).",
|
||||||
"gateway.reload.mode": 'Hot reload strategy for config changes ("hybrid" recommended).',
|
"gateway.reload.mode": 'Hot reload strategy for config changes ("hybrid" recommended).',
|
||||||
"gateway.reload.debounceMs": "Debounce window (ms) before applying config changes.",
|
"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":
|
"gateway.nodes.allowCommands":
|
||||||
"Extra node.invoke commands to allow beyond the gateway defaults (array of command strings).",
|
"Extra node.invoke commands to allow beyond the gateway defaults (array of command strings).",
|
||||||
"gateway.nodes.denyCommands":
|
"gateway.nodes.denyCommands":
|
||||||
"Commands to block even if present in node claims or default allowlist.",
|
"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":
|
"diagnostics.cacheTrace.enabled":
|
||||||
"Log cache trace snapshots for embedded agent runs (default: false).",
|
"Log cache trace snapshots for embedded agent runs (default: false).",
|
||||||
"diagnostics.cacheTrace.filePath":
|
"diagnostics.cacheTrace.filePath":
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import type {
|
||||||
MessagesConfig,
|
MessagesConfig,
|
||||||
} from "./types.messages.js";
|
} from "./types.messages.js";
|
||||||
import type { ModelsConfig } from "./types.models.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 { PluginsConfig } from "./types.plugins.js";
|
||||||
import type { SkillsConfig } from "./types.skills.js";
|
import type { SkillsConfig } from "./types.skills.js";
|
||||||
import type { ToolsConfig } from "./types.tools.js";
|
import type { ToolsConfig } from "./types.tools.js";
|
||||||
|
|
@ -75,6 +76,7 @@ export type ClawdbotConfig = {
|
||||||
skills?: SkillsConfig;
|
skills?: SkillsConfig;
|
||||||
plugins?: PluginsConfig;
|
plugins?: PluginsConfig;
|
||||||
models?: ModelsConfig;
|
models?: ModelsConfig;
|
||||||
|
nodeHost?: NodeHostConfig;
|
||||||
agents?: AgentsConfig;
|
agents?: AgentsConfig;
|
||||||
tools?: ToolsConfig;
|
tools?: ToolsConfig;
|
||||||
bindings?: AgentBinding[];
|
bindings?: AgentBinding[];
|
||||||
|
|
|
||||||
|
|
@ -175,6 +175,13 @@ export type GatewayHttpConfig = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GatewayNodesConfig = {
|
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. */
|
/** Additional node.invoke commands to allow on the gateway. */
|
||||||
allowCommands?: string[];
|
allowCommands?: string[];
|
||||||
/** Commands to deny even if they appear in the defaults or node claims. */
|
/** Commands to deny even if they appear in the defaults or node claims. */
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -14,6 +14,7 @@ export * from "./types.hooks.js";
|
||||||
export * from "./types.imessage.js";
|
export * from "./types.imessage.js";
|
||||||
export * from "./types.messages.js";
|
export * from "./types.messages.js";
|
||||||
export * from "./types.models.js";
|
export * from "./types.models.js";
|
||||||
|
export * from "./types.node-host.js";
|
||||||
export * from "./types.msteams.js";
|
export * from "./types.msteams.js";
|
||||||
export * from "./types.plugins.js";
|
export * from "./types.plugins.js";
|
||||||
export * from "./types.queue.js";
|
export * from "./types.queue.js";
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,19 @@ const BrowserSnapshotDefaultsSchema = z
|
||||||
.strict()
|
.strict()
|
||||||
.optional();
|
.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
|
export const ClawdbotSchema = z
|
||||||
.object({
|
.object({
|
||||||
meta: z
|
meta: z
|
||||||
|
|
@ -193,6 +206,7 @@ export const ClawdbotSchema = z
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
models: ModelsConfigSchema,
|
models: ModelsConfigSchema,
|
||||||
|
nodeHost: NodeHostSchema,
|
||||||
agents: AgentsSchema,
|
agents: AgentsSchema,
|
||||||
tools: ToolsSchema,
|
tools: ToolsSchema,
|
||||||
bindings: BindingsSchema,
|
bindings: BindingsSchema,
|
||||||
|
|
@ -403,6 +417,15 @@ export const ClawdbotSchema = z
|
||||||
.optional(),
|
.optional(),
|
||||||
nodes: z
|
nodes: z
|
||||||
.object({
|
.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(),
|
allowCommands: z.array(z.string()).optional(),
|
||||||
denyCommands: z.array(z.string()).optional(),
|
denyCommands: z.array(z.string()).optional(),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ const SYSTEM_COMMANDS = [
|
||||||
"system.notify",
|
"system.notify",
|
||||||
"system.execApprovals.get",
|
"system.execApprovals.get",
|
||||||
"system.execApprovals.set",
|
"system.execApprovals.set",
|
||||||
|
"browser.proxy",
|
||||||
];
|
];
|
||||||
|
|
||||||
const PLATFORM_DEFAULTS: Record<string, string[]> = {
|
const PLATFORM_DEFAULTS: Record<string, string[]> = {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
|
import fsPromises from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -30,6 +31,8 @@ import {
|
||||||
import { getMachineDisplayName } from "../infra/machine-name.js";
|
import { getMachineDisplayName } from "../infra/machine-name.js";
|
||||||
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
|
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
|
||||||
import { loadConfig } from "../config/config.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 { resolveAgentConfig } from "../agents/agent-scope.js";
|
||||||
import { ensureClawdbotCliOnPath } from "../infra/path-env.js";
|
import { ensureClawdbotCliOnPath } from "../infra/path-env.js";
|
||||||
import { VERSION } from "../version.js";
|
import { VERSION } from "../version.js";
|
||||||
|
|
@ -65,6 +68,26 @@ type SystemWhichParams = {
|
||||||
bins: string[];
|
bins: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type BrowserProxyParams = {
|
||||||
|
method?: string;
|
||||||
|
path?: string;
|
||||||
|
query?: Record<string, string | number | boolean | null | undefined>;
|
||||||
|
body?: unknown;
|
||||||
|
timeoutMs?: number;
|
||||||
|
profile?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BrowserProxyFile = {
|
||||||
|
path: string;
|
||||||
|
base64: string;
|
||||||
|
mimeType?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BrowserProxyResult = {
|
||||||
|
result: unknown;
|
||||||
|
files?: BrowserProxyFile[];
|
||||||
|
};
|
||||||
|
|
||||||
type SystemExecApprovalsSetParams = {
|
type SystemExecApprovalsSetParams = {
|
||||||
file: ExecApprovalsFile;
|
file: ExecApprovalsFile;
|
||||||
baseHash?: string | null;
|
baseHash?: string | null;
|
||||||
|
|
@ -111,6 +134,7 @@ type NodeInvokeRequestPayload = {
|
||||||
const OUTPUT_CAP = 200_000;
|
const OUTPUT_CAP = 200_000;
|
||||||
const OUTPUT_EVENT_TAIL = 20_000;
|
const OUTPUT_EVENT_TAIL = 20_000;
|
||||||
const DEFAULT_NODE_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
|
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 execHostEnforced = process.env.CLAWDBOT_NODE_EXEC_HOST?.trim().toLowerCase() === "app";
|
||||||
const execHostFallbackAllowed =
|
const execHostFallbackAllowed =
|
||||||
|
|
@ -187,6 +211,72 @@ function sanitizeEnv(
|
||||||
return merged;
|
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<void> | null = null;
|
||||||
|
|
||||||
|
async function ensureBrowserControlServer(): Promise<void> {
|
||||||
|
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<string>();
|
||||||
|
const obj =
|
||||||
|
typeof payload === "object" && payload !== null ? (payload as Record<string, unknown>) : 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<string, unknown>).path;
|
||||||
|
if (typeof dlPath === "string" && dlPath.trim()) paths.add(dlPath.trim());
|
||||||
|
}
|
||||||
|
return [...paths];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readBrowserProxyFile(filePath: string): Promise<BrowserProxyFile | null> {
|
||||||
|
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 {
|
function formatCommand(argv: string[]): string {
|
||||||
return argv
|
return argv
|
||||||
.map((arg) => {
|
.map((arg) => {
|
||||||
|
|
@ -387,6 +477,12 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
|
||||||
await saveNodeHostConfig(config);
|
await saveNodeHostConfig(config);
|
||||||
|
|
||||||
const cfg = loadConfig();
|
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 isRemoteMode = cfg.gateway?.mode === "remote";
|
||||||
const token =
|
const token =
|
||||||
process.env.CLAWDBOT_GATEWAY_TOKEN?.trim() ||
|
process.env.CLAWDBOT_GATEWAY_TOKEN?.trim() ||
|
||||||
|
|
@ -415,12 +511,13 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
|
||||||
mode: GATEWAY_CLIENT_MODES.NODE,
|
mode: GATEWAY_CLIENT_MODES.NODE,
|
||||||
role: "node",
|
role: "node",
|
||||||
scopes: [],
|
scopes: [],
|
||||||
caps: ["system"],
|
caps: ["system", ...(browserProxyEnabled ? ["browser"] : [])],
|
||||||
commands: [
|
commands: [
|
||||||
"system.run",
|
"system.run",
|
||||||
"system.which",
|
"system.which",
|
||||||
"system.execApprovals.get",
|
"system.execApprovals.get",
|
||||||
"system.execApprovals.set",
|
"system.execApprovals.set",
|
||||||
|
...(browserProxyEnabled ? ["browser.proxy"] : []),
|
||||||
],
|
],
|
||||||
pathEnv,
|
pathEnv,
|
||||||
permissions: undefined,
|
permissions: undefined,
|
||||||
|
|
@ -549,6 +646,123 @@ async function handleInvoke(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (command === "browser.proxy") {
|
||||||
|
try {
|
||||||
|
const params = decodeParams<BrowserProxyParams>(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<string, unknown>) : {};
|
||||||
|
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<string, unknown>).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") {
|
if (command !== "system.run") {
|
||||||
await sendInvokeResult(client, frame, {
|
await sendInvokeResult(client, frame, {
|
||||||
ok: false,
|
ok: false,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue