refactor: move browser runtime seams behind plugin metadata

This commit is contained in:
Peter Steinberger 2026-04-05 23:13:03 +01:00
parent 1351bacaa4
commit 471d056e2f
No known key found for this signature in database
44 changed files with 1441 additions and 1026 deletions

View File

@ -1 +1 @@
export { redactCdpUrl } from "./src/browser/cdp.helpers.js";
export { parseBrowserHttpUrl, redactCdpUrl } from "./src/browser/cdp.helpers.js";

View File

@ -0,0 +1 @@
export { noteChromeMcpBrowserReadiness } from "./src/doctor-browser.js";

View File

@ -0,0 +1,6 @@
export type { BrowserExecutable } from "./src/browser/chrome.executables.js";
export {
parseBrowserMajorVersion,
readBrowserVersion,
resolveGoogleChromeExecutableForPlatform,
} from "./src/browser/chrome.executables.js";

View File

@ -0,0 +1,2 @@
export { closeTrackedBrowserTabsForSessions } from "./src/browser/session-tab-registry.js";
export { movePathToTrash } from "./src/browser/trash.js";

View File

@ -5,6 +5,7 @@ export {
DEFAULT_OPENCLAW_BROWSER_COLOR,
DEFAULT_OPENCLAW_BROWSER_ENABLED,
DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME,
DEFAULT_UPLOAD_DIR,
resolveBrowserConfig,
resolveProfile,
type ResolvedBrowserConfig,

View File

@ -48,6 +48,17 @@ function createApi() {
}
describe("browser plugin", () => {
it("exposes static browser metadata on the plugin definition", () => {
expect(browserPlugin.reload).toEqual({ restartPrefixes: ["browser"] });
expect(browserPlugin.nodeHostCommands).toEqual([
expect.objectContaining({
command: "browser.proxy",
cap: "browser",
}),
]);
expect(browserPlugin.securityAuditCollectors).toHaveLength(1);
});
it("forwards per-session browser options into the tool factory", async () => {
const { api, registerTool } = createApi();
await browserPlugin.register(api);

View File

@ -4,16 +4,27 @@ import {
type OpenClawPluginToolFactory,
} from "openclaw/plugin-sdk/plugin-entry";
import {
collectBrowserSecurityAuditFindings,
createBrowserPluginService,
createBrowserTool,
handleBrowserGatewayRequest,
registerBrowserCli,
runBrowserProxyCommand,
} from "./register.runtime.js";
export default definePluginEntry({
id: "browser",
name: "Browser",
description: "Default browser tool plugin",
reload: { restartPrefixes: ["browser"] },
nodeHostCommands: [
{
command: "browser.proxy",
cap: "browser",
handle: runBrowserProxyCommand,
},
],
securityAuditCollectors: [collectBrowserSecurityAuditFindings],
register(api) {
api.registerTool(((ctx: OpenClawPluginToolContext) =>
createBrowserTool({

View File

@ -1,4 +1,6 @@
export { createBrowserTool } from "./src/browser-tool.js";
export { registerBrowserCli } from "./src/cli/browser-cli.js";
export { handleBrowserGatewayRequest } from "./src/gateway/browser-request.js";
export { runBrowserProxyCommand } from "./src/node-host/invoke-browser.js";
export { createBrowserPluginService } from "./src/plugin-service.js";
export { collectBrowserSecurityAuditFindings } from "./src/security-audit.js";

View File

@ -9,6 +9,33 @@ import { resolveBrowserRateLimitMessage } from "./client-fetch.js";
export { isLoopbackHost };
export function parseBrowserHttpUrl(raw: string, label: string) {
const trimmed = raw.trim();
const parsed = new URL(trimmed);
const allowed = ["http:", "https:", "ws:", "wss:"];
if (!allowed.includes(parsed.protocol)) {
throw new Error(`${label} must be http(s) or ws(s), got: ${parsed.protocol.replace(":", "")}`);
}
const isSecure = parsed.protocol === "https:" || parsed.protocol === "wss:";
const port =
parsed.port && Number.parseInt(parsed.port, 10) > 0
? Number.parseInt(parsed.port, 10)
: isSecure
? 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(/\/$/, ""),
};
}
/**
* Returns true when the URL uses a WebSocket protocol (ws: or wss:).
* Used to distinguish direct-WebSocket CDP endpoints

View File

@ -9,7 +9,7 @@ export type BrowserExecutable = {
path: string;
};
const CHROME_VERSION_RE = /(\d+)(?:\.\d+){0,3}/;
const CHROME_VERSION_RE = /\b(\d+)(?:\.\d+){1,3}\b/g;
const CHROMIUM_BUNDLE_IDS = new Set([
"com.google.Chrome",
@ -464,9 +464,13 @@ function findFirstExecutable(candidates: Array<BrowserExecutable>): BrowserExecu
function findFirstChromeExecutable(candidates: string[]): BrowserExecutable | null {
for (const candidate of candidates) {
if (exists(candidate)) {
const normalizedPath = candidate.toLowerCase();
return {
kind:
candidate.toLowerCase().includes("sxs") || candidate.toLowerCase().includes("canary")
normalizedPath.includes("beta") ||
normalizedPath.includes("canary") ||
normalizedPath.includes("sxs") ||
normalizedPath.includes("unstable")
? "canary"
: "chrome",
path: candidate,
@ -683,7 +687,8 @@ export function readBrowserVersion(executablePath: string): string | null {
}
export function parseBrowserMajorVersion(rawVersion: string | null | undefined): number | null {
const match = String(rawVersion ?? "").match(CHROME_VERSION_RE);
const matches = [...String(rawVersion ?? "").matchAll(CHROME_VERSION_RE)];
const match = matches.at(-1);
if (!match?.[1]) {
return null;
}

View File

@ -1,3 +1,28 @@
import {
type BrowserConfig,
type BrowserProfileConfig,
type OpenClawConfig,
} from "../config/config.js";
import { resolveGatewayPort } from "../config/paths.js";
import {
DEFAULT_BROWSER_CONTROL_PORT,
deriveDefaultBrowserCdpPortRange,
deriveDefaultBrowserControlPort,
} from "../config/port-defaults.js";
import type { SsrFPolicy } from "../infra/net/ssrf.js";
import { resolveUserPath } from "../utils.js";
import { parseBrowserHttpUrl, redactCdpUrl, isLoopbackHost } from "./cdp.helpers.js";
import {
DEFAULT_AI_SNAPSHOT_MAX_CHARS,
DEFAULT_BROWSER_DEFAULT_PROFILE_NAME,
DEFAULT_BROWSER_EVALUATE_ENABLED,
DEFAULT_OPENCLAW_BROWSER_COLOR,
DEFAULT_OPENCLAW_BROWSER_ENABLED,
DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME,
} from "./constants.js";
import { resolveBrowserControlAuth, type BrowserControlAuth } from "./control-auth.js";
import { DEFAULT_UPLOAD_DIR } from "./paths.js";
export {
DEFAULT_AI_SNAPSHOT_MAX_CHARS,
DEFAULT_BROWSER_DEFAULT_PROFILE_NAME,
@ -5,15 +30,329 @@ export {
DEFAULT_OPENCLAW_BROWSER_COLOR,
DEFAULT_OPENCLAW_BROWSER_ENABLED,
DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME,
resolveBrowserConfig,
resolveProfile,
type ResolvedBrowserConfig,
type ResolvedBrowserProfile,
} from "openclaw/plugin-sdk/browser-profiles";
export { parseBrowserHttpUrl, redactCdpUrl } from "openclaw/plugin-sdk/browser-cdp";
export type { BrowserControlAuth } from "openclaw/plugin-sdk/browser-control-auth";
export { resolveBrowserControlAuth } from "openclaw/plugin-sdk/browser-control-auth";
export { parseBrowserHttpUrl as parseHttpUrl } from "openclaw/plugin-sdk/browser-cdp";
DEFAULT_UPLOAD_DIR,
parseBrowserHttpUrl,
redactCdpUrl,
resolveBrowserControlAuth,
};
export type { BrowserControlAuth };
export { parseBrowserHttpUrl as parseHttpUrl };
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;
userDataDir?: string;
color: string;
driver: "openclaw" | "existing-session";
attachOnly: boolean;
};
const DEFAULT_BROWSER_CDP_PORT_RANGE_START = 18800;
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();
}
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 > 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 rawPolicy = cfg?.ssrfPolicy as
| (BrowserConfig["ssrfPolicy"] & { allowPrivateNetwork?: boolean })
| undefined;
const allowPrivateNetwork = rawPolicy?.allowPrivateNetwork;
const dangerouslyAllowPrivateNetwork = rawPolicy?.dangerouslyAllowPrivateNetwork;
const allowedHostnames = normalizeStringList(rawPolicy?.allowedHostnames);
const hostnameAllowlist = normalizeStringList(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;
}
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 > 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;
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,
};
}
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,
};
}
export function shouldStartLocalBrowserServer(_resolved: unknown) {
return true;

View File

@ -0,0 +1,43 @@
import fs from "node:fs";
import os from "node:os";
import { beforeEach, describe, expect, it, vi } from "vitest";
const runExec = vi.hoisted(() => vi.fn());
vi.mock("../process/exec.js", () => ({
runExec,
}));
describe("browser trash", () => {
beforeEach(() => {
vi.restoreAllMocks();
runExec.mockReset();
vi.spyOn(Date, "now").mockReturnValue(123);
vi.spyOn(os, "homedir").mockReturnValue("/home/test");
});
it("returns the target path when trash exits successfully", async () => {
const { movePathToTrash } = await import("./trash.js");
runExec.mockResolvedValue(undefined);
const mkdirSync = vi.spyOn(fs, "mkdirSync");
const renameSync = vi.spyOn(fs, "renameSync");
await expect(movePathToTrash("/tmp/demo")).resolves.toBe("/tmp/demo");
expect(runExec).toHaveBeenCalledWith("trash", ["/tmp/demo"], { timeoutMs: 10_000 });
expect(mkdirSync).not.toHaveBeenCalled();
expect(renameSync).not.toHaveBeenCalled();
});
it("falls back to rename when trash exits non-zero", async () => {
const { movePathToTrash } = await import("./trash.js");
runExec.mockRejectedValue(new Error("permission denied"));
const mkdirSync = vi.spyOn(fs, "mkdirSync").mockImplementation(() => undefined);
const existsSync = vi.spyOn(fs, "existsSync").mockReturnValue(false);
const renameSync = vi.spyOn(fs, "renameSync").mockImplementation(() => undefined);
await expect(movePathToTrash("/tmp/demo")).resolves.toBe("/home/test/.Trash/demo-123");
expect(mkdirSync).toHaveBeenCalledWith("/home/test/.Trash", { recursive: true });
expect(existsSync).toHaveBeenCalledWith("/home/test/.Trash/demo-123");
expect(renameSync).toHaveBeenCalledWith("/tmp/demo", "/home/test/.Trash/demo-123");
});
});

View File

@ -0,0 +1,150 @@
import { note } from "openclaw/plugin-sdk/browser-support";
import {
parseBrowserMajorVersion,
readBrowserVersion,
resolveGoogleChromeExecutableForPlatform,
} from "./browser/chrome.executables.js";
import type { OpenClawConfig } from "./config/config.js";
const CHROME_MCP_MIN_MAJOR = 144;
const REMOTE_DEBUGGING_PAGES = [
"chrome://inspect/#remote-debugging",
"brave://inspect/#remote-debugging",
"edge://inspect/#remote-debugging",
].join(", ");
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
type ExistingSessionProfile = {
name: string;
userDataDir?: string;
};
function collectChromeMcpProfiles(cfg: OpenClawConfig): ExistingSessionProfile[] {
const browser = asRecord(cfg.browser);
if (!browser) {
return [];
}
const profiles = new Map<string, ExistingSessionProfile>();
const defaultProfile =
typeof browser.defaultProfile === "string" ? browser.defaultProfile.trim() : "";
if (defaultProfile === "user") {
profiles.set("user", { name: "user" });
}
const configuredProfiles = asRecord(browser.profiles);
if (!configuredProfiles) {
return [...profiles.values()].toSorted((a, b) => a.name.localeCompare(b.name));
}
for (const [profileName, rawProfile] of Object.entries(configuredProfiles)) {
const profile = asRecord(rawProfile);
const driver = typeof profile?.driver === "string" ? profile.driver.trim() : "";
if (driver === "existing-session") {
const userDataDir =
typeof profile?.userDataDir === "string" ? profile.userDataDir.trim() : undefined;
profiles.set(profileName, { name: profileName, userDataDir: userDataDir || undefined });
}
}
return [...profiles.values()].toSorted((a, b) => a.name.localeCompare(b.name));
}
export async function noteChromeMcpBrowserReadiness(
cfg: OpenClawConfig,
deps?: {
platform?: NodeJS.Platform;
noteFn?: typeof note;
resolveChromeExecutable?: (platform: NodeJS.Platform) => { path: string } | null;
readVersion?: (executablePath: string) => string | null;
},
) {
const profiles = collectChromeMcpProfiles(cfg);
if (profiles.length === 0) {
return;
}
const noteFn = deps?.noteFn ?? note;
const platform = deps?.platform ?? process.platform;
const resolveChromeExecutable =
deps?.resolveChromeExecutable ?? resolveGoogleChromeExecutableForPlatform;
const readVersion = deps?.readVersion ?? readBrowserVersion;
const explicitProfiles = profiles.filter((profile) => profile.userDataDir);
const autoConnectProfiles = profiles.filter((profile) => !profile.userDataDir);
const profileLabel = profiles.map((profile) => profile.name).join(", ");
if (autoConnectProfiles.length === 0) {
noteFn(
[
`- Chrome MCP existing-session is configured for profile(s): ${profileLabel}.`,
"- These profiles use an explicit Chromium user data directory instead of Chrome's default auto-connect path.",
`- Verify the matching Chromium-based browser is version ${CHROME_MCP_MIN_MAJOR}+ on the same host as the Gateway or node.`,
`- Enable remote debugging in that browser's inspect page (${REMOTE_DEBUGGING_PAGES}).`,
"- Keep the browser running and accept the attach consent prompt the first time OpenClaw connects.",
].join("\n"),
"Browser",
);
return;
}
const chrome = resolveChromeExecutable(platform);
const autoProfileLabel = autoConnectProfiles.map((profile) => profile.name).join(", ");
if (!chrome) {
const lines = [
`- Chrome MCP existing-session is configured for profile(s): ${profileLabel}.`,
`- Google Chrome was not found on this host for auto-connect profile(s): ${autoProfileLabel}. OpenClaw does not bundle Chrome.`,
`- Install Google Chrome ${CHROME_MCP_MIN_MAJOR}+ on the same host as the Gateway or node, or set browser.profiles.<name>.userDataDir for a different Chromium-based browser.`,
`- Enable remote debugging in the browser inspect page (${REMOTE_DEBUGGING_PAGES}).`,
"- Keep the browser running and accept the attach consent prompt the first time OpenClaw connects.",
"- Docker, headless, and sandbox browser flows stay on raw CDP; this check only applies to host-local Chrome MCP attach.",
];
if (explicitProfiles.length > 0) {
lines.push(
`- Profiles with explicit userDataDir skip Chrome auto-detection: ${explicitProfiles
.map((profile) => profile.name)
.join(", ")}.`,
);
}
noteFn(lines.join("\n"), "Browser");
return;
}
const versionRaw = readVersion(chrome.path);
const major = parseBrowserMajorVersion(versionRaw);
const lines = [
`- Chrome MCP existing-session is configured for profile(s): ${profileLabel}.`,
`- Chrome path: ${chrome.path}`,
];
if (!versionRaw || major === null) {
lines.push(
`- Could not determine the installed Chrome version. Chrome MCP requires Google Chrome ${CHROME_MCP_MIN_MAJOR}+ on this host.`,
);
} else if (major < CHROME_MCP_MIN_MAJOR) {
lines.push(
`- Detected Chrome ${versionRaw}, which is too old for Chrome MCP existing-session attach. Upgrade to Chrome ${CHROME_MCP_MIN_MAJOR}+.`,
);
} else {
lines.push(`- Detected Chrome ${versionRaw}.`);
}
lines.push(`- Enable remote debugging in the browser inspect page (${REMOTE_DEBUGGING_PAGES}).`);
lines.push(
"- Keep the browser running and accept the attach consent prompt the first time OpenClaw connects.",
);
if (explicitProfiles.length > 0) {
lines.push(
`- Profiles with explicit userDataDir still need manual validation of the matching Chromium-based browser: ${explicitProfiles
.map((profile) => profile.name)
.join(", ")}.`,
);
}
noteFn(lines.join("\n"), "Browser");
}

View File

@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import { collectBrowserSecurityAuditFindings } from "./security-audit.js";
function collectFindings(
config: Parameters<typeof collectBrowserSecurityAuditFindings>[0]["config"],
) {
return collectBrowserSecurityAuditFindings({
config,
sourceConfig: config,
env: {} as NodeJS.ProcessEnv,
stateDir: "/tmp/openclaw-state",
configPath: "/tmp/openclaw.json",
});
}
describe("browser security audit collector", () => {
it("flags browser control without auth", () => {
const findings = collectFindings({
gateway: {
controlUi: { enabled: false },
auth: {},
},
browser: {
enabled: true,
},
});
expect(findings).toEqual(
expect.arrayContaining([
expect.objectContaining({
checkId: "browser.control_no_auth",
severity: "critical",
}),
]),
);
});
it("warns on remote http CDP profiles", () => {
const findings = collectFindings({
browser: {
profiles: {
remote: {
cdpUrl: "http://example.com:9222",
color: "#0066CC",
},
},
},
});
expect(findings).toEqual(
expect.arrayContaining([
expect.objectContaining({
checkId: "browser.remote_cdp_http",
severity: "warn",
}),
]),
);
});
it("redacts private-host CDP URLs in findings", () => {
const findings = collectFindings({
browser: {
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
profiles: {
remote: {
cdpUrl:
"http://169.254.169.254:9222/json/version?token=supersecrettokenvalue1234567890",
color: "#0066CC",
},
},
},
});
expect(findings).toEqual(
expect.arrayContaining([
expect.objectContaining({
checkId: "browser.remote_cdp_private_host",
severity: "warn",
detail: expect.stringContaining("token=supers…7890"),
}),
]),
);
});
});

View File

@ -0,0 +1,122 @@
import type { OpenClawPluginSecurityAuditContext } from "openclaw/plugin-sdk/plugin-entry";
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input";
import { formatCliCommand } from "openclaw/plugin-sdk/setup-tools";
import { isPrivateNetworkOptInEnabled, isPrivateIpAddress } from "openclaw/plugin-sdk/ssrf-policy";
import { redactCdpUrl, resolveBrowserConfig, resolveProfile } from "./browser/config.js";
import { resolveBrowserControlAuth } from "./browser/control-auth.js";
const BLOCKED_HOSTNAMES = new Set([
"localhost",
"localhost.localdomain",
"metadata.google.internal",
]);
function hasNonEmptyString(value: unknown): boolean {
return typeof value === "string" && value.trim().length > 0;
}
function isTrustedPrivateHostname(hostname: string): boolean {
const normalized = hostname.trim().toLowerCase();
return normalized.length > 0 && BLOCKED_HOSTNAMES.has(normalized);
}
export function collectBrowserSecurityAuditFindings(ctx: OpenClawPluginSecurityAuditContext) {
const findings: Array<{
checkId: string;
severity: "warn" | "critical";
title: string;
detail: string;
remediation?: string;
}> = [];
let resolved: ReturnType<typeof resolveBrowserConfig>;
try {
resolved = resolveBrowserConfig(ctx.config.browser, ctx.config);
} catch (err) {
findings.push({
checkId: "browser.control_invalid_config",
severity: "warn" as const,
title: "Browser control config looks invalid",
detail: String(err),
remediation: `Fix browser.cdpUrl in ${ctx.configPath} and re-run "${formatCliCommand("openclaw security audit --deep")}".`,
});
return findings;
}
if (!resolved.enabled) {
return findings;
}
const browserAuth = resolveBrowserControlAuth(ctx.config, ctx.env);
const explicitAuthMode = ctx.config.gateway?.auth?.mode;
const tokenConfigured =
Boolean(browserAuth.token) ||
hasNonEmptyString(ctx.env.OPENCLAW_GATEWAY_TOKEN) ||
hasConfiguredSecretInput(ctx.config.gateway?.auth?.token, ctx.config.secrets?.defaults);
const passwordCanWin =
explicitAuthMode === "password" ||
(explicitAuthMode !== "token" &&
explicitAuthMode !== "none" &&
explicitAuthMode !== "trusted-proxy" &&
!tokenConfigured);
const passwordConfigured =
Boolean(browserAuth.password) ||
(passwordCanWin &&
(hasNonEmptyString(ctx.env.OPENCLAW_GATEWAY_PASSWORD) ||
hasConfiguredSecretInput(
ctx.config.gateway?.auth?.password,
ctx.config.secrets?.defaults,
)));
if (!tokenConfigured && !passwordConfigured) {
findings.push({
checkId: "browser.control_no_auth",
severity: "critical" as const,
title: "Browser control has no auth",
detail:
"Browser control HTTP routes are enabled but no gateway.auth token/password is configured. " +
"Any local process (or SSRF to loopback) can call browser control endpoints.",
remediation:
"Set gateway.auth.token (recommended) or gateway.auth.password so browser control HTTP routes require authentication. Restarting the gateway will auto-generate gateway.auth.token when browser control is enabled.",
});
}
for (const name of Object.keys(resolved.profiles)) {
const profile = resolveProfile(resolved, name);
if (!profile || profile.cdpIsLoopback) {
continue;
}
let url: URL;
try {
url = new URL(profile.cdpUrl);
} catch {
continue;
}
const redactedCdpUrl = redactCdpUrl(profile.cdpUrl) ?? profile.cdpUrl;
if (url.protocol === "http:") {
findings.push({
checkId: "browser.remote_cdp_http",
severity: "warn" as const,
title: "Remote CDP uses HTTP",
detail: `browser profile "${name}" uses http CDP (${redactedCdpUrl}); this is OK only if it's tailnet-only or behind an encrypted tunnel.`,
remediation: "Prefer HTTPS/TLS or a tailnet-only endpoint for remote CDP.",
});
}
if (
isPrivateNetworkOptInEnabled(resolved.ssrfPolicy) &&
(isTrustedPrivateHostname(url.hostname) || isPrivateIpAddress(url.hostname))
) {
findings.push({
checkId: "browser.remote_cdp_private_host",
severity: "warn" as const,
title: "Remote CDP targets a private/internal host",
detail:
`browser profile "${name}" points at a private/internal CDP host (${redactedCdpUrl}). ` +
"This is expected for LAN/tailnet/WSL-style setups, but treat it as a trusted-network endpoint.",
remediation:
"Prefer a tailnet or tunnel for remote CDP. If you want strict blocking, set browser.ssrfPolicy.dangerouslyAllowPrivateNetwork=false and allow only explicit hosts.",
});
}
}
return findings;
}

View File

@ -44,6 +44,9 @@ function fakeApi(overrides: Partial<OpenClawPluginApi> = {}): OpenClawPluginApi
registerGatewayMethod() {},
registerCli() {},
registerService() {},
registerReload() {},
registerNodeHostCommand() {},
registerSecurityAuditCollector() {},
registerConfigMigration() {},
registerAutoEnableProbe() {},
registerProvider() {},

View File

@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import {
rewriteUpdateFlagArgv,
resolveMissingBrowserCommandMessage,
resolveMissingPluginCommandMessage,
shouldEnsureCliPath,
shouldRegisterPrimarySubcommand,
shouldSkipPluginCommandRegistration,
@ -138,10 +138,10 @@ describe("shouldUseRootHelpFastPath", () => {
});
});
describe("resolveMissingBrowserCommandMessage", () => {
it("explains plugins.allow misses for the browser command", () => {
describe("resolveMissingPluginCommandMessage", () => {
it("explains plugins.allow misses for a bundled plugin command", () => {
expect(
resolveMissingBrowserCommandMessage({
resolveMissingPluginCommandMessage("browser", {
plugins: {
allow: ["telegram"],
},
@ -149,9 +149,9 @@ describe("resolveMissingBrowserCommandMessage", () => {
).toContain('`plugins.allow` excludes "browser"');
});
it("explains explicit bundled browser disablement", () => {
it("explains explicit bundled plugin disablement", () => {
expect(
resolveMissingBrowserCommandMessage({
resolveMissingPluginCommandMessage("browser", {
plugins: {
entries: {
browser: {
@ -163,9 +163,9 @@ describe("resolveMissingBrowserCommandMessage", () => {
).toContain("plugins.entries.browser.enabled=false");
});
it("returns null when browser is already allowed", () => {
it("returns null when the bundled plugin command is already allowed", () => {
expect(
resolveMissingBrowserCommandMessage({
resolveMissingPluginCommandMessage("browser", {
plugins: {
allow: ["browser"],
},

View File

@ -88,23 +88,32 @@ export function shouldUseRootHelpFastPath(argv: string[]): boolean {
return isRootHelpInvocation(argv);
}
export function resolveMissingBrowserCommandMessage(config?: OpenClawConfig): string | null {
export function resolveMissingPluginCommandMessage(
pluginId: string,
config?: OpenClawConfig,
): string | null {
const normalizedPluginId = pluginId.trim().toLowerCase();
if (!normalizedPluginId) {
return null;
}
const allow =
Array.isArray(config?.plugins?.allow) && config.plugins.allow.length > 0
? config.plugins.allow
.filter((entry): entry is string => typeof entry === "string")
.map((entry) => entry.trim().toLowerCase())
: [];
if (allow.length > 0 && !allow.includes("browser")) {
if (allow.length > 0 && !allow.includes(normalizedPluginId)) {
return (
'The `openclaw browser` command is unavailable because `plugins.allow` excludes "browser". ' +
'Add "browser" to `plugins.allow` if you want the bundled browser CLI and tool.'
`The \`openclaw ${normalizedPluginId}\` command is unavailable because ` +
`\`plugins.allow\` excludes "${normalizedPluginId}". Add "${normalizedPluginId}" to ` +
`\`plugins.allow\` if you want that bundled plugin CLI surface.`
);
}
if (config?.plugins?.entries?.browser?.enabled === false) {
if (config?.plugins?.entries?.[normalizedPluginId]?.enabled === false) {
return (
"The `openclaw browser` command is unavailable because `plugins.entries.browser.enabled=false`. " +
"Re-enable that entry if you want the bundled browser CLI and tool."
`The \`openclaw ${normalizedPluginId}\` command is unavailable because ` +
`\`plugins.entries.${normalizedPluginId}.enabled=false\`. Re-enable that entry if you want ` +
"the bundled plugin CLI surface."
);
}
return null;
@ -220,13 +229,10 @@ export async function runCli(argv: string[] = process.argv) {
mode: "lazy",
primary,
});
if (
primary === "browser" &&
!program.commands.some((command) => command.name() === "browser")
) {
const browserCommandMessage = resolveMissingBrowserCommandMessage(config);
if (browserCommandMessage) {
throw new Error(browserCommandMessage);
if (primary && !program.commands.some((command) => command.name() === primary)) {
const missingPluginCommandMessage = resolveMissingPluginCommandMessage(primary, config);
if (missingPluginCommandMessage) {
throw new Error(missingPluginCommandMessage);
}
}
}

View File

@ -1,150 +1 @@
import type { OpenClawConfig } from "../config/config.js";
import {
parseBrowserMajorVersion,
readBrowserVersion,
resolveGoogleChromeExecutableForPlatform,
} from "../plugin-sdk/browser-host-inspection.js";
import { note } from "../terminal/note.js";
const CHROME_MCP_MIN_MAJOR = 144;
const REMOTE_DEBUGGING_PAGES = [
"chrome://inspect/#remote-debugging",
"brave://inspect/#remote-debugging",
"edge://inspect/#remote-debugging",
].join(", ");
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
type ExistingSessionProfile = {
name: string;
userDataDir?: string;
};
function collectChromeMcpProfiles(cfg: OpenClawConfig): ExistingSessionProfile[] {
const browser = asRecord(cfg.browser);
if (!browser) {
return [];
}
const profiles = new Map<string, ExistingSessionProfile>();
const defaultProfile =
typeof browser.defaultProfile === "string" ? browser.defaultProfile.trim() : "";
if (defaultProfile === "user") {
profiles.set("user", { name: "user" });
}
const configuredProfiles = asRecord(browser.profiles);
if (!configuredProfiles) {
return [...profiles.values()].toSorted((a, b) => a.name.localeCompare(b.name));
}
for (const [profileName, rawProfile] of Object.entries(configuredProfiles)) {
const profile = asRecord(rawProfile);
const driver = typeof profile?.driver === "string" ? profile.driver.trim() : "";
if (driver === "existing-session") {
const userDataDir =
typeof profile?.userDataDir === "string" ? profile.userDataDir.trim() : undefined;
profiles.set(profileName, { name: profileName, userDataDir: userDataDir || undefined });
}
}
return [...profiles.values()].toSorted((a, b) => a.name.localeCompare(b.name));
}
export async function noteChromeMcpBrowserReadiness(
cfg: OpenClawConfig,
deps?: {
platform?: NodeJS.Platform;
noteFn?: typeof note;
resolveChromeExecutable?: (platform: NodeJS.Platform) => { path: string } | null;
readVersion?: (executablePath: string) => string | null;
},
) {
const profiles = collectChromeMcpProfiles(cfg);
if (profiles.length === 0) {
return;
}
const noteFn = deps?.noteFn ?? note;
const platform = deps?.platform ?? process.platform;
const resolveChromeExecutable =
deps?.resolveChromeExecutable ?? resolveGoogleChromeExecutableForPlatform;
const readVersion = deps?.readVersion ?? readBrowserVersion;
const explicitProfiles = profiles.filter((profile) => profile.userDataDir);
const autoConnectProfiles = profiles.filter((profile) => !profile.userDataDir);
const profileLabel = profiles.map((profile) => profile.name).join(", ");
if (autoConnectProfiles.length === 0) {
noteFn(
[
`- Chrome MCP existing-session is configured for profile(s): ${profileLabel}.`,
"- These profiles use an explicit Chromium user data directory instead of Chrome's default auto-connect path.",
`- Verify the matching Chromium-based browser is version ${CHROME_MCP_MIN_MAJOR}+ on the same host as the Gateway or node.`,
`- Enable remote debugging in that browser's inspect page (${REMOTE_DEBUGGING_PAGES}).`,
"- Keep the browser running and accept the attach consent prompt the first time OpenClaw connects.",
].join("\n"),
"Browser",
);
return;
}
const chrome = resolveChromeExecutable(platform);
const autoProfileLabel = autoConnectProfiles.map((profile) => profile.name).join(", ");
if (!chrome) {
const lines = [
`- Chrome MCP existing-session is configured for profile(s): ${profileLabel}.`,
`- Google Chrome was not found on this host for auto-connect profile(s): ${autoProfileLabel}. OpenClaw does not bundle Chrome.`,
`- Install Google Chrome ${CHROME_MCP_MIN_MAJOR}+ on the same host as the Gateway or node, or set browser.profiles.<name>.userDataDir for a different Chromium-based browser.`,
`- Enable remote debugging in the browser inspect page (${REMOTE_DEBUGGING_PAGES}).`,
"- Keep the browser running and accept the attach consent prompt the first time OpenClaw connects.",
"- Docker, headless, and sandbox browser flows stay on raw CDP; this check only applies to host-local Chrome MCP attach.",
];
if (explicitProfiles.length > 0) {
lines.push(
`- Profiles with explicit userDataDir skip Chrome auto-detection: ${explicitProfiles
.map((profile) => profile.name)
.join(", ")}.`,
);
}
noteFn(lines.join("\n"), "Browser");
return;
}
const versionRaw = readVersion(chrome.path);
const major = parseBrowserMajorVersion(versionRaw);
const lines = [
`- Chrome MCP existing-session is configured for profile(s): ${profileLabel}.`,
`- Chrome path: ${chrome.path}`,
];
if (!versionRaw || major === null) {
lines.push(
`- Could not determine the installed Chrome version. Chrome MCP requires Google Chrome ${CHROME_MCP_MIN_MAJOR}+ on this host.`,
);
} else if (major < CHROME_MCP_MIN_MAJOR) {
lines.push(
`- Detected Chrome ${versionRaw}, which is too old for Chrome MCP existing-session attach. Upgrade to Chrome ${CHROME_MCP_MIN_MAJOR}+.`,
);
} else {
lines.push(`- Detected Chrome ${versionRaw}.`);
}
lines.push(`- Enable remote debugging in the browser inspect page (${REMOTE_DEBUGGING_PAGES}).`);
lines.push(
"- Keep the browser running and accept the attach consent prompt the first time OpenClaw connects.",
);
if (explicitProfiles.length > 0) {
lines.push(
`- Profiles with explicit userDataDir still need manual validation of the matching Chromium-based browser: ${explicitProfiles
.map((profile) => profile.name)
.join(", ")}.`,
);
}
noteFn(lines.join("\n"), "Browser");
}
export { noteChromeMcpBrowserReadiness } from "../../extensions/browser/browser-doctor.js";

View File

@ -80,7 +80,6 @@ const BASE_RELOAD_RULES: ReloadRule[] = [
},
{ prefix: "agent.heartbeat", kind: "hot", actions: ["restart-heartbeat"] },
{ prefix: "cron", kind: "hot", actions: ["restart-cron"] },
{ prefix: "browser", kind: "restart" },
];
const BASE_RELOAD_RULES_TAIL: ReloadRule[] = [
@ -134,7 +133,32 @@ function listReloadRules(): ReloadRule[] {
}),
),
]);
const rules = [...BASE_RELOAD_RULES, ...channelReloadRules, ...BASE_RELOAD_RULES_TAIL];
const pluginReloadRules: ReloadRule[] = (registry?.reloads ?? []).flatMap((entry) => [
...(entry.registration.restartPrefixes ?? []).map(
(prefix): ReloadRule => ({
prefix,
kind: "restart",
}),
),
...(entry.registration.hotPrefixes ?? []).map(
(prefix): ReloadRule => ({
prefix,
kind: "hot",
}),
),
...(entry.registration.noopPrefixes ?? []).map(
(prefix): ReloadRule => ({
prefix,
kind: "none",
}),
),
]);
const rules = [
...BASE_RELOAD_RULES,
...pluginReloadRules,
...channelReloadRules,
...BASE_RELOAD_RULES_TAIL,
];
cachedReloadRules = rules;
return rules;
}

View File

@ -123,6 +123,14 @@ describe("buildGatewayReloadPlan", () => {
{ pluginId: "telegram", plugin: telegramPlugin, source: "test" },
{ pluginId: "whatsapp", plugin: whatsappPlugin, source: "test" },
]);
registry.reloads = [
{
pluginId: "browser",
pluginName: "Browser",
registration: { restartPrefixes: ["browser"] },
source: "test",
},
];
beforeEach(() => {
setActivePluginRegistry(registry);

View File

@ -19,7 +19,6 @@ import {
type ExecHostResponse,
} from "../infra/exec-host.js";
import { sanitizeHostExecEnv } from "../infra/host-env-security.js";
import { runBrowserProxyCommand } from "../plugin-sdk/browser-node-host.js";
import { buildSystemRunApprovalPlan, handleSystemRunInvoke } from "./invoke-system-run.js";
import type {
ExecEventPayload,
@ -28,6 +27,7 @@ import type {
SkillBinsProvider,
SystemRunParams,
} from "./invoke-types.js";
import { invokeRegisteredNodeHostCommand } from "./plugin-node-host.js";
const OUTPUT_CAP = 200_000;
const OUTPUT_EVENT_TAIL = 20_000;
@ -480,13 +480,14 @@ export async function handleInvoke(
return;
}
if (command === "browser.proxy") {
try {
const payload = await runBrowserProxyCommand(frame.paramsJSON);
await sendRawPayloadResult(client, frame, payload);
} catch (err) {
await sendInvalidRequestResult(client, frame, err);
try {
const pluginNodeHostResult = await invokeRegisteredNodeHostCommand(command, frame.paramsJSON);
if (pluginNodeHostResult !== null) {
await sendRawPayloadResult(client, frame, pluginNodeHostResult);
return;
}
} catch (err) {
await sendInvalidRequestResult(client, frame, err);
return;
}

View File

@ -0,0 +1,79 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js";
import {
invokeRegisteredNodeHostCommand,
listRegisteredNodeHostCapsAndCommands,
} from "./plugin-node-host.js";
afterEach(() => {
resetPluginRuntimeStateForTest();
});
describe("plugin node-host registry", () => {
it("lists plugin-declared caps and commands", () => {
const registry = createEmptyPluginRegistry();
registry.nodeHostCommands = [
{
pluginId: "browser",
pluginName: "Browser",
command: {
command: "browser.proxy",
cap: "browser",
handle: vi.fn(async () => "{}"),
},
source: "test",
},
{
pluginId: "photos",
pluginName: "Photos",
command: {
command: "photos.proxy",
cap: "photos",
handle: vi.fn(async () => "{}"),
},
source: "test",
},
{
pluginId: "browser-dup",
pluginName: "Browser Dup",
command: {
command: "browser.inspect",
cap: "browser",
handle: vi.fn(async () => "{}"),
},
source: "test",
},
];
setActivePluginRegistry(registry);
expect(listRegisteredNodeHostCapsAndCommands()).toEqual({
caps: ["browser", "photos"],
commands: ["browser.inspect", "browser.proxy", "photos.proxy"],
});
});
it("dispatches plugin-declared node-host commands", async () => {
const handle = vi.fn(async (paramsJSON?: string | null) => paramsJSON ?? "");
const registry = createEmptyPluginRegistry();
registry.nodeHostCommands = [
{
pluginId: "browser",
pluginName: "Browser",
command: {
command: "browser.proxy",
cap: "browser",
handle,
},
source: "test",
},
];
setActivePluginRegistry(registry);
await expect(invokeRegisteredNodeHostCommand("browser.proxy", '{"ok":true}')).resolves.toBe(
'{"ok":true}',
);
await expect(invokeRegisteredNodeHostCommand("missing.command", null)).resolves.toBeNull();
expect(handle).toHaveBeenCalledWith('{"ok":true}');
});
});

View File

@ -0,0 +1,56 @@
import type { OpenClawConfig } from "../config/config.js";
import { getActivePluginRegistry } from "../plugins/runtime.js";
let pluginRegistryLoaderModulePromise:
| Promise<typeof import("../plugins/runtime/runtime-registry-loader.js")>
| undefined;
async function loadPluginRegistryLoaderModule() {
pluginRegistryLoaderModulePromise ??= import("../plugins/runtime/runtime-registry-loader.js");
return await pluginRegistryLoaderModulePromise;
}
export async function ensureNodeHostPluginRegistry(params: {
config: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): Promise<void> {
(await loadPluginRegistryLoaderModule()).ensurePluginRegistryLoaded({
scope: "all",
config: params.config,
activationSourceConfig: params.config,
env: params.env,
});
}
export function listRegisteredNodeHostCapsAndCommands(): {
caps: string[];
commands: string[];
} {
const registry = getActivePluginRegistry();
const caps = new Set<string>();
const commands = new Set<string>();
for (const entry of registry?.nodeHostCommands ?? []) {
if (entry.command.cap) {
caps.add(entry.command.cap);
}
commands.add(entry.command.command);
}
return {
caps: [...caps].toSorted((left, right) => left.localeCompare(right)),
commands: [...commands].toSorted((left, right) => left.localeCompare(right)),
};
}
export async function invokeRegisteredNodeHostCommand(
command: string,
paramsJSON?: string | null,
): Promise<string | null> {
const registry = getActivePluginRegistry();
const match = (registry?.nodeHostCommands ?? []).find(
(entry) => entry.command.command === command,
);
if (!match) {
return null;
}
return await match.command.handle(paramsJSON);
}

View File

@ -5,13 +5,8 @@ import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
import type { SkillBinTrustEntry } from "../infra/exec-approvals.js";
import { resolveExecutableFromPathEnv } from "../infra/executable-path.js";
import { getMachineDisplayName } from "../infra/machine-name.js";
import {
NODE_BROWSER_PROXY_COMMAND,
NODE_EXEC_APPROVALS_COMMANDS,
NODE_SYSTEM_RUN_COMMANDS,
} from "../infra/node-commands.js";
import { NODE_EXEC_APPROVALS_COMMANDS, NODE_SYSTEM_RUN_COMMANDS } from "../infra/node-commands.js";
import { ensureOpenClawCliOnPath } from "../infra/path-env.js";
import { resolveBrowserConfig } from "../plugin-sdk/browser-profiles.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { VERSION } from "../version.js";
import { ensureNodeHostConfig, saveNodeHostConfig, type NodeHostGatewayConfig } from "./config.js";
@ -21,6 +16,10 @@ import {
buildNodeInvokeResultParams,
handleInvoke,
} from "./invoke.js";
import {
ensureNodeHostPluginRegistry,
listRegisteredNodeHostCapsAndCommands,
} from "./plugin-node-host.js";
export { buildNodeInvokeResultParams };
@ -164,9 +163,8 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
await saveNodeHostConfig(config);
const cfg = loadConfig();
const resolvedBrowser = resolveBrowserConfig(cfg.browser, cfg);
const browserProxyEnabled =
cfg.nodeHost?.browserProxy?.enabled !== false && resolvedBrowser.enabled;
await ensureNodeHostPluginRegistry({ config: cfg, env: process.env });
const pluginNodeHost = listRegisteredNodeHostCapsAndCommands();
const { token, password } = await resolveNodeHostGatewayCredentials({
config: cfg,
env: process.env,
@ -190,11 +188,11 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
mode: GATEWAY_CLIENT_MODES.NODE,
role: "node",
scopes: [],
caps: ["system", ...(browserProxyEnabled ? ["browser"] : [])],
caps: ["system", ...pluginNodeHost.caps],
commands: [
...NODE_SYSTEM_RUN_COMMANDS,
...NODE_EXEC_APPROVALS_COMMANDS,
...(browserProxyEnabled ? [NODE_BROWSER_PROXY_COMMAND] : []),
...pluginNodeHost.commands,
],
pathEnv,
permissions: undefined,

View File

@ -144,7 +144,7 @@ describe("plugin activation boundary", () => {
expect(loadBundledPluginPublicSurfaceModuleSync).not.toHaveBeenCalled();
});
it("does not load the browser plugin for static browser config helpers", async () => {
it("keeps browser helper imports cold and loads only narrow browser helper surfaces on use", async () => {
const browser = await importBrowserHelpers();
expect(browser.DEFAULT_AI_SNAPSHOT_MAX_CHARS).toBe(80_000);
@ -152,6 +152,7 @@ describe("plugin activation boundary", () => {
expect(browser.DEFAULT_OPENCLAW_BROWSER_COLOR).toBe("#FF4500");
expect(browser.DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME).toBe("openclaw");
expect(browser.DEFAULT_UPLOAD_DIR).toContain("uploads");
expect(loadBundledPluginPublicSurfaceModuleSync).not.toHaveBeenCalled();
expect(browser.parseBrowserMajorVersion("Google Chrome 144.0.7534.0")).toBe(144);
expect(browser.resolveBrowserControlAuth({}, {} as NodeJS.ProcessEnv)).toEqual({
token: undefined,

View File

@ -1,46 +1 @@
import { redactSensitiveText } from "./browser-support.js";
export function parseBrowserHttpUrl(raw: string, label: string) {
const trimmed = raw.trim();
const parsed = new URL(trimmed);
const allowed = ["http:", "https:", "ws:", "wss:"];
if (!allowed.includes(parsed.protocol)) {
throw new Error(`${label} must be http(s) or ws(s), got: ${parsed.protocol.replace(":", "")}`);
}
const isSecure = parsed.protocol === "https:" || parsed.protocol === "wss:";
const port =
parsed.port && Number.parseInt(parsed.port, 10) > 0
? Number.parseInt(parsed.port, 10)
: isSecure
? 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(/\/$/, ""),
};
}
export function redactCdpUrl(cdpUrl: string | null | undefined): string | null | undefined {
if (typeof cdpUrl !== "string") {
return cdpUrl;
}
const trimmed = cdpUrl.trim();
if (!trimmed) {
return trimmed;
}
try {
const parsed = new URL(trimmed);
parsed.username = "";
parsed.password = "";
return redactSensitiveText(parsed.toString().replace(/\/$/, ""));
} catch {
return redactSensitiveText(trimmed);
}
}
export { parseBrowserHttpUrl, redactCdpUrl } from "../../extensions/browser/browser-cdp.js";

View File

@ -1,41 +1,5 @@
import { resolveGatewayAuth } from "../gateway/auth.js";
import type { OpenClawConfig } from "./browser-support.js";
export type BrowserControlAuth = {
token?: string;
password?: string;
};
export function resolveBrowserControlAuth(
cfg: OpenClawConfig | undefined,
env: NodeJS.ProcessEnv = process.env,
): BrowserControlAuth {
const auth = resolveGatewayAuth({
authConfig: cfg?.gateway?.auth,
env,
tailscaleMode: cfg?.gateway?.tailscale?.mode,
});
const token = typeof auth.token === "string" ? auth.token.trim() : "";
const password = typeof auth.password === "string" ? auth.password.trim() : "";
return {
token: token || undefined,
password: password || undefined,
};
}
type BrowserControlAuthModule = typeof import("@openclaw/browser/browser-control-auth.js");
import { loadBundledPluginPublicSurfaceModuleSync } from "./facade-runtime.js";
function loadBrowserControlAuthModule(): BrowserControlAuthModule {
return loadBundledPluginPublicSurfaceModuleSync<BrowserControlAuthModule>({
dirName: "browser",
artifactBasename: "browser-control-auth.js",
});
}
export const ensureBrowserControlAuth: BrowserControlAuthModule["ensureBrowserControlAuth"] = ((
...args
) =>
loadBrowserControlAuthModule().ensureBrowserControlAuth(
...args,
)) as BrowserControlAuthModule["ensureBrowserControlAuth"];
export type { BrowserControlAuth } from "../../extensions/browser/browser-control-auth.js";
export {
ensureBrowserControlAuth,
resolveBrowserControlAuth,
} from "../../extensions/browser/browser-control-auth.js";

View File

@ -1,129 +1,6 @@
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
export type BrowserExecutable = {
kind: "chrome" | "chromium" | "edge" | "canary";
path: string;
};
const CHROME_VERSION_RE = /\b(\d+)(?:\.\d+){1,3}\b/g;
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 String(output ?? "").trim() || null;
} catch {
return null;
}
}
function findFirstChromeExecutable(candidates: string[]): BrowserExecutable | null {
for (const candidate of candidates) {
if (exists(candidate)) {
const normalizedPath = candidate.toLowerCase();
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);
}
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;
}
export function readBrowserVersion(executablePath: string): string | null {
const output = execText(executablePath, ["--version"], 2000);
if (!output) {
return null;
}
return output.replace(/\s+/g, " ").trim();
}
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;
}
export type { BrowserExecutable } from "../../extensions/browser/browser-host-inspection.js";
export {
parseBrowserMajorVersion,
readBrowserVersion,
resolveGoogleChromeExecutableForPlatform,
} from "../../extensions/browser/browser-host-inspection.js";

View File

@ -1,85 +1,48 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const runCommandWithTimeout = vi.hoisted(() => vi.fn());
const mkdir = vi.hoisted(() => vi.fn());
const access = vi.hoisted(() => vi.fn());
const rename = vi.hoisted(() => vi.fn());
const tryLoadActivatedBundledPluginPublicSurfaceModuleSync = vi.hoisted(() => vi.fn());
const closeTrackedBrowserTabsForSessionsImpl = vi.hoisted(() => vi.fn());
const movePathToTrashImpl = vi.hoisted(() => vi.fn());
vi.mock("../process/exec.js", () => ({
runCommandWithTimeout,
vi.mock("../../extensions/browser/browser-maintenance.js", () => ({
closeTrackedBrowserTabsForSessions: closeTrackedBrowserTabsForSessionsImpl,
movePathToTrash: movePathToTrashImpl,
}));
vi.mock("./facade-runtime.js", () => ({
tryLoadActivatedBundledPluginPublicSurfaceModuleSync,
}));
vi.mock("node:fs/promises", async () => {
const { mockNodeBuiltinModule } = await import("../../test/helpers/node-builtin-mocks.js");
return mockNodeBuiltinModule(
() => vi.importActual<typeof import("node:fs/promises")>("node:fs/promises"),
{ mkdir, access, rename },
{ mirrorToDefault: true },
);
});
vi.mock("node:os", async () => {
const { mockNodeBuiltinModule } = await import("../../test/helpers/node-builtin-mocks.js");
return mockNodeBuiltinModule(
() => vi.importActual<typeof import("node:os")>("node:os"),
{ homedir: () => "/home/test" },
{ mirrorToDefault: true },
);
});
describe("browser maintenance", () => {
beforeEach(() => {
vi.restoreAllMocks();
runCommandWithTimeout.mockReset();
mkdir.mockReset();
access.mockReset();
rename.mockReset();
tryLoadActivatedBundledPluginPublicSurfaceModuleSync.mockReset();
vi.spyOn(Date, "now").mockReturnValue(123);
closeTrackedBrowserTabsForSessionsImpl.mockReset();
movePathToTrashImpl.mockReset();
});
it("skips browser runtime lookup when no session keys are provided", async () => {
it("skips browser cleanup when no session keys are provided", async () => {
closeTrackedBrowserTabsForSessionsImpl.mockResolvedValue(0);
const { closeTrackedBrowserTabsForSessions } = await import("./browser-maintenance.js");
await expect(closeTrackedBrowserTabsForSessions({ sessionKeys: [] })).resolves.toBe(0);
expect(tryLoadActivatedBundledPluginPublicSurfaceModuleSync).not.toHaveBeenCalled();
expect(closeTrackedBrowserTabsForSessionsImpl).toHaveBeenCalledWith({ sessionKeys: [] });
expect(movePathToTrashImpl).not.toHaveBeenCalled();
});
it("returns the target path when trash exits successfully", async () => {
const { movePathToTrash } = await import("./browser-maintenance.js");
runCommandWithTimeout.mockResolvedValue({
stdout: "",
stderr: "",
code: 0,
signal: null,
killed: false,
termination: "exit",
});
it("delegates cleanup through the browser maintenance surface", async () => {
closeTrackedBrowserTabsForSessionsImpl.mockResolvedValue(2);
await expect(movePathToTrash("/tmp/demo")).resolves.toBe("/tmp/demo");
expect(mkdir).not.toHaveBeenCalled();
expect(rename).not.toHaveBeenCalled();
const { closeTrackedBrowserTabsForSessions } = await import("./browser-maintenance.js");
await expect(
closeTrackedBrowserTabsForSessions({ sessionKeys: ["agent:main:test"] }),
).resolves.toBe(2);
expect(closeTrackedBrowserTabsForSessionsImpl).toHaveBeenCalledWith({
sessionKeys: ["agent:main:test"],
});
});
it("falls back to rename when trash exits non-zero", async () => {
const { movePathToTrash } = await import("./browser-maintenance.js");
runCommandWithTimeout.mockResolvedValue({
stdout: "",
stderr: "permission denied",
code: 1,
signal: null,
killed: false,
termination: "exit",
});
access.mockRejectedValue(new Error("missing"));
it("delegates move-to-trash through the browser maintenance surface", async () => {
movePathToTrashImpl.mockImplementation(async (targetPath: string) => `${targetPath}.trashed`);
await expect(movePathToTrash("/tmp/demo")).resolves.toBe("/home/test/.Trash/demo-123");
expect(mkdir).toHaveBeenCalledWith("/home/test/.Trash", { recursive: true });
expect(rename).toHaveBeenCalledWith("/tmp/demo", "/home/test/.Trash/demo-123");
const { movePathToTrash } = await import("./browser-maintenance.js");
await expect(movePathToTrash("/tmp/demo")).resolves.toBe("/tmp/demo.trashed");
expect(movePathToTrashImpl).toHaveBeenCalledWith("/tmp/demo");
});
});

View File

@ -1,67 +1,4 @@
import { randomBytes } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { runCommandWithTimeout } from "../process/exec.js";
import { tryLoadActivatedBundledPluginPublicSurfaceModuleSync } from "./facade-runtime.js";
type CloseTrackedBrowserTabsForSessions = (params: {
sessionKeys: Array<string | undefined>;
closeTab?: (tab: { targetId: string; baseUrl?: string; profile?: string }) => Promise<void>;
onWarn?: (message: string) => void;
}) => Promise<number>;
type MovePathToTrash = (targetPath: string) => Promise<string>;
function createTrashCollisionSuffix(): string {
return randomBytes(6).toString("hex");
}
export const closeTrackedBrowserTabsForSessions: CloseTrackedBrowserTabsForSessions = async (
params,
) => {
if (!Array.isArray(params?.sessionKeys) || params.sessionKeys.length === 0) {
return 0;
}
// Session reset always attempts browser cleanup, even when browser is disabled.
// Keep that path a no-op unless the browser runtime is actually active.
const closeTrackedTabs = tryLoadActivatedBundledPluginPublicSurfaceModuleSync<{
closeTrackedBrowserTabsForSessions: CloseTrackedBrowserTabsForSessions;
}>({
dirName: "browser",
artifactBasename: "runtime-api.js",
})?.closeTrackedBrowserTabsForSessions;
if (typeof closeTrackedTabs !== "function") {
return 0;
}
return await closeTrackedTabs(params);
};
export const movePathToTrash: MovePathToTrash = async (targetPath) => {
try {
const result = await runCommandWithTimeout(["trash", targetPath], { timeoutMs: 10_000 });
if (result.code !== 0) {
throw new Error(`trash exited with code ${result.code ?? "unknown"}`);
}
return targetPath;
} catch {
const homeDir = os.homedir();
const pathRuntime = homeDir.startsWith("/") ? path.posix : path;
const trashDir = pathRuntime.join(homeDir, ".Trash");
await fs.mkdir(trashDir, { recursive: true });
const base = pathRuntime.basename(targetPath);
const timestamp = Date.now();
let destination = pathRuntime.join(trashDir, `${base}-${timestamp}`);
try {
await fs.access(destination);
destination = pathRuntime.join(
trashDir,
`${base}-${timestamp}-${createTrashCollisionSuffix()}`,
);
} catch {
// The initial destination is free to use.
}
await fs.rename(targetPath, destination);
return destination;
}
};
export {
closeTrackedBrowserTabsForSessions,
movePathToTrash,
} from "../../extensions/browser/browser-maintenance.js";

View File

@ -1,364 +1,15 @@
import path from "node:path";
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
import { parseBrowserHttpUrl } from "./browser-cdp.js";
import {
DEFAULT_BROWSER_CONTROL_PORT,
deriveDefaultBrowserCdpPortRange,
deriveDefaultBrowserControlPort,
isLoopbackHost,
resolveGatewayPort,
resolveUserPath,
} from "./browser-config-support.js";
import type {
BrowserConfig,
BrowserProfileConfig,
OpenClawConfig,
SsrFPolicy,
} from "./browser-support.js";
export const DEFAULT_OPENCLAW_BROWSER_ENABLED = true;
export const DEFAULT_BROWSER_EVALUATE_ENABLED = true;
export const DEFAULT_OPENCLAW_BROWSER_COLOR = "#FF4500";
export const DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME = "openclaw";
export const DEFAULT_BROWSER_DEFAULT_PROFILE_NAME = "openclaw";
export const DEFAULT_AI_SNAPSHOT_MAX_CHARS = 80_000;
const DEFAULT_BROWSER_CDP_PORT_RANGE_START = 18800;
const DEFAULT_FALLBACK_BROWSER_TMP_DIR = "/tmp/openclaw";
const DEFAULT_UPLOADS_DIR_NAME = "uploads";
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;
userDataDir?: string;
color: string;
driver: "openclaw" | "existing-session";
attachOnly: boolean;
};
function canUseNodeFs(): boolean {
const getBuiltinModule = (
process as NodeJS.Process & {
getBuiltinModule?: (id: string) => unknown;
}
).getBuiltinModule;
if (typeof getBuiltinModule !== "function") {
return false;
}
try {
return getBuiltinModule("fs") !== undefined;
} catch {
return false;
}
}
const DEFAULT_BROWSER_TMP_DIR = canUseNodeFs()
? resolvePreferredOpenClawTmpDir()
: DEFAULT_FALLBACK_BROWSER_TMP_DIR;
export const DEFAULT_UPLOAD_DIR = path.join(DEFAULT_BROWSER_TMP_DIR, DEFAULT_UPLOADS_DIR_NAME);
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();
}
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 > 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 rawPolicy = cfg?.ssrfPolicy as
| (BrowserConfig["ssrfPolicy"] & { allowPrivateNetwork?: boolean })
| undefined;
const allowPrivateNetwork = rawPolicy?.allowPrivateNetwork;
const dangerouslyAllowPrivateNetwork = rawPolicy?.dangerouslyAllowPrivateNetwork;
const allowedHostnames = normalizeStringList(rawPolicy?.allowedHostnames);
const hostnameAllowlist = normalizeStringList(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;
}
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 > 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;
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,
};
}
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,
};
}
export {
DEFAULT_AI_SNAPSHOT_MAX_CHARS,
DEFAULT_BROWSER_DEFAULT_PROFILE_NAME,
DEFAULT_BROWSER_EVALUATE_ENABLED,
DEFAULT_OPENCLAW_BROWSER_COLOR,
DEFAULT_OPENCLAW_BROWSER_ENABLED,
DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME,
DEFAULT_UPLOAD_DIR,
resolveBrowserConfig,
resolveProfile,
} from "../../extensions/browser/browser-profiles.js";
export type {
ResolvedBrowserConfig,
ResolvedBrowserProfile,
} from "../../extensions/browser/browser-profiles.js";

View File

@ -6,7 +6,8 @@ export {
type BrowserConfig,
type BrowserProfileConfig,
} from "../config/config.js";
export { resolveGatewayPort } from "../config/paths.js";
export { resolveConfigPath, resolveGatewayPort } from "../config/paths.js";
export { hasConfiguredSecretInput } from "../config/types.secrets.js";
export {
DEFAULT_BROWSER_CONTROL_PORT,
deriveDefaultBrowserCdpPortRange,
@ -46,6 +47,7 @@ export { wrapExternalContent } from "../security/external-content.js";
export { safeEqualSecret } from "../security/secret-equal.js";
export { optionalStringEnum, stringEnum } from "../agents/schema/typebox.js";
export { formatDocsLink } from "../terminal/links.js";
export { note } from "../terminal/note.js";
export { theme } from "../terminal/theme.js";
export { CONFIG_DIR, escapeRegExp, resolveUserPath, shortenHomePath } from "../utils.js";
export { parseBooleanValue } from "../utils/boolean.js";
@ -77,6 +79,7 @@ export {
export { hasProxyEnvConfigured } from "../infra/net/proxy-env.js";
export {
SsrFBlockedError,
isBlockedHostnameOrIp,
isPrivateNetworkAllowedByPolicy,
resolvePinnedHostnameWithPolicy,
type LookupFn,

View File

@ -7,6 +7,10 @@ import type {
OpenClawPluginCommandDefinition,
OpenClawPluginConfigSchema,
OpenClawPluginDefinition,
OpenClawPluginNodeHostCommand,
OpenClawPluginReloadRegistration,
OpenClawPluginSecurityAuditCollector,
OpenClawPluginSecurityAuditContext,
OpenClawPluginService,
OpenClawPluginServiceContext,
OpenClawPluginToolContext,
@ -71,6 +75,10 @@ export type {
AnyAgentTool,
MediaUnderstandingProviderPlugin,
OpenClawPluginApi,
OpenClawPluginNodeHostCommand,
OpenClawPluginReloadRegistration,
OpenClawPluginSecurityAuditCollector,
OpenClawPluginSecurityAuditContext,
OpenClawPluginToolContext,
OpenClawPluginToolFactory,
PluginCommandContext,
@ -143,6 +151,9 @@ type DefinePluginEntryOptions = {
description: string;
kind?: OpenClawPluginDefinition["kind"];
configSchema?: OpenClawPluginConfigSchema | (() => OpenClawPluginConfigSchema);
reload?: OpenClawPluginDefinition["reload"];
nodeHostCommands?: OpenClawPluginDefinition["nodeHostCommands"];
securityAuditCollectors?: OpenClawPluginDefinition["securityAuditCollectors"];
register: (api: OpenClawPluginApi) => void;
};
@ -153,7 +164,10 @@ type DefinedPluginEntry = {
description: string;
configSchema: OpenClawPluginConfigSchema;
register: NonNullable<OpenClawPluginDefinition["register"]>;
} & Pick<OpenClawPluginDefinition, "kind">;
} & Pick<
OpenClawPluginDefinition,
"kind" | "reload" | "nodeHostCommands" | "securityAuditCollectors"
>;
/**
* Canonical entry helper for non-channel plugins.
@ -168,6 +182,9 @@ export function definePluginEntry({
description,
kind,
configSchema = emptyPluginConfigSchema,
reload,
nodeHostCommands,
securityAuditCollectors,
register,
}: DefinePluginEntryOptions): DefinedPluginEntry {
const getConfigSchema = createCachedLazyValueGetter(configSchema);
@ -176,6 +193,9 @@ export function definePluginEntry({
name,
description,
...(kind ? { kind } : {}),
...(reload ? { reload } : {}),
...(nodeHostCommands ? { nodeHostCommands } : {}),
...(securityAuditCollectors ? { securityAuditCollectors } : {}),
get configSchema() {
return getConfigSchema();
},

View File

@ -24,6 +24,9 @@ export type BuildPluginApiParams = {
| "registerChannel"
| "registerGatewayMethod"
| "registerCli"
| "registerReload"
| "registerNodeHostCommand"
| "registerSecurityAuditCollector"
| "registerService"
| "registerConfigMigration"
| "registerAutoEnableProbe"
@ -55,6 +58,10 @@ const noopRegisterHttpRoute: OpenClawPluginApi["registerHttpRoute"] = () => {};
const noopRegisterChannel: OpenClawPluginApi["registerChannel"] = () => {};
const noopRegisterGatewayMethod: OpenClawPluginApi["registerGatewayMethod"] = () => {};
const noopRegisterCli: OpenClawPluginApi["registerCli"] = () => {};
const noopRegisterReload: OpenClawPluginApi["registerReload"] = () => {};
const noopRegisterNodeHostCommand: OpenClawPluginApi["registerNodeHostCommand"] = () => {};
const noopRegisterSecurityAuditCollector: OpenClawPluginApi["registerSecurityAuditCollector"] =
() => {};
const noopRegisterService: OpenClawPluginApi["registerService"] = () => {};
const noopRegisterConfigMigration: OpenClawPluginApi["registerConfigMigration"] = () => {};
const noopRegisterAutoEnableProbe: OpenClawPluginApi["registerAutoEnableProbe"] = () => {};
@ -104,6 +111,10 @@ export function buildPluginApi(params: BuildPluginApiParams): OpenClawPluginApi
registerChannel: handlers.registerChannel ?? noopRegisterChannel,
registerGatewayMethod: handlers.registerGatewayMethod ?? noopRegisterGatewayMethod,
registerCli: handlers.registerCli ?? noopRegisterCli,
registerReload: handlers.registerReload ?? noopRegisterReload,
registerNodeHostCommand: handlers.registerNodeHostCommand ?? noopRegisterNodeHostCommand,
registerSecurityAuditCollector:
handlers.registerSecurityAuditCollector ?? noopRegisterSecurityAuditCollector,
registerService: handlers.registerService ?? noopRegisterService,
registerConfigMigration: handlers.registerConfigMigration ?? noopRegisterConfigMigration,
registerAutoEnableProbe: handlers.registerAutoEnableProbe ?? noopRegisterAutoEnableProbe,

View File

@ -1093,7 +1093,13 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
},
});
const { registry, createApi } = createPluginRegistry({
const {
registry,
createApi,
registerReload,
registerNodeHostCommand,
registerSecurityAuditCollector,
} = createPluginRegistry({
logger,
runtime,
coreGatewayHandlers: options.coreGatewayHandlers as Record<string, GatewayRequestHandler>,
@ -1536,6 +1542,18 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
}
}
if (registrationMode === "full") {
if (definition?.reload) {
registerReload(record, definition.reload);
}
for (const nodeHostCommand of definition?.nodeHostCommands ?? []) {
registerNodeHostCommand(record, nodeHostCommand);
}
for (const collector of definition?.securityAuditCollectors ?? []) {
registerSecurityAuditCollector(record, collector);
}
}
if (validateOnly) {
registry.plugins.push(record);
seenIds.set(pluginId, candidate.origin);

View File

@ -22,6 +22,9 @@ export function createEmptyPluginRegistry(): PluginRegistry {
gatewayMethodScopes: {},
httpRoutes: [],
cliRegistrars: [],
reloads: [],
nodeHostCommands: [],
securityAuditCollectors: [],
services: [],
commands: [],
conversationBindingResolvedHandlers: [],

View File

@ -9,6 +9,11 @@ import type {
} from "../gateway/server-methods/types.js";
import { registerInternalHook } from "../hooks/internal-hooks.js";
import type { HookEntry } from "../hooks/types.js";
import {
NODE_EXEC_APPROVALS_COMMANDS,
NODE_SYSTEM_NOTIFY_COMMAND,
NODE_SYSTEM_RUN_COMMANDS,
} from "../infra/node-commands.js";
import { normalizePluginGatewayMethodScope } from "../shared/gateway-method-policy.js";
import { resolveUserPath } from "../utils.js";
import { buildPluginApi } from "./api-builder.js";
@ -52,6 +57,9 @@ import type {
OpenClawPluginHttpRouteHandler,
OpenClawPluginHttpRouteParams,
OpenClawPluginHookOptions,
OpenClawPluginNodeHostCommand,
OpenClawPluginReloadRegistration,
OpenClawPluginSecurityAuditCollector,
MediaUnderstandingProviderPlugin,
ProviderPlugin,
RealtimeVoiceProviderPlugin,
@ -172,6 +180,30 @@ export type PluginServiceRegistration = {
rootDir?: string;
};
export type PluginReloadRegistration = {
pluginId: string;
pluginName?: string;
registration: OpenClawPluginReloadRegistration;
source: string;
rootDir?: string;
};
export type PluginNodeHostCommandRegistration = {
pluginId: string;
pluginName?: string;
command: OpenClawPluginNodeHostCommand;
source: string;
rootDir?: string;
};
export type PluginSecurityAuditCollectorRegistration = {
pluginId: string;
pluginName?: string;
collector: OpenClawPluginSecurityAuditCollector;
source: string;
rootDir?: string;
};
export type PluginCommandRegistration = {
pluginId: string;
pluginName?: string;
@ -259,6 +291,9 @@ export type PluginRegistry = {
gatewayMethodScopes?: Partial<Record<string, OperatorScope>>;
httpRoutes: PluginHttpRouteRegistration[];
cliRegistrars: PluginCliRegistration[];
reloads?: PluginReloadRegistration[];
nodeHostCommands?: PluginNodeHostCommandRegistration[];
securityAuditCollectors?: PluginSecurityAuditCollectorRegistration[];
services: PluginServiceRegistration[];
commands: PluginCommandRegistration[];
conversationBindingResolvedHandlers: PluginConversationBindingResolvedHandlerRegistration[];
@ -824,6 +859,104 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
});
};
const reservedNodeHostCommands = new Set<string>([
...NODE_SYSTEM_RUN_COMMANDS,
...NODE_EXEC_APPROVALS_COMMANDS,
NODE_SYSTEM_NOTIFY_COMMAND,
]);
const registerReload = (record: PluginRecord, registration: OpenClawPluginReloadRegistration) => {
const normalize = (values?: string[]) =>
(values ?? []).map((value) => value.trim()).filter(Boolean);
const normalized: OpenClawPluginReloadRegistration = {
restartPrefixes: normalize(registration.restartPrefixes),
hotPrefixes: normalize(registration.hotPrefixes),
noopPrefixes: normalize(registration.noopPrefixes),
};
if (
(normalized.restartPrefixes?.length ?? 0) === 0 &&
(normalized.hotPrefixes?.length ?? 0) === 0 &&
(normalized.noopPrefixes?.length ?? 0) === 0
) {
pushDiagnostic({
level: "warn",
pluginId: record.id,
source: record.source,
message: "reload registration missing prefixes",
});
return;
}
registry.reloads ??= [];
registry.reloads.push({
pluginId: record.id,
pluginName: record.name,
registration: normalized,
source: record.source,
rootDir: record.rootDir,
});
};
const registerNodeHostCommand = (
record: PluginRecord,
nodeCommand: OpenClawPluginNodeHostCommand,
) => {
const command = nodeCommand.command.trim();
if (!command) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: "node host command registration missing command",
});
return;
}
if (reservedNodeHostCommands.has(command)) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `node host command reserved by core: ${command}`,
});
return;
}
registry.nodeHostCommands ??= [];
const existing = registry.nodeHostCommands.find((entry) => entry.command.command === command);
if (existing) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `node host command already registered: ${command} (${existing.pluginId})`,
});
return;
}
registry.nodeHostCommands.push({
pluginId: record.id,
pluginName: record.name,
command: {
...nodeCommand,
command,
cap: nodeCommand.cap?.trim() || undefined,
},
source: record.source,
rootDir: record.rootDir,
});
};
const registerSecurityAuditCollector = (
record: PluginRecord,
collector: OpenClawPluginSecurityAuditCollector,
) => {
registry.securityAuditCollectors ??= [];
registry.securityAuditCollectors.push({
pluginId: record.id,
pluginName: record.name,
collector,
source: record.source,
rootDir: record.rootDir,
});
};
const registerService = (record: PluginRecord, service: OpenClawPluginService) => {
const id = service.id.trim();
if (!id) {
@ -1051,6 +1184,10 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
registerGatewayMethod: (method, handler, opts) =>
registerGatewayMethod(record, method, handler, opts),
registerService: (service) => registerService(record, service),
registerReload: (registration) => registerReload(record, registration),
registerNodeHostCommand: (command) => registerNodeHostCommand(record, command),
registerSecurityAuditCollector: (collector) =>
registerSecurityAuditCollector(record, collector),
registerInteractiveHandler: (registration) => {
const result = registerPluginInteractiveHandler(record.id, registration, {
pluginName: record.name,
@ -1247,6 +1384,9 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
registerWebSearchProvider,
registerGatewayMethod,
registerCli,
registerReload,
registerNodeHostCommand,
registerSecurityAuditCollector,
registerService,
registerCommand,
registerHook,

View File

@ -33,10 +33,17 @@ function activeRegistrySatisfiesScope(
scope: PluginRegistryScope,
active: ReturnType<typeof getActivePluginRegistry>,
expectedChannelPluginIds: readonly string[],
requestedPluginIds: readonly string[],
): boolean {
if (!active) {
return false;
}
if (requestedPluginIds.length > 0) {
const activePluginIds = new Set(
active.plugins.filter((plugin) => plugin.status === "loaded").map((plugin) => plugin.id),
);
return requestedPluginIds.every((pluginId) => activePluginIds.has(pluginId));
}
const activeChannelPluginIds = new Set(active.channels.map((entry) => entry.plugin.id));
switch (scope) {
case "configured-channels":
@ -55,9 +62,13 @@ export function ensurePluginRegistryLoaded(options?: {
config?: OpenClawConfig;
activationSourceConfig?: OpenClawConfig;
env?: NodeJS.ProcessEnv;
onlyPluginIds?: string[];
}): void {
const scope = options?.scope ?? "all";
if (scopeRank(pluginRegistryLoaded) >= scopeRank(scope)) {
const requestedPluginIds =
options?.onlyPluginIds?.map((pluginId) => pluginId.trim()).filter(Boolean) ?? [];
const scopedLoad = requestedPluginIds.length > 0;
if (!scopedLoad && scopeRank(pluginRegistryLoaded) >= scopeRank(scope)) {
return;
}
const env = options?.env ?? process.env;
@ -68,8 +79,9 @@ export function ensurePluginRegistryLoaded(options?: {
resolvedConfig,
resolveDefaultAgentId(resolvedConfig),
);
const expectedChannelPluginIds =
scope === "configured-channels"
const expectedChannelPluginIds = scopedLoad
? requestedPluginIds
: scope === "configured-channels"
? resolveConfiguredChannelPluginIds({
config: resolvedConfig,
workspaceDir,
@ -84,10 +96,12 @@ export function ensurePluginRegistryLoaded(options?: {
: [];
const active = getActivePluginRegistry();
if (
pluginRegistryLoaded === "none" &&
activeRegistrySatisfiesScope(scope, active, expectedChannelPluginIds)
(pluginRegistryLoaded === "none" || scopedLoad) &&
activeRegistrySatisfiesScope(scope, active, expectedChannelPluginIds, expectedChannelPluginIds)
) {
pluginRegistryLoaded = scope;
if (!scopedLoad) {
pluginRegistryLoaded = scope;
}
return;
}
const logger: PluginLogger = {
@ -103,11 +117,11 @@ export function ensurePluginRegistryLoaded(options?: {
workspaceDir,
logger,
throwOnLoadError: true,
...(scope === "configured-channels" || scope === "channels"
? { onlyPluginIds: expectedChannelPluginIds }
: {}),
...(expectedChannelPluginIds.length > 0 ? { onlyPluginIds: expectedChannelPluginIds } : {}),
});
pluginRegistryLoaded = scope;
if (!scopedLoad) {
pluginRegistryLoaded = scope;
}
}
export const __testing = {

View File

@ -53,6 +53,7 @@ import type {
RuntimeWebFetchMetadata,
RuntimeWebSearchMetadata,
} from "../secrets/runtime-web-tools.types.js";
import type { SecurityAuditFinding } from "../security/audit.js";
import type {
SpeechDirectiveTokenParseContext,
SpeechDirectiveTokenParseResult,
@ -1928,6 +1929,30 @@ export type OpenClawPluginCliCommandDescriptor = {
hasSubcommands: boolean;
};
export type OpenClawPluginReloadRegistration = {
restartPrefixes?: string[];
hotPrefixes?: string[];
noopPrefixes?: string[];
};
export type OpenClawPluginNodeHostCommand = {
command: string;
cap?: string;
handle: (paramsJSON?: string | null) => Promise<string>;
};
export type OpenClawPluginSecurityAuditContext = {
config: OpenClawConfig;
sourceConfig: OpenClawConfig;
env: NodeJS.ProcessEnv;
stateDir: string;
configPath: string;
};
export type OpenClawPluginSecurityAuditCollector = (
ctx: OpenClawPluginSecurityAuditContext,
) => SecurityAuditFinding[] | Promise<SecurityAuditFinding[]>;
/** Context passed to long-lived plugin services. */
export type OpenClawPluginServiceContext = {
config: OpenClawConfig;
@ -1955,6 +1980,9 @@ export type OpenClawPluginDefinition = {
version?: string;
kind?: PluginKind | PluginKind[];
configSchema?: OpenClawPluginConfigSchema;
reload?: OpenClawPluginReloadRegistration;
nodeHostCommands?: OpenClawPluginNodeHostCommand[];
securityAuditCollectors?: OpenClawPluginSecurityAuditCollector[];
register?: (api: OpenClawPluginApi) => void | Promise<void>;
activate?: (api: OpenClawPluginApi) => void | Promise<void>;
};
@ -2040,6 +2068,9 @@ export type OpenClawPluginApi = {
descriptors?: OpenClawPluginCliCommandDescriptor[];
},
) => void;
registerReload: (registration: OpenClawPluginReloadRegistration) => void;
registerNodeHostCommand: (command: OpenClawPluginNodeHostCommand) => void;
registerSecurityAuditCollector: (collector: OpenClawPluginSecurityAuditCollector) => void;
registerService: (service: OpenClawPluginService) => void;
/** Register a lightweight config migration that can run before plugin runtime loads. */
registerConfigMigration: (migrate: PluginConfigMigration) => void;

View File

@ -6,6 +6,7 @@ import type { listChannelPlugins } from "../channels/plugins/index.js";
import { formatCliCommand } from "../cli/command-format.js";
import type { ConfigFileSnapshot, OpenClawConfig } from "../config/config.js";
import { resolveConfigPath, resolveStateDir } from "../config/paths.js";
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
import { hasConfiguredSecretInput } from "../config/types.secrets.js";
import { resolveGatewayAuth } from "../gateway/auth.js";
import { type ExecApprovalsFile, loadExecApprovals } from "../infra/exec-approvals.js";
@ -16,10 +17,7 @@ import {
} from "../infra/exec-safe-bin-runtime-policy.js";
import { listRiskyConfiguredSafeBins } from "../infra/exec-safe-bin-semantics.js";
import { normalizeTrustedSafeBinDirs } from "../infra/exec-safe-bin-trust.js";
import { isBlockedHostnameOrIp, isPrivateNetworkAllowedByPolicy } from "../infra/net/ssrf.js";
import { redactCdpUrl } from "../plugin-sdk/browser-cdp.js";
import { resolveBrowserControlAuth } from "../plugin-sdk/browser-control-auth.js";
import { resolveBrowserConfig, resolveProfile } from "../plugin-sdk/browser-profiles.js";
import { getActivePluginRegistry } from "../plugins/runtime.js";
import { DEFAULT_AGENT_ID } from "../routing/session-key.js";
import {
formatPermissionDetail,
@ -123,6 +121,7 @@ let auditChannelModulePromise:
let pluginRegistryLoaderModulePromise:
| Promise<typeof import("../plugins/runtime/runtime-registry-loader.js")>
| undefined;
let pluginLoaderModulePromise: Promise<typeof import("../plugins/loader.js")> | undefined;
let gatewayProbeDepsPromise:
| Promise<{
buildGatewayConnectionDetails: typeof import("../gateway/call.js").buildGatewayConnectionDetails;
@ -156,6 +155,11 @@ async function loadPluginRegistryLoaderModule() {
return await pluginRegistryLoaderModulePromise;
}
async function loadPluginLoaderModule() {
pluginLoaderModulePromise ??= import("../plugins/loader.js");
return await pluginLoaderModulePromise;
}
async function loadGatewayProbeDeps() {
gatewayProbeDepsPromise ??= Promise.all([
import("../gateway/call.js"),
@ -699,99 +703,78 @@ function isStrictLoopbackTrustedProxyEntry(entry: string): boolean {
return false;
}
function collectBrowserControlFindings(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv,
): SecurityAuditFinding[] {
const findings: SecurityAuditFinding[] = [];
let resolved: ReturnType<typeof resolveBrowserConfig>;
try {
resolved = resolveBrowserConfig(cfg.browser, cfg);
} catch (err) {
findings.push({
checkId: "browser.control_invalid_config",
severity: "warn",
title: "Browser control config looks invalid",
detail: String(err),
remediation: `Fix browser.cdpUrl in ${resolveConfigPath()} and re-run "${formatCliCommand("openclaw security audit --deep")}".`,
async function collectPluginSecurityAuditFindings(
context: AuditExecutionContext,
): Promise<SecurityAuditFinding[]> {
let collectors = getActivePluginRegistry()?.securityAuditCollectors ?? [];
if (collectors.length === 0) {
const autoEnabled = applyPluginAutoEnable({
config: context.sourceConfig,
env: context.env,
});
return findings;
}
if (!resolved.enabled) {
return findings;
}
const browserAuth = resolveBrowserControlAuth(cfg, env);
const explicitAuthMode = cfg.gateway?.auth?.mode;
const tokenConfigured =
Boolean(browserAuth.token) ||
hasNonEmptyString(env.OPENCLAW_GATEWAY_TOKEN) ||
hasConfiguredSecretInput(cfg.gateway?.auth?.token, cfg.secrets?.defaults);
const passwordCanWin =
explicitAuthMode === "password" ||
(explicitAuthMode !== "token" &&
explicitAuthMode !== "none" &&
explicitAuthMode !== "trusted-proxy" &&
!tokenConfigured);
const passwordConfigured =
Boolean(browserAuth.password) ||
(passwordCanWin &&
(hasNonEmptyString(env.OPENCLAW_GATEWAY_PASSWORD) ||
hasConfiguredSecretInput(cfg.gateway?.auth?.password, cfg.secrets?.defaults)));
if (!tokenConfigured && !passwordConfigured) {
findings.push({
checkId: "browser.control_no_auth",
severity: "critical",
title: "Browser control has no auth",
detail:
"Browser control HTTP routes are enabled but no gateway.auth token/password is configured. " +
"Any local process (or SSRF to loopback) can call browser control endpoints.",
remediation:
"Set gateway.auth.token (recommended) or gateway.auth.password so browser control HTTP routes require authentication. Restarting the gateway will auto-generate gateway.auth.token when browser control is enabled.",
const requestedPluginIds = new Set<string>();
for (const pluginId of Object.keys(autoEnabled.autoEnabledReasons)) {
const normalized = pluginId.trim();
if (normalized) {
requestedPluginIds.add(normalized);
}
}
for (const pluginId of autoEnabled.config.plugins?.allow ?? []) {
if (typeof pluginId !== "string") {
continue;
}
const normalized = pluginId.trim();
if (normalized) {
requestedPluginIds.add(normalized);
}
}
for (const [pluginId, entry] of Object.entries(autoEnabled.config.plugins?.entries ?? {})) {
if (entry?.enabled === false) {
continue;
}
const normalized = pluginId.trim();
if (normalized) {
requestedPluginIds.add(normalized);
}
}
if (requestedPluginIds.size === 0) {
return [];
}
const snapshot = (await loadPluginLoaderModule()).loadOpenClawPlugins({
config: autoEnabled.config,
activationSourceConfig: context.sourceConfig,
autoEnabledReasons: autoEnabled.autoEnabledReasons,
env: context.env,
activate: false,
cache: false,
mode: "validate",
onlyPluginIds: [...requestedPluginIds],
});
collectors = snapshot.securityAuditCollectors ?? [];
}
for (const name of Object.keys(resolved.profiles)) {
const profile = resolveProfile(resolved, name);
if (!profile || profile.cdpIsLoopback) {
continue;
}
let url: URL;
try {
url = new URL(profile.cdpUrl);
} catch {
continue;
}
const redactedCdpUrl = redactCdpUrl(profile.cdpUrl) ?? profile.cdpUrl;
if (url.protocol === "http:") {
findings.push({
checkId: "browser.remote_cdp_http",
severity: "warn",
title: "Remote CDP uses HTTP",
detail: `browser profile "${name}" uses http CDP (${redactedCdpUrl}); this is OK only if it's tailnet-only or behind an encrypted tunnel.`,
remediation: `Prefer HTTPS/TLS or a tailnet-only endpoint for remote CDP.`,
});
}
if (
isPrivateNetworkAllowedByPolicy(resolved.ssrfPolicy) &&
isBlockedHostnameOrIp(url.hostname)
) {
findings.push({
checkId: "browser.remote_cdp_private_host",
severity: "warn",
title: "Remote CDP targets a private/internal host",
detail:
`browser profile "${name}" points at a private/internal CDP host (${redactedCdpUrl}). ` +
"This is expected for LAN/tailnet/WSL-style setups, but treat it as a trusted-network endpoint.",
remediation:
"Prefer a tailnet or tunnel for remote CDP. If you want strict blocking, set browser.ssrfPolicy.dangerouslyAllowPrivateNetwork=false and allow only explicit hosts.",
});
}
}
return findings;
const collectorResults = await Promise.all(
collectors.map(async (entry) => {
try {
return await entry.collector({
config: context.cfg,
sourceConfig: context.sourceConfig,
env: context.env,
stateDir: context.stateDir,
configPath: context.configPath,
});
} catch (err) {
return [
{
checkId: `plugins.${entry.pluginId}.security_audit_failed`,
severity: "warn" as const,
title: "Plugin security audit collector failed",
detail: `${entry.pluginId}: ${String(err)}`,
},
];
}
}),
);
return collectorResults.flat();
}
function collectLoggingFindings(cfg: OpenClawConfig): SecurityAuditFinding[] {
@ -1335,7 +1318,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise<Secu
findings.push(...auditNonDeep.collectSyncedFolderFindings({ stateDir, configPath }));
findings.push(...collectGatewayConfigFindings(cfg, context.sourceConfig, env));
findings.push(...collectBrowserControlFindings(cfg, env));
findings.push(...(await collectPluginSecurityAuditFindings(context)));
findings.push(...collectLoggingFindings(cfg));
findings.push(...collectElevatedFindings(cfg));
findings.push(...collectExecRuntimeFindings(cfg));

View File

@ -36,8 +36,12 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl
webSearchProviders: [],
memoryEmbeddingProviders: [],
gatewayHandlers: {},
gatewayMethodScopes: {},
httpRoutes: [],
cliRegistrars: [],
reloads: [],
nodeHostCommands: [],
securityAuditCollectors: [],
services: [],
commands: [],
conversationBindingResolvedHandlers: [],

View File

@ -17,6 +17,9 @@ export function createTestPluginApi(api: TestPluginApiInput): OpenClawPluginApi
registerGatewayMethod() {},
registerCli() {},
registerService() {},
registerReload() {},
registerNodeHostCommand() {},
registerSecurityAuditCollector() {},
registerConfigMigration() {},
registerAutoEnableProbe() {},
registerProvider() {},

View File

@ -81,9 +81,14 @@ function createTestRegistryForSetup(
videoGenerationProviders: [],
webFetchProviders: [],
webSearchProviders: [],
memoryEmbeddingProviders: [],
gatewayHandlers: {},
gatewayMethodScopes: {},
httpRoutes: [],
cliRegistrars: [],
reloads: [],
nodeHostCommands: [],
securityAuditCollectors: [],
services: [],
commands: [],
conversationBindingResolvedHandlers: [],