Files
openclaw/src/gateway/server-methods/browser.ts
SidQin-cyber fa875a6bf7 fix(gateway): honor browser profile from request body for node proxy calls
Gateway browser.request only read profile from query.profile before invoking
browser.proxy on nodes. Calls that passed profile in POST body silently fell
back to the default profile, which could switch users into chrome extension
mode even when they explicitly requested openclaw profile.

Use query profile first, then fall back to body.profile when present.

Closes #28687
2026-03-02 06:26:35 +00:00

267 lines
8.0 KiB
TypeScript

import crypto from "node:crypto";
import {
createBrowserControlContext,
startBrowserControlServiceFromConfig,
} from "../../browser/control-service.js";
import { applyBrowserProxyPaths, persistBrowserProxyFiles } from "../../browser/proxy-files.js";
import { createBrowserRouteDispatcher } from "../../browser/routes/dispatcher.js";
import { loadConfig } from "../../config/config.js";
import { isNodeCommandAllowed, resolveNodeCommandAllowlist } from "../node-command-policy.js";
import type { NodeSession } from "../node-registry.js";
import { ErrorCodes, errorShape } from "../protocol/index.js";
import { respondUnavailableOnNodeInvokeError, 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;
};
function resolveRequestedProfile(params: {
query?: Record<string, unknown>;
body?: unknown;
}): string | undefined {
const queryProfile =
typeof params.query?.profile === "string" ? params.query.profile.trim() : undefined;
if (queryProfile) {
return queryProfile;
}
if (!params.body || typeof params.body !== "object") {
return undefined;
}
const bodyProfile =
"profile" in params.body && typeof params.body.profile === "string"
? params.body.profile.trim()
: undefined;
return bodyProfile || undefined;
}
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) {
return await persistBrowserProxyFiles(files);
}
function applyProxyPaths(result: unknown, mapping: Map<string, string>) {
applyBrowserProxyPaths(result, mapping);
}
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) {
const platform = nodeTarget.platform ?? "unknown";
const hint = `node command not allowed: ${allowed.reason} (platform: ${platform}, command: browser.proxy)`;
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, hint, {
details: { reason: allowed.reason, command: "browser.proxy" },
}),
);
return;
}
const proxyParams = {
method: methodRaw,
path,
query,
body,
timeoutMs,
profile: resolveRequestedProfile({ query, body }),
};
const res = await context.nodeRegistry.invoke({
nodeId: nodeTarget.nodeId,
command: "browser.proxy",
params: proxyParams,
timeoutMs,
idempotencyKey: crypto.randomUUID(),
});
if (!respondUnavailableOnNodeInvokeError(respond, res)) {
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);
},
};