Spaces:
Configuration error
Configuration error
| 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); | |
| }, | |
| }; | |