amos-fernandes's picture
Upload 4501 files
3a65265 verified
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);
},
};