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; 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; 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(); const mapping = new Map(); for (const file of files) { const buffer = Buffer.from(file.base64, "base64"); const saved = await saveMediaBuffer(buffer, file.mimeType, "browser", buffer.byteLength); mapping.set(file.path, saved.path); } return mapping; } function applyProxyPaths(result: unknown, mapping: Map) { if (!result || typeof result !== "object") return; const obj = result as Record; if (typeof obj.path === "string" && mapping.has(obj.path)) { obj.path = mapping.get(obj.path); } if (typeof obj.imagePath === "string" && mapping.has(obj.imagePath)) { obj.imagePath = mapping.get(obj.imagePath); } const download = obj.download; if (download && typeof download === "object") { const d = download as Record; if (typeof d.path === "string" && mapping.has(d.path)) { d.path = mapping.get(d.path); } } } 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); }, };