import { formatCliCommand } from "../cli/command-format.js"; import { loadConfig } from "../config/config.js"; import { resolveBrowserControlAuth } from "./control-auth.js"; import { createBrowserControlContext, startBrowserControlServiceFromConfig, } from "./control-service.js"; import { createBrowserRouteDispatcher } from "./routes/dispatcher.js"; function isAbsoluteHttp(url: string): boolean { return /^https?:\/\//i.test(url.trim()); } function isLoopbackHttpUrl(url: string): boolean { try { const host = new URL(url).hostname.trim().toLowerCase(); return host === "127.0.0.1" || host === "localhost" || host === "::1"; } catch { return false; } } function withLoopbackBrowserAuth( url: string, init: (RequestInit & { timeoutMs?: number }) | undefined, ): RequestInit & { timeoutMs?: number } { const headers = new Headers(init?.headers ?? {}); if (headers.has("authorization") || headers.has("x-openclaw-password")) { return { ...init, headers }; } if (!isLoopbackHttpUrl(url)) { return { ...init, headers }; } try { const cfg = loadConfig(); const auth = resolveBrowserControlAuth(cfg); if (auth.token) { headers.set("Authorization", `Bearer ${auth.token}`); } else if (auth.password) { headers.set("x-openclaw-password", auth.password); } } catch { // ignore config/auth lookup failures and continue without auth headers } return { ...init, headers }; } function enhanceBrowserFetchError(url: string, err: unknown, timeoutMs: number): Error { const hint = isAbsoluteHttp(url) ? "If this is a sandboxed session, ensure the sandbox browser is running and try again." : `Start (or restart) the OpenClaw gateway (OpenClaw.app menubar, or \`${formatCliCommand("openclaw gateway")}\`) and try again.`; const msg = String(err); const msgLower = msg.toLowerCase(); const looksLikeTimeout = msgLower.includes("timed out") || msgLower.includes("timeout") || msgLower.includes("aborted") || msgLower.includes("abort") || msgLower.includes("aborterror"); if (looksLikeTimeout) { return new Error( `Can't reach the OpenClaw browser control service (timed out after ${timeoutMs}ms). ${hint}`, ); } return new Error(`Can't reach the OpenClaw browser control service. ${hint} (${msg})`); } async function fetchHttpJson( url: string, init: RequestInit & { timeoutMs?: number }, ): Promise { const timeoutMs = init.timeoutMs ?? 5000; const ctrl = new AbortController(); const upstreamSignal = init.signal; let upstreamAbortListener: (() => void) | undefined; if (upstreamSignal) { if (upstreamSignal.aborted) { ctrl.abort(upstreamSignal.reason); } else { upstreamAbortListener = () => ctrl.abort(upstreamSignal.reason); upstreamSignal.addEventListener("abort", upstreamAbortListener, { once: true }); } } const t = setTimeout(() => ctrl.abort(new Error("timed out")), timeoutMs); try { const res = await fetch(url, { ...init, signal: ctrl.signal }); if (!res.ok) { const text = await res.text().catch(() => ""); throw new Error(text || `HTTP ${res.status}`); } return (await res.json()) as T; } finally { clearTimeout(t); if (upstreamSignal && upstreamAbortListener) { upstreamSignal.removeEventListener("abort", upstreamAbortListener); } } } export async function fetchBrowserJson( url: string, init?: RequestInit & { timeoutMs?: number }, ): Promise { const timeoutMs = init?.timeoutMs ?? 5000; try { if (isAbsoluteHttp(url)) { const httpInit = withLoopbackBrowserAuth(url, init); return await fetchHttpJson(url, { ...httpInit, timeoutMs }); } const started = await startBrowserControlServiceFromConfig(); if (!started) { throw new Error("browser control disabled"); } const dispatcher = createBrowserRouteDispatcher(createBrowserControlContext()); const parsed = new URL(url, "http://localhost"); const query: Record = {}; for (const [key, value] of parsed.searchParams.entries()) { query[key] = value; } let body = init?.body; if (typeof body === "string") { try { body = JSON.parse(body); } catch { // keep as string } } const abortCtrl = new AbortController(); const upstreamSignal = init?.signal; let upstreamAbortListener: (() => void) | undefined; if (upstreamSignal) { if (upstreamSignal.aborted) { abortCtrl.abort(upstreamSignal.reason); } else { upstreamAbortListener = () => abortCtrl.abort(upstreamSignal.reason); upstreamSignal.addEventListener("abort", upstreamAbortListener, { once: true }); } } let abortListener: (() => void) | undefined; const abortPromise: Promise = abortCtrl.signal.aborted ? Promise.reject(abortCtrl.signal.reason ?? new Error("aborted")) : new Promise((_, reject) => { abortListener = () => reject(abortCtrl.signal.reason ?? new Error("aborted")); abortCtrl.signal.addEventListener("abort", abortListener, { once: true }); }); let timer: ReturnType | undefined; if (timeoutMs) { timer = setTimeout(() => abortCtrl.abort(new Error("timed out")), timeoutMs); } const dispatchPromise = dispatcher.dispatch({ method: init?.method?.toUpperCase() === "DELETE" ? "DELETE" : init?.method?.toUpperCase() === "POST" ? "POST" : "GET", path: parsed.pathname, query, body, signal: abortCtrl.signal, }); const result = await Promise.race([dispatchPromise, abortPromise]).finally(() => { if (timer) { clearTimeout(timer); } if (abortListener) { abortCtrl.signal.removeEventListener("abort", abortListener); } if (upstreamSignal && upstreamAbortListener) { upstreamSignal.removeEventListener("abort", upstreamAbortListener); } }); if (result.status >= 400) { const message = result.body && typeof result.body === "object" && "error" in result.body ? String((result.body as { error?: unknown }).error) : `HTTP ${result.status}`; throw new Error(message); } return result.body as T; } catch (err) { throw enhanceBrowserFetchError(url, err, timeoutMs); } }