mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
* config(browser): allow profile attachOnly field * config(schema): accept profile attachOnly * browser(config): resolve per-profile attachOnly * browser(runtime): honor profile attachOnly checks * browser(routes): expose profile attachOnly in status * config(labels): add browser profile attachOnly label * config(help): document browser profile attachOnly * test(config): cover profile attachOnly resolution * test(browser): cover profile attachOnly runtime path * test(config): include profile attachOnly help target * changelog: note profile attachOnly override * browser(runtime): prioritize attachOnly over loopback ownership error * test(browser): cover attachOnly ws-failure ownership path
352 lines
11 KiB
TypeScript
352 lines
11 KiB
TypeScript
import type { BrowserConfig, BrowserProfileConfig, OpenClawConfig } from "../config/config.js";
|
|
import { resolveGatewayPort } from "../config/paths.js";
|
|
import {
|
|
deriveDefaultBrowserCdpPortRange,
|
|
deriveDefaultBrowserControlPort,
|
|
DEFAULT_BROWSER_CONTROL_PORT,
|
|
} from "../config/port-defaults.js";
|
|
import { isLoopbackHost } from "../gateway/net.js";
|
|
import type { SsrFPolicy } from "../infra/net/ssrf.js";
|
|
import {
|
|
DEFAULT_OPENCLAW_BROWSER_COLOR,
|
|
DEFAULT_OPENCLAW_BROWSER_ENABLED,
|
|
DEFAULT_BROWSER_EVALUATE_ENABLED,
|
|
DEFAULT_BROWSER_DEFAULT_PROFILE_NAME,
|
|
DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME,
|
|
} from "./constants.js";
|
|
import { CDP_PORT_RANGE_START, getUsedPorts } from "./profiles.js";
|
|
|
|
export type ResolvedBrowserConfig = {
|
|
enabled: boolean;
|
|
evaluateEnabled: boolean;
|
|
controlPort: number;
|
|
cdpPortRangeStart: number;
|
|
cdpPortRangeEnd: number;
|
|
cdpProtocol: "http" | "https";
|
|
cdpHost: string;
|
|
cdpIsLoopback: boolean;
|
|
remoteCdpTimeoutMs: number;
|
|
remoteCdpHandshakeTimeoutMs: number;
|
|
color: string;
|
|
executablePath?: string;
|
|
headless: boolean;
|
|
noSandbox: boolean;
|
|
attachOnly: boolean;
|
|
defaultProfile: string;
|
|
profiles: Record<string, BrowserProfileConfig>;
|
|
ssrfPolicy?: SsrFPolicy;
|
|
extraArgs: string[];
|
|
};
|
|
|
|
export type ResolvedBrowserProfile = {
|
|
name: string;
|
|
cdpPort: number;
|
|
cdpUrl: string;
|
|
cdpHost: string;
|
|
cdpIsLoopback: boolean;
|
|
color: string;
|
|
driver: "openclaw" | "extension";
|
|
attachOnly: boolean;
|
|
};
|
|
|
|
function normalizeHexColor(raw: string | undefined) {
|
|
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();
|
|
}
|
|
|
|
function normalizeTimeoutMs(raw: number | undefined, fallback: 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,
|
|
) {
|
|
const start =
|
|
typeof rawStart === "number" && Number.isFinite(rawStart)
|
|
? Math.floor(rawStart)
|
|
: fallbackStart;
|
|
if (start < 1 || start > 65535) {
|
|
throw new Error(`browser.cdpPortRangeStart must be between 1 and 65535, got: ${start}`);
|
|
}
|
|
const maxStart = 65535 - 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 normalizeStringList(raw: string[] | undefined): string[] | undefined {
|
|
if (!Array.isArray(raw) || raw.length === 0) {
|
|
return undefined;
|
|
}
|
|
const values = raw
|
|
.map((value) => value.trim())
|
|
.filter((value): value is string => value.length > 0);
|
|
return values.length > 0 ? values : undefined;
|
|
}
|
|
|
|
function resolveBrowserSsrFPolicy(cfg: BrowserConfig | undefined): SsrFPolicy | undefined {
|
|
const allowPrivateNetwork = cfg?.ssrfPolicy?.allowPrivateNetwork;
|
|
const dangerouslyAllowPrivateNetwork = cfg?.ssrfPolicy?.dangerouslyAllowPrivateNetwork;
|
|
const allowedHostnames = normalizeStringList(cfg?.ssrfPolicy?.allowedHostnames);
|
|
const hostnameAllowlist = normalizeStringList(cfg?.ssrfPolicy?.hostnameAllowlist);
|
|
const hasExplicitPrivateSetting =
|
|
allowPrivateNetwork !== undefined || dangerouslyAllowPrivateNetwork !== undefined;
|
|
// Browser defaults to trusted-network mode unless explicitly disabled by policy.
|
|
const resolvedAllowPrivateNetwork =
|
|
dangerouslyAllowPrivateNetwork === true ||
|
|
allowPrivateNetwork === true ||
|
|
!hasExplicitPrivateSetting;
|
|
|
|
if (
|
|
!resolvedAllowPrivateNetwork &&
|
|
!hasExplicitPrivateSetting &&
|
|
!allowedHostnames &&
|
|
!hostnameAllowlist
|
|
) {
|
|
return undefined;
|
|
}
|
|
|
|
return {
|
|
...(resolvedAllowPrivateNetwork ? { dangerouslyAllowPrivateNetwork: true } : {}),
|
|
...(allowedHostnames ? { allowedHostnames } : {}),
|
|
...(hostnameAllowlist ? { hostnameAllowlist } : {}),
|
|
};
|
|
}
|
|
|
|
export function parseHttpUrl(raw: string, label: string) {
|
|
const trimmed = raw.trim();
|
|
const parsed = new URL(trimmed);
|
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
throw new Error(`${label} must be http(s), got: ${parsed.protocol.replace(":", "")}`);
|
|
}
|
|
|
|
const port =
|
|
parsed.port && Number.parseInt(parsed.port, 10) > 0
|
|
? Number.parseInt(parsed.port, 10)
|
|
: parsed.protocol === "https:"
|
|
? 443
|
|
: 80;
|
|
|
|
if (Number.isNaN(port) || port <= 0 || port > 65535) {
|
|
throw new Error(`${label} has invalid port: ${parsed.port}`);
|
|
}
|
|
|
|
return {
|
|
parsed,
|
|
port,
|
|
normalized: parsed.toString().replace(/\/$/, ""),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Ensure the default "openclaw" profile exists in the profiles map.
|
|
* Auto-creates it with the legacy CDP port (from browser.cdpUrl) or first port if missing.
|
|
*/
|
|
function ensureDefaultProfile(
|
|
profiles: Record<string, BrowserProfileConfig> | undefined,
|
|
defaultColor: string,
|
|
legacyCdpPort?: number,
|
|
derivedDefaultCdpPort?: number,
|
|
): Record<string, BrowserProfileConfig> {
|
|
const result = { ...profiles };
|
|
if (!result[DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME]) {
|
|
result[DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME] = {
|
|
cdpPort: legacyCdpPort ?? derivedDefaultCdpPort ?? CDP_PORT_RANGE_START,
|
|
color: defaultColor,
|
|
};
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Ensure a built-in "chrome" profile exists for the Chrome extension relay.
|
|
*
|
|
* Note: this is an OpenClaw browser profile (routing config), not a Chrome user profile.
|
|
* It points at the local relay CDP endpoint (controlPort + 1).
|
|
*/
|
|
function ensureDefaultChromeExtensionProfile(
|
|
profiles: Record<string, BrowserProfileConfig>,
|
|
controlPort: number,
|
|
): Record<string, BrowserProfileConfig> {
|
|
const result = { ...profiles };
|
|
if (result.chrome) {
|
|
return result;
|
|
}
|
|
const relayPort = controlPort + 1;
|
|
if (!Number.isFinite(relayPort) || relayPort <= 0 || relayPort > 65535) {
|
|
return result;
|
|
}
|
|
// Avoid adding the built-in profile if the derived relay port is already used by another profile
|
|
// (legacy single-profile configs may use controlPort+1 for openclaw/openclaw CDP).
|
|
if (getUsedPorts(result).has(relayPort)) {
|
|
return result;
|
|
}
|
|
result.chrome = {
|
|
driver: "extension",
|
|
cdpUrl: `http://127.0.0.1:${relayPort}`,
|
|
color: "#00AA00",
|
|
};
|
|
return result;
|
|
}
|
|
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 = parseHttpUrl(rawCdpUrl, "browser.cdpUrl");
|
|
} else {
|
|
const derivedPort = controlPort + 1;
|
|
if (derivedPort > 65535) {
|
|
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 = cfg?.executablePath?.trim() || undefined;
|
|
|
|
const defaultProfileFromConfig = cfg?.defaultProfile?.trim() || undefined;
|
|
// Use legacy cdpUrl port for backward compatibility when no profiles configured
|
|
const legacyCdpPort = rawCdpUrl ? cdpInfo.port : undefined;
|
|
const profiles = ensureDefaultChromeExtensionProfile(
|
|
ensureDefaultProfile(cfg?.profiles, defaultColor, legacyCdpPort, cdpPortRangeStart),
|
|
controlPort,
|
|
);
|
|
const cdpProtocol = cdpInfo.parsed.protocol === "https:" ? "https" : "http";
|
|
|
|
// In headless/noSandbox environments (servers), prefer "openclaw" profile over "chrome"
|
|
// because Chrome extension relay requires a GUI browser which isn't available headless.
|
|
// Issue: https://github.com/openclaw/openclaw/issues/14895
|
|
const preferOpenClawProfile = headless || noSandbox;
|
|
const defaultProfile =
|
|
defaultProfileFromConfig ??
|
|
(preferOpenClawProfile && profiles[DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME]
|
|
? DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME
|
|
: profiles[DEFAULT_BROWSER_DEFAULT_PROFILE_NAME]
|
|
? DEFAULT_BROWSER_DEFAULT_PROFILE_NAME
|
|
: DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME);
|
|
|
|
const extraArgs = Array.isArray(cfg?.extraArgs)
|
|
? cfg.extraArgs.filter((a): a is string => typeof a === "string" && a.trim().length > 0)
|
|
: [];
|
|
const ssrfPolicy = resolveBrowserSsrFPolicy(cfg);
|
|
|
|
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,
|
|
extraArgs,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Resolve a profile by name from the config.
|
|
* Returns null if the profile doesn't exist.
|
|
*/
|
|
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 === "extension" ? "extension" : "openclaw";
|
|
|
|
if (rawProfileUrl) {
|
|
const parsed = parseHttpUrl(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,
|
|
};
|
|
}
|
|
|
|
export function shouldStartLocalBrowserServer(_resolved: ResolvedBrowserConfig) {
|
|
return true;
|
|
}
|