mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 11:41:24 +00:00
refactor: route browser control via gateway/node
This commit is contained in:
@@ -9,7 +9,19 @@ export async function startBrowserControlServerIfEnabled(): Promise<BrowserContr
|
||||
// Lazy import: keeps startup fast, but still bundles for the embedded
|
||||
// gateway (bun --compile) via the static specifier path.
|
||||
const override = process.env.CLAWDBOT_BROWSER_CONTROL_MODULE?.trim();
|
||||
const mod = override ? await import(override) : await import("../browser/server.js");
|
||||
await mod.startBrowserControlServerFromConfig();
|
||||
return { stop: mod.stopBrowserControlServer };
|
||||
const mod = override ? await import(override) : await import("../browser/control-service.js");
|
||||
const start =
|
||||
typeof (mod as { startBrowserControlServiceFromConfig?: unknown })
|
||||
.startBrowserControlServiceFromConfig === "function"
|
||||
? (mod as { startBrowserControlServiceFromConfig: () => Promise<unknown> })
|
||||
.startBrowserControlServiceFromConfig
|
||||
: (mod as { startBrowserControlServerFromConfig?: () => Promise<unknown> })
|
||||
.startBrowserControlServerFromConfig;
|
||||
const stop =
|
||||
typeof (mod as { stopBrowserControlService?: unknown }).stopBrowserControlService === "function"
|
||||
? (mod as { stopBrowserControlService: () => Promise<void> }).stopBrowserControlService
|
||||
: (mod as { stopBrowserControlServer?: () => Promise<void> }).stopBrowserControlServer;
|
||||
if (!start) return null;
|
||||
await start();
|
||||
return { stop: stop ?? (async () => {}) };
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ const BASE_METHODS = [
|
||||
"agent",
|
||||
"agent.identity.get",
|
||||
"agent.wait",
|
||||
"browser.request",
|
||||
// WebChat WebSocket-native chat methods
|
||||
"chat.history",
|
||||
"chat.abort",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ErrorCodes, errorShape } from "./protocol/index.js";
|
||||
import { agentHandlers } from "./server-methods/agent.js";
|
||||
import { agentsHandlers } from "./server-methods/agents.js";
|
||||
import { browserHandlers } from "./server-methods/browser.js";
|
||||
import { channelsHandlers } from "./server-methods/channels.js";
|
||||
import { chatHandlers } from "./server-methods/chat.js";
|
||||
import { configHandlers } from "./server-methods/config.js";
|
||||
@@ -86,6 +87,7 @@ const WRITE_METHODS = new Set([
|
||||
"node.invoke",
|
||||
"chat.send",
|
||||
"chat.abort",
|
||||
"browser.request",
|
||||
]);
|
||||
|
||||
function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["client"]) {
|
||||
@@ -168,6 +170,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
|
||||
...usageHandlers,
|
||||
...agentHandlers,
|
||||
...agentsHandlers,
|
||||
...browserHandlers,
|
||||
};
|
||||
|
||||
export async function handleGatewayRequest(
|
||||
|
||||
253
src/gateway/server-methods/browser.ts
Normal file
253
src/gateway/server-methods/browser.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import crypto from "node:crypto";
|
||||
import {
|
||||
createBrowserControlContext,
|
||||
startBrowserControlServiceFromConfig,
|
||||
} from "../../browser/control-service.js";
|
||||
import { createBrowserRouteDispatcher } from "../../browser/routes/dispatcher.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import { saveMediaBuffer } from "../../media/store.js";
|
||||
import { isNodeCommandAllowed, resolveNodeCommandAllowlist } from "../node-command-policy.js";
|
||||
import type { NodeSession } from "../node-registry.js";
|
||||
import { ErrorCodes, errorShape } from "../protocol/index.js";
|
||||
import { safeParseJson } from "./nodes.helpers.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
|
||||
type BrowserRequestParams = {
|
||||
method?: string;
|
||||
path?: string;
|
||||
query?: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type BrowserProxyFile = {
|
||||
path: string;
|
||||
base64: string;
|
||||
mimeType?: string;
|
||||
};
|
||||
|
||||
type BrowserProxyResult = {
|
||||
result: unknown;
|
||||
files?: BrowserProxyFile[];
|
||||
};
|
||||
|
||||
function isBrowserNode(node: NodeSession) {
|
||||
const caps = Array.isArray(node.caps) ? node.caps : [];
|
||||
const commands = Array.isArray(node.commands) ? node.commands : [];
|
||||
return caps.includes("browser") || commands.includes("browser.proxy");
|
||||
}
|
||||
|
||||
function normalizeNodeKey(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "");
|
||||
}
|
||||
|
||||
function resolveBrowserNode(nodes: NodeSession[], query: string): NodeSession | null {
|
||||
const q = query.trim();
|
||||
if (!q) return null;
|
||||
const qNorm = normalizeNodeKey(q);
|
||||
const matches = nodes.filter((node) => {
|
||||
if (node.nodeId === q) return true;
|
||||
if (typeof node.remoteIp === "string" && node.remoteIp === q) return true;
|
||||
const name = typeof node.displayName === "string" ? node.displayName : "";
|
||||
if (name && normalizeNodeKey(name) === qNorm) return true;
|
||||
if (q.length >= 6 && node.nodeId.startsWith(q)) return true;
|
||||
return false;
|
||||
});
|
||||
if (matches.length === 1) return matches[0] ?? null;
|
||||
if (matches.length === 0) return null;
|
||||
throw new Error(
|
||||
`ambiguous node: ${q} (matches: ${matches
|
||||
.map((node) => node.displayName || node.remoteIp || node.nodeId)
|
||||
.join(", ")})`,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveBrowserNodeTarget(params: {
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
nodes: NodeSession[];
|
||||
}): NodeSession | null {
|
||||
const policy = params.cfg.gateway?.nodes?.browser;
|
||||
const mode = policy?.mode ?? "auto";
|
||||
if (mode === "off") return null;
|
||||
const browserNodes = params.nodes.filter((node) => isBrowserNode(node));
|
||||
if (browserNodes.length === 0) {
|
||||
if (policy?.node?.trim()) {
|
||||
throw new Error("No connected browser-capable nodes.");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const requested = policy?.node?.trim() || "";
|
||||
if (requested) {
|
||||
const resolved = resolveBrowserNode(browserNodes, requested);
|
||||
if (!resolved) {
|
||||
throw new Error(`Configured browser node not connected: ${requested}`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
if (mode === "manual") return null;
|
||||
if (browserNodes.length === 1) return browserNodes[0] ?? null;
|
||||
return null;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const browserHandlers: GatewayRequestHandlers = {
|
||||
"browser.request": async ({ params, respond, context }) => {
|
||||
const typed = params as BrowserRequestParams;
|
||||
const methodRaw = typeof typed.method === "string" ? typed.method.trim().toUpperCase() : "";
|
||||
const path = typeof typed.path === "string" ? typed.path.trim() : "";
|
||||
const query = typed.query && typeof typed.query === "object" ? typed.query : undefined;
|
||||
const body = typed.body;
|
||||
const timeoutMs =
|
||||
typeof typed.timeoutMs === "number" && Number.isFinite(typed.timeoutMs)
|
||||
? Math.max(1, Math.floor(typed.timeoutMs))
|
||||
: undefined;
|
||||
|
||||
if (!methodRaw || !path) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "method and path are required"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (methodRaw !== "GET" && methodRaw !== "POST" && methodRaw !== "DELETE") {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "method must be GET, POST, or DELETE"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const cfg = loadConfig();
|
||||
let nodeTarget: NodeSession | null = null;
|
||||
try {
|
||||
nodeTarget = resolveBrowserNodeTarget({
|
||||
cfg,
|
||||
nodes: context.nodeRegistry.listConnected(),
|
||||
});
|
||||
} catch (err) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(err)));
|
||||
return;
|
||||
}
|
||||
|
||||
if (nodeTarget) {
|
||||
const allowlist = resolveNodeCommandAllowlist(cfg, nodeTarget);
|
||||
const allowed = isNodeCommandAllowed({
|
||||
command: "browser.proxy",
|
||||
declaredCommands: nodeTarget.commands,
|
||||
allowlist,
|
||||
});
|
||||
if (!allowed.ok) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "node command not allowed", {
|
||||
details: { reason: allowed.reason, command: "browser.proxy" },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const proxyParams = {
|
||||
method: methodRaw,
|
||||
path,
|
||||
query,
|
||||
body,
|
||||
timeoutMs,
|
||||
profile: typeof query?.profile === "string" ? query.profile : undefined,
|
||||
};
|
||||
const res = await context.nodeRegistry.invoke({
|
||||
nodeId: nodeTarget.nodeId,
|
||||
command: "browser.proxy",
|
||||
params: proxyParams,
|
||||
timeoutMs,
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.UNAVAILABLE, res.error?.message ?? "node invoke failed", {
|
||||
details: { nodeError: res.error ?? null },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const payload = res.payloadJSON ? safeParseJson(res.payloadJSON) : res.payload;
|
||||
const proxy = payload && typeof payload === "object" ? (payload as BrowserProxyResult) : null;
|
||||
if (!proxy || !("result" in proxy)) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "browser proxy failed"));
|
||||
return;
|
||||
}
|
||||
const mapping = await persistProxyFiles(proxy.files);
|
||||
applyProxyPaths(proxy.result, mapping);
|
||||
respond(true, proxy.result);
|
||||
return;
|
||||
}
|
||||
|
||||
const ready = await startBrowserControlServiceFromConfig();
|
||||
if (!ready) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "browser control is disabled"));
|
||||
return;
|
||||
}
|
||||
|
||||
let dispatcher;
|
||||
try {
|
||||
dispatcher = createBrowserRouteDispatcher(createBrowserControlContext());
|
||||
} catch (err) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(err)));
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await dispatcher.dispatch({
|
||||
method: methodRaw,
|
||||
path,
|
||||
query,
|
||||
body,
|
||||
});
|
||||
|
||||
if (result.status >= 400) {
|
||||
const message =
|
||||
result.body && typeof result.body === "object" && "error" in result.body
|
||||
? String((result.body as { error?: unknown }).error)
|
||||
: `browser request failed (${result.status})`;
|
||||
const code = result.status >= 500 ? ErrorCodes.UNAVAILABLE : ErrorCodes.INVALID_REQUEST;
|
||||
respond(false, undefined, errorShape(code, message, { details: result.body }));
|
||||
return;
|
||||
}
|
||||
|
||||
respond(true, result.body);
|
||||
},
|
||||
};
|
||||
@@ -206,7 +206,7 @@ describe("gateway hot reload", () => {
|
||||
},
|
||||
cron: { enabled: true, store: "/tmp/cron.json" },
|
||||
agents: { defaults: { heartbeat: { every: "1m" }, maxConcurrent: 2 } },
|
||||
browser: { enabled: true, controlUrl: "http://127.0.0.1:18791" },
|
||||
browser: { enabled: true },
|
||||
web: { enabled: true },
|
||||
channels: {
|
||||
telegram: { botToken: "token" },
|
||||
|
||||
Reference in New Issue
Block a user