feat: add node browser proxy routing

main
Peter Steinberger 2026-01-24 04:19:43 +00:00
parent dd06028827
commit c3cb26f7ca
14 changed files with 834 additions and 39 deletions

View File

@ -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.

View File

@ -23,6 +23,24 @@ Common use cases:
Execution is still guarded by **exec approvals** and peragent allowlists on the Execution is still guarded by **exec approvals** and peragent 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

View File

@ -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 nodes own `browser.profiles` config (same as local).
- Disable if you dont 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

View File

@ -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()),

View File

@ -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", () => {

View File

@ -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.",

View File

@ -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":

View File

@ -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[];

View File

@ -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. */

View File

@ -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;
};

View File

@ -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";

View File

@ -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(),
}) })

View File

@ -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[]> = {

View File

@ -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,