Browser: consolidate duplicate helper surfaces via facade delegation (#63957)

* Plugin SDK: route browser helper surfaces through browser facade

* Browser doctor flow: add facade path regression and export parity guards

* Contracts: dedupe browser facade parity checks without reducing coverage

* Browser tests: restore host-inspection semantics coverage in extension

* fix: add changelog note for browser facade consolidation (#63957) (thanks @joshavant)
This commit is contained in:
Josh Avant
2026-04-09 19:49:04 -05:00
committed by GitHub
parent c6d0baf562
commit 33ad806a14
13 changed files with 591 additions and 723 deletions

View File

@@ -1,182 +1,49 @@
import crypto from "node:crypto";
import type { OpenClawConfig } from "../config/config.js";
import { loadConfig, writeConfigFile } from "../config/config.js";
import { resolveGatewayAuth } from "../gateway/auth.js";
import { ensureGatewayStartupAuth } from "../gateway/startup-auth.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "../shared/string-coerce.js";
import { loadBundledPluginPublicSurfaceModuleSync } from "./facade-loader.js";
export type BrowserControlAuth = {
token?: string;
password?: string;
};
type EnsureBrowserControlAuthParams = {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
};
type EnsureBrowserControlAuthResult = {
auth: BrowserControlAuth;
generatedToken?: string;
};
type BrowserControlAuthSurface = {
resolveBrowserControlAuth: (cfg?: OpenClawConfig, env?: NodeJS.ProcessEnv) => BrowserControlAuth;
shouldAutoGenerateBrowserAuth: (env: NodeJS.ProcessEnv) => boolean;
ensureBrowserControlAuth: (
params: EnsureBrowserControlAuthParams,
) => Promise<EnsureBrowserControlAuthResult>;
};
function loadBrowserControlAuthSurface(): BrowserControlAuthSurface {
return loadBundledPluginPublicSurfaceModuleSync<BrowserControlAuthSurface>({
dirName: "browser",
artifactBasename: "browser-control-auth.js",
});
}
export function resolveBrowserControlAuth(
cfg?: OpenClawConfig,
env: NodeJS.ProcessEnv = process.env,
): BrowserControlAuth {
const auth = resolveGatewayAuth({
authConfig: cfg?.gateway?.auth,
env,
tailscaleMode: cfg?.gateway?.tailscale?.mode,
});
const token = normalizeOptionalString(auth.token) ?? "";
const password = normalizeOptionalString(auth.password) ?? "";
return {
token: token || undefined,
password: password || undefined,
};
return loadBrowserControlAuthSurface().resolveBrowserControlAuth(cfg, env);
}
export function shouldAutoGenerateBrowserAuth(env: NodeJS.ProcessEnv): boolean {
const nodeEnv = normalizeLowercaseStringOrEmpty(env.NODE_ENV);
if (nodeEnv === "test") {
return false;
}
const vitest = normalizeLowercaseStringOrEmpty(env.VITEST);
if (vitest && vitest !== "0" && vitest !== "false" && vitest !== "off") {
return false;
}
return true;
return loadBrowserControlAuthSurface().shouldAutoGenerateBrowserAuth(env);
}
function hasExplicitNonStringGatewayCredentialForMode(params: {
cfg?: OpenClawConfig;
mode: "none" | "trusted-proxy";
}): boolean {
const { cfg, mode } = params;
const auth = cfg?.gateway?.auth;
if (!auth) {
return false;
}
if (mode === "none") {
return auth.token != null && typeof auth.token !== "string";
}
return auth.password != null && typeof auth.password !== "string";
}
function generateBrowserControlToken(): string {
return crypto.randomBytes(24).toString("hex");
}
async function generateAndPersistBrowserControlToken(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
}): Promise<{
auth: BrowserControlAuth;
generatedToken?: string;
}> {
const token = generateBrowserControlToken();
const nextCfg: OpenClawConfig = {
...params.cfg,
gateway: {
...params.cfg.gateway,
auth: {
...params.cfg.gateway?.auth,
token,
},
},
};
await writeConfigFile(nextCfg);
const persistedAuth = resolveBrowserControlAuth(loadConfig(), params.env);
if (persistedAuth.token || persistedAuth.password) {
return {
auth: persistedAuth,
generatedToken: persistedAuth.token === token ? token : undefined,
};
}
return { auth: { token }, generatedToken: token };
}
async function generateAndPersistBrowserControlPassword(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
}): Promise<{
auth: BrowserControlAuth;
generatedToken?: string;
}> {
const password = generateBrowserControlToken();
const nextCfg: OpenClawConfig = {
...params.cfg,
gateway: {
...params.cfg.gateway,
auth: {
...params.cfg.gateway?.auth,
password,
},
},
};
await writeConfigFile(nextCfg);
const persistedAuth = resolveBrowserControlAuth(loadConfig(), params.env);
if (persistedAuth.token || persistedAuth.password) {
return {
auth: persistedAuth,
generatedToken: persistedAuth.password === password ? password : undefined,
};
}
return { auth: { password }, generatedToken: password };
}
export async function ensureBrowserControlAuth(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): Promise<{
auth: BrowserControlAuth;
generatedToken?: string;
}> {
const env = params.env ?? process.env;
const auth = resolveBrowserControlAuth(params.cfg, env);
if (auth.token || auth.password) {
return { auth };
}
if (!shouldAutoGenerateBrowserAuth(env)) {
return { auth };
}
if (params.cfg.gateway?.auth?.mode === "password") {
return { auth };
}
const latestCfg = loadConfig();
const latestAuth = resolveBrowserControlAuth(latestCfg, env);
if (latestAuth.token || latestAuth.password) {
return { auth: latestAuth };
}
if (latestCfg.gateway?.auth?.mode === "password") {
return { auth: latestAuth };
}
const latestMode = latestCfg.gateway?.auth?.mode;
if (latestMode === "none" || latestMode === "trusted-proxy") {
if (
hasExplicitNonStringGatewayCredentialForMode({
cfg: latestCfg,
mode: latestMode,
})
) {
return { auth: latestAuth };
}
if (latestMode === "trusted-proxy") {
return await generateAndPersistBrowserControlPassword({ cfg: latestCfg, env });
}
return await generateAndPersistBrowserControlToken({ cfg: latestCfg, env });
}
const ensured = await ensureGatewayStartupAuth({
cfg: latestCfg,
env,
persist: true,
});
return {
auth: {
token: ensured.auth.token,
password: ensured.auth.password,
},
generatedToken: ensured.generatedToken,
};
export async function ensureBrowserControlAuth(
params: EnsureBrowserControlAuthParams,
): Promise<EnsureBrowserControlAuthResult> {
return await loadBrowserControlAuthSurface().ensureBrowserControlAuth(params);
}

View File

@@ -0,0 +1,138 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const loadBundledPluginPublicSurfaceModuleSync = vi.hoisted(() => vi.fn());
vi.mock("./facade-loader.js", () => ({
loadBundledPluginPublicSurfaceModuleSync,
}));
describe("plugin-sdk browser facades", () => {
beforeEach(() => {
loadBundledPluginPublicSurfaceModuleSync.mockReset();
});
it("delegates browser profile helpers to the browser facade", async () => {
const resolvedConfig = {
marker: "resolved-config",
} as unknown as import("./browser-profiles.js").ResolvedBrowserConfig;
const resolvedProfile = {
marker: "resolved-profile",
} as unknown as import("./browser-profiles.js").ResolvedBrowserProfile;
const resolveBrowserConfig = vi.fn().mockReturnValue(resolvedConfig);
const resolveProfile = vi.fn().mockReturnValue(resolvedProfile);
loadBundledPluginPublicSurfaceModuleSync.mockReturnValue({
resolveBrowserConfig,
resolveProfile,
});
const browserProfiles = await import("./browser-profiles.js");
const cfg = { enabled: true } as unknown as import("../config/config.js").BrowserConfig;
const rootConfig = { gateway: { port: 18789 } } as import("../config/config.js").OpenClawConfig;
expect(browserProfiles.resolveBrowserConfig(cfg, rootConfig)).toBe(resolvedConfig);
expect(browserProfiles.resolveProfile(resolvedConfig, "openclaw")).toBe(resolvedProfile);
expect(loadBundledPluginPublicSurfaceModuleSync).toHaveBeenCalledWith({
dirName: "browser",
artifactBasename: "browser-profiles.js",
});
expect(resolveBrowserConfig).toHaveBeenCalledWith(cfg, rootConfig);
expect(resolveProfile).toHaveBeenCalledWith(resolvedConfig, "openclaw");
});
it("hard-fails when browser profile facade is unavailable", async () => {
loadBundledPluginPublicSurfaceModuleSync.mockImplementation(() => {
throw new Error("missing browser profiles facade");
});
const browserProfiles = await import("./browser-profiles.js");
expect(() => browserProfiles.resolveBrowserConfig(undefined, undefined)).toThrow(
"missing browser profiles facade",
);
});
it("delegates browser control auth helpers to the browser facade", async () => {
const resolvedAuth = {
token: "token-1",
password: undefined,
} as import("./browser-control-auth.js").BrowserControlAuth;
const ensuredAuth = {
auth: resolvedAuth,
generatedToken: "token-1",
};
const resolveBrowserControlAuth = vi.fn().mockReturnValue(resolvedAuth);
const shouldAutoGenerateBrowserAuth = vi.fn().mockReturnValue(true);
const ensureBrowserControlAuth = vi.fn().mockResolvedValue(ensuredAuth);
loadBundledPluginPublicSurfaceModuleSync.mockReturnValue({
resolveBrowserControlAuth,
shouldAutoGenerateBrowserAuth,
ensureBrowserControlAuth,
});
const controlAuth = await import("./browser-control-auth.js");
const cfg = {
gateway: { auth: { token: "token-1" } },
} as import("../config/config.js").OpenClawConfig;
const env = {} as NodeJS.ProcessEnv;
expect(controlAuth.resolveBrowserControlAuth(cfg, env)).toBe(resolvedAuth);
expect(controlAuth.shouldAutoGenerateBrowserAuth(env)).toBe(true);
await expect(controlAuth.ensureBrowserControlAuth({ cfg, env })).resolves.toEqual(ensuredAuth);
expect(loadBundledPluginPublicSurfaceModuleSync).toHaveBeenCalledWith({
dirName: "browser",
artifactBasename: "browser-control-auth.js",
});
});
it("hard-fails when browser control auth facade is unavailable", async () => {
loadBundledPluginPublicSurfaceModuleSync.mockImplementation(() => {
throw new Error("missing browser control auth facade");
});
const controlAuth = await import("./browser-control-auth.js");
expect(() => controlAuth.resolveBrowserControlAuth(undefined, {} as NodeJS.ProcessEnv)).toThrow(
"missing browser control auth facade",
);
});
it("delegates browser host inspection helpers to the browser facade", async () => {
const executable: import("./browser-host-inspection.js").BrowserExecutable = {
kind: "chrome",
path: "/usr/bin/google-chrome",
};
const resolveGoogleChromeExecutableForPlatform = vi.fn().mockReturnValue(executable);
const readBrowserVersion = vi.fn().mockReturnValue("Google Chrome 144.0.7534.0");
const parseBrowserMajorVersion = vi.fn().mockReturnValue(144);
loadBundledPluginPublicSurfaceModuleSync.mockReturnValue({
resolveGoogleChromeExecutableForPlatform,
readBrowserVersion,
parseBrowserMajorVersion,
});
const hostInspection = await import("./browser-host-inspection.js");
expect(hostInspection.resolveGoogleChromeExecutableForPlatform("linux")).toEqual(executable);
expect(hostInspection.readBrowserVersion(executable.path)).toBe("Google Chrome 144.0.7534.0");
expect(hostInspection.parseBrowserMajorVersion("Google Chrome 144.0.7534.0")).toBe(144);
expect(loadBundledPluginPublicSurfaceModuleSync).toHaveBeenCalledWith({
dirName: "browser",
artifactBasename: "browser-host-inspection.js",
});
});
it("hard-fails when browser host inspection facade is unavailable", async () => {
loadBundledPluginPublicSurfaceModuleSync.mockImplementation(() => {
throw new Error("missing browser host inspection facade");
});
const hostInspection = await import("./browser-host-inspection.js");
expect(() => hostInspection.resolveGoogleChromeExecutableForPlatform("linux")).toThrow(
"missing browser host inspection facade",
);
});
});

View File

@@ -1,42 +1,56 @@
import fs from "node:fs";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
parseBrowserMajorVersion,
resolveGoogleChromeExecutableForPlatform,
} from "./browser-host-inspection.js";
const loadBundledPluginPublicSurfaceModuleSync = vi.hoisted(() => vi.fn());
vi.mock("./facade-loader.js", () => ({
loadBundledPluginPublicSurfaceModuleSync,
}));
describe("browser host inspection", () => {
beforeEach(() => {
vi.restoreAllMocks();
loadBundledPluginPublicSurfaceModuleSync.mockReset();
});
it("parses the last dotted browser version token", () => {
expect(parseBrowserMajorVersion("Google Chrome 144.0.7534.0")).toBe(144);
expect(parseBrowserMajorVersion("Chromium 3.0/1.2.3")).toBe(1);
expect(parseBrowserMajorVersion("no version here")).toBeNull();
});
it("classifies beta Linux Chrome builds as prerelease", () => {
vi.spyOn(fs, "existsSync").mockImplementation((candidate) => {
const normalized = String(candidate);
return normalized === "/usr/bin/google-chrome-beta";
});
expect(resolveGoogleChromeExecutableForPlatform("linux")).toEqual({
it("delegates browser host inspection helpers through the browser facade", async () => {
const resolveGoogleChromeExecutableForPlatform = vi.fn().mockReturnValue({
kind: "canary",
path: "/usr/bin/google-chrome-beta",
});
const readBrowserVersion = vi.fn().mockReturnValue("Google Chrome 144.0.7534.0");
const parseBrowserMajorVersion = vi.fn().mockReturnValue(144);
loadBundledPluginPublicSurfaceModuleSync.mockReturnValue({
resolveGoogleChromeExecutableForPlatform,
readBrowserVersion,
parseBrowserMajorVersion,
});
const hostInspection = await import("./browser-host-inspection.js");
expect(hostInspection.resolveGoogleChromeExecutableForPlatform("linux")).toEqual({
kind: "canary",
path: "/usr/bin/google-chrome-beta",
});
expect(hostInspection.readBrowserVersion("/usr/bin/google-chrome-beta")).toBe(
"Google Chrome 144.0.7534.0",
);
expect(hostInspection.parseBrowserMajorVersion("Google Chrome 144.0.7534.0")).toBe(144);
expect(loadBundledPluginPublicSurfaceModuleSync).toHaveBeenCalledWith({
dirName: "browser",
artifactBasename: "browser-host-inspection.js",
});
});
it("classifies unstable Linux Chrome builds as prerelease", () => {
vi.spyOn(fs, "existsSync").mockImplementation((candidate) => {
const normalized = String(candidate);
return normalized === "/usr/bin/google-chrome-unstable";
it("hard-fails when browser host inspection facade is unavailable", async () => {
loadBundledPluginPublicSurfaceModuleSync.mockImplementation(() => {
throw new Error("missing browser host inspection facade");
});
expect(resolveGoogleChromeExecutableForPlatform("linux")).toEqual({
kind: "canary",
path: "/usr/bin/google-chrome-unstable",
});
const hostInspection = await import("./browser-host-inspection.js");
expect(() => hostInspection.resolveGoogleChromeExecutableForPlatform("linux")).toThrow(
"missing browser host inspection facade",
);
});
});

View File

@@ -1,134 +1,33 @@
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "../shared/string-coerce.js";
import { loadBundledPluginPublicSurfaceModuleSync } from "./facade-loader.js";
export type BrowserExecutable = {
kind: "brave" | "canary" | "chromium" | "chrome" | "custom" | "edge";
path: string;
};
const CHROME_VERSION_RE = /\b(\d+)(?:\.\d+){1,3}\b/g;
type BrowserHostInspectionSurface = {
resolveGoogleChromeExecutableForPlatform: (platform: NodeJS.Platform) => BrowserExecutable | null;
readBrowserVersion: (executablePath: string) => string | null;
parseBrowserMajorVersion: (rawVersion: string | null | undefined) => number | null;
};
function exists(filePath: string) {
try {
return fs.existsSync(filePath);
} catch {
return false;
}
}
function execText(
command: string,
args: string[],
timeoutMs = 1200,
maxBuffer = 1024 * 1024,
): string | null {
try {
const output = execFileSync(command, args, {
timeout: timeoutMs,
encoding: "utf8",
maxBuffer,
});
return normalizeOptionalString(output) ?? null;
} catch {
return null;
}
}
function findFirstChromeExecutable(candidates: string[]): BrowserExecutable | null {
for (const candidate of candidates) {
if (exists(candidate)) {
const normalizedPath = normalizeLowercaseStringOrEmpty(candidate);
return {
kind:
normalizedPath.includes("beta") ||
normalizedPath.includes("canary") ||
normalizedPath.includes("sxs") ||
normalizedPath.includes("unstable")
? "canary"
: "chrome",
path: candidate,
};
}
}
return null;
}
function findGoogleChromeExecutableMac(): BrowserExecutable | null {
return findFirstChromeExecutable([
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
path.join(os.homedir(), "Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
path.join(
os.homedir(),
"Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
),
]);
}
function findGoogleChromeExecutableLinux(): BrowserExecutable | null {
return findFirstChromeExecutable([
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/google-chrome-beta",
"/usr/bin/google-chrome-unstable",
"/snap/bin/google-chrome",
]);
}
function findGoogleChromeExecutableWindows(): BrowserExecutable | null {
const localAppData = process.env.LOCALAPPDATA ?? "";
const programFiles = process.env.ProgramFiles ?? "C:\\Program Files";
const programFilesX86 = process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
const joinWin = path.win32.join;
const candidates: string[] = [];
if (localAppData) {
candidates.push(joinWin(localAppData, "Google", "Chrome", "Application", "chrome.exe"));
candidates.push(joinWin(localAppData, "Google", "Chrome SxS", "Application", "chrome.exe"));
}
candidates.push(joinWin(programFiles, "Google", "Chrome", "Application", "chrome.exe"));
candidates.push(joinWin(programFilesX86, "Google", "Chrome", "Application", "chrome.exe"));
return findFirstChromeExecutable(candidates);
function loadBrowserHostInspectionSurface(): BrowserHostInspectionSurface {
return loadBundledPluginPublicSurfaceModuleSync<BrowserHostInspectionSurface>({
dirName: "browser",
artifactBasename: "browser-host-inspection.js",
});
}
export function resolveGoogleChromeExecutableForPlatform(
platform: NodeJS.Platform,
): BrowserExecutable | null {
if (platform === "darwin") {
return findGoogleChromeExecutableMac();
}
if (platform === "linux") {
return findGoogleChromeExecutableLinux();
}
if (platform === "win32") {
return findGoogleChromeExecutableWindows();
}
return null;
return loadBrowserHostInspectionSurface().resolveGoogleChromeExecutableForPlatform(platform);
}
export function readBrowserVersion(executablePath: string): string | null {
const output = execText(executablePath, ["--version"], 2000);
if (!output) {
return null;
}
return output.replace(/\s+/g, " ").trim();
return loadBrowserHostInspectionSurface().readBrowserVersion(executablePath);
}
export function parseBrowserMajorVersion(rawVersion: string | null | undefined): number | null {
const matches = [...String(rawVersion ?? "").matchAll(CHROME_VERSION_RE)];
const match = matches.at(-1);
if (!match?.[1]) {
return null;
}
const major = Number.parseInt(match[1], 10);
return Number.isFinite(major) ? major : null;
return loadBrowserHostInspectionSurface().parseBrowserMajorVersion(rawVersion);
}

View File

@@ -1,19 +1,8 @@
import path from "node:path";
import type { BrowserConfig, BrowserProfileConfig, OpenClawConfig } from "../config/config.js";
import { resolveGatewayPort } from "../config/config.js";
import {
DEFAULT_BROWSER_CDP_PORT_RANGE_START,
DEFAULT_BROWSER_CONTROL_PORT,
deriveDefaultBrowserCdpPortRange,
deriveDefaultBrowserControlPort,
} from "../config/port-defaults.js";
import { isLoopbackHost } from "../gateway/net.js";
import type { SsrFPolicy } from "../infra/net/ssrf.js";
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import { normalizeOptionalTrimmedStringList } from "../shared/string-normalization.js";
import { resolveUserPath } from "../utils.js";
import { parseBrowserHttpUrl } from "./browser-cdp.js";
import { loadBundledPluginPublicSurfaceModuleSync } from "./facade-loader.js";
export const DEFAULT_OPENCLAW_BROWSER_ENABLED = true;
export const DEFAULT_BROWSER_EVALUATE_ENABLED = true;
@@ -57,272 +46,34 @@ export type ResolvedBrowserProfile = {
attachOnly: boolean;
};
function normalizeHexColor(raw: string | undefined): string {
const value = (raw ?? "").trim();
if (!value) {
return DEFAULT_OPENCLAW_BROWSER_COLOR;
}
const normalized = value.startsWith("#") ? value : `#${value}`;
if (!/^#[0-9a-fA-F]{6}$/.test(normalized)) {
return DEFAULT_OPENCLAW_BROWSER_COLOR;
}
return normalized.toUpperCase();
}
type BrowserProfilesSurface = {
resolveBrowserConfig: (
cfg: BrowserConfig | undefined,
rootConfig?: OpenClawConfig,
) => ResolvedBrowserConfig;
resolveProfile: (
resolved: ResolvedBrowserConfig,
profileName: string,
) => ResolvedBrowserProfile | null;
};
function normalizeTimeoutMs(raw: number | undefined, fallback: number): number {
const value = typeof raw === "number" && Number.isFinite(raw) ? Math.floor(raw) : fallback;
return value < 0 ? fallback : value;
}
function resolveCdpPortRangeStart(
rawStart: number | undefined,
fallbackStart: number,
rangeSpan: number,
): number {
const start =
typeof rawStart === "number" && Number.isFinite(rawStart)
? Math.floor(rawStart)
: fallbackStart;
if (start < 1 || start > 65_535) {
throw new Error(`browser.cdpPortRangeStart must be between 1 and 65535, got: ${start}`);
}
const maxStart = 65_535 - rangeSpan;
if (start > maxStart) {
throw new Error(
`browser.cdpPortRangeStart (${start}) is too high for a ${rangeSpan + 1}-port range; max is ${maxStart}.`,
);
}
return start;
}
function resolveBrowserSsrFPolicy(cfg: BrowserConfig | undefined): SsrFPolicy | undefined {
const rawPolicy = cfg?.ssrfPolicy as
| (BrowserConfig["ssrfPolicy"] & { allowPrivateNetwork?: boolean })
| undefined;
const allowPrivateNetwork = rawPolicy?.allowPrivateNetwork;
const dangerouslyAllowPrivateNetwork = rawPolicy?.dangerouslyAllowPrivateNetwork;
const allowedHostnames = normalizeOptionalTrimmedStringList(rawPolicy?.allowedHostnames);
const hostnameAllowlist = normalizeOptionalTrimmedStringList(rawPolicy?.hostnameAllowlist);
const hasExplicitPrivateSetting =
allowPrivateNetwork !== undefined || dangerouslyAllowPrivateNetwork !== undefined;
const resolvedAllowPrivateNetwork =
dangerouslyAllowPrivateNetwork === true ||
allowPrivateNetwork === true ||
!hasExplicitPrivateSetting;
if (
!resolvedAllowPrivateNetwork &&
!hasExplicitPrivateSetting &&
!allowedHostnames &&
!hostnameAllowlist
) {
return undefined;
}
return {
...(resolvedAllowPrivateNetwork ? { dangerouslyAllowPrivateNetwork: true } : {}),
...(allowedHostnames ? { allowedHostnames } : {}),
...(hostnameAllowlist ? { hostnameAllowlist } : {}),
};
}
function ensureDefaultProfile(
profiles: Record<string, BrowserProfileConfig> | undefined,
defaultColor: string,
legacyCdpPort?: number,
derivedDefaultCdpPort?: number,
legacyCdpUrl?: string,
): Record<string, BrowserProfileConfig> {
const result = { ...profiles };
if (!result[DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME]) {
result[DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME] = {
cdpPort: legacyCdpPort ?? derivedDefaultCdpPort ?? DEFAULT_BROWSER_CDP_PORT_RANGE_START,
color: defaultColor,
...(legacyCdpUrl ? { cdpUrl: legacyCdpUrl } : {}),
};
}
return result;
}
function ensureDefaultUserBrowserProfile(
profiles: Record<string, BrowserProfileConfig>,
): Record<string, BrowserProfileConfig> {
const result = { ...profiles };
if (result.user) {
return result;
}
result.user = {
driver: "existing-session",
attachOnly: true,
color: "#00AA00",
};
return result;
function loadBrowserProfilesSurface(): BrowserProfilesSurface {
return loadBundledPluginPublicSurfaceModuleSync<BrowserProfilesSurface>({
dirName: "browser",
artifactBasename: "browser-profiles.js",
});
}
export function resolveBrowserConfig(
cfg: BrowserConfig | undefined,
rootConfig?: OpenClawConfig,
): ResolvedBrowserConfig {
const enabled = cfg?.enabled ?? DEFAULT_OPENCLAW_BROWSER_ENABLED;
const evaluateEnabled = cfg?.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED;
const gatewayPort = resolveGatewayPort(rootConfig);
const controlPort = deriveDefaultBrowserControlPort(gatewayPort ?? DEFAULT_BROWSER_CONTROL_PORT);
const defaultColor = normalizeHexColor(cfg?.color);
const remoteCdpTimeoutMs = normalizeTimeoutMs(cfg?.remoteCdpTimeoutMs, 1500);
const remoteCdpHandshakeTimeoutMs = normalizeTimeoutMs(
cfg?.remoteCdpHandshakeTimeoutMs,
Math.max(2000, remoteCdpTimeoutMs * 2),
);
const derivedCdpRange = deriveDefaultBrowserCdpPortRange(controlPort);
const cdpRangeSpan = derivedCdpRange.end - derivedCdpRange.start;
const cdpPortRangeStart = resolveCdpPortRangeStart(
cfg?.cdpPortRangeStart,
derivedCdpRange.start,
cdpRangeSpan,
);
const cdpPortRangeEnd = cdpPortRangeStart + cdpRangeSpan;
const rawCdpUrl = (cfg?.cdpUrl ?? "").trim();
let cdpInfo:
| {
parsed: URL;
port: number;
normalized: string;
}
| undefined;
if (rawCdpUrl) {
cdpInfo = parseBrowserHttpUrl(rawCdpUrl, "browser.cdpUrl");
} else {
const derivedPort = controlPort + 1;
if (derivedPort > 65_535) {
throw new Error(
`Derived CDP port (${derivedPort}) is too high; check gateway port configuration.`,
);
}
const derived = new URL(`http://127.0.0.1:${derivedPort}`);
cdpInfo = {
parsed: derived,
port: derivedPort,
normalized: derived.toString().replace(/\/$/, ""),
};
}
const headless = cfg?.headless === true;
const noSandbox = cfg?.noSandbox === true;
const attachOnly = cfg?.attachOnly === true;
const executablePath = normalizeOptionalString(cfg?.executablePath);
const defaultProfileFromConfig = normalizeOptionalString(cfg?.defaultProfile);
const legacyCdpPort = rawCdpUrl ? cdpInfo.port : undefined;
const isWsUrl = cdpInfo.parsed.protocol === "ws:" || cdpInfo.parsed.protocol === "wss:";
const legacyCdpUrl = rawCdpUrl && isWsUrl ? cdpInfo.normalized : undefined;
const profiles = ensureDefaultUserBrowserProfile(
ensureDefaultProfile(
cfg?.profiles,
defaultColor,
legacyCdpPort,
cdpPortRangeStart,
legacyCdpUrl,
),
);
const cdpProtocol = cdpInfo.parsed.protocol === "https:" ? "https" : "http";
const defaultProfile =
defaultProfileFromConfig ??
(profiles[DEFAULT_BROWSER_DEFAULT_PROFILE_NAME]
? DEFAULT_BROWSER_DEFAULT_PROFILE_NAME
: profiles[DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME]
? DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME
: "user");
const extraArgs = Array.isArray(cfg?.extraArgs)
? cfg.extraArgs.filter(
(value): value is string => typeof value === "string" && value.trim().length > 0,
)
: [];
return {
enabled,
evaluateEnabled,
controlPort,
cdpPortRangeStart,
cdpPortRangeEnd,
cdpProtocol,
cdpHost: cdpInfo.parsed.hostname,
cdpIsLoopback: isLoopbackHost(cdpInfo.parsed.hostname),
remoteCdpTimeoutMs,
remoteCdpHandshakeTimeoutMs,
color: defaultColor,
executablePath,
headless,
noSandbox,
attachOnly,
defaultProfile,
profiles,
ssrfPolicy: resolveBrowserSsrFPolicy(cfg),
extraArgs,
};
return loadBrowserProfilesSurface().resolveBrowserConfig(cfg, rootConfig);
}
export function resolveProfile(
resolved: ResolvedBrowserConfig,
profileName: string,
): ResolvedBrowserProfile | null {
const profile = resolved.profiles[profileName];
if (!profile) {
return null;
}
const rawProfileUrl = profile.cdpUrl?.trim() ?? "";
let cdpHost = resolved.cdpHost;
let cdpPort = profile.cdpPort ?? 0;
let cdpUrl = "";
const driver = profile.driver === "existing-session" ? "existing-session" : "openclaw";
if (driver === "existing-session") {
return {
name: profileName,
cdpPort: 0,
cdpUrl: "",
cdpHost: "",
cdpIsLoopback: true,
userDataDir: resolveUserPath(profile.userDataDir?.trim() || "") || undefined,
color: profile.color,
driver,
attachOnly: true,
};
}
const hasStaleWsPath =
rawProfileUrl !== "" &&
cdpPort > 0 &&
/^wss?:\/\//i.test(rawProfileUrl) &&
/\/devtools\/browser\//i.test(rawProfileUrl);
if (hasStaleWsPath) {
const parsed = new URL(rawProfileUrl);
cdpHost = parsed.hostname;
cdpUrl = `${resolved.cdpProtocol}://${cdpHost}:${cdpPort}`;
} else if (rawProfileUrl) {
const parsed = parseBrowserHttpUrl(rawProfileUrl, `browser.profiles.${profileName}.cdpUrl`);
cdpHost = parsed.parsed.hostname;
cdpPort = parsed.port;
cdpUrl = parsed.normalized;
} else if (cdpPort) {
cdpUrl = `${resolved.cdpProtocol}://${resolved.cdpHost}:${cdpPort}`;
} else {
throw new Error(`Profile "${profileName}" must define cdpPort or cdpUrl.`);
}
return {
name: profileName,
cdpPort,
cdpUrl,
cdpHost,
cdpIsLoopback: isLoopbackHost(cdpHost),
color: profile.color,
driver,
attachOnly: profile.attachOnly ?? resolved.attachOnly,
};
return loadBrowserProfilesSurface().resolveProfile(resolved, profileName);
}