From 471d056e2f0dcbf0ca11f0dac1915c1b66e2ed2b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 5 Apr 2026 23:13:03 +0100 Subject: [PATCH] refactor: move browser runtime seams behind plugin metadata --- extensions/browser/browser-cdp.ts | 2 +- extensions/browser/browser-doctor.ts | 1 + extensions/browser/browser-host-inspection.ts | 6 + extensions/browser/browser-maintenance.ts | 2 + extensions/browser/browser-profiles.ts | 1 + extensions/browser/index.test.ts | 11 + extensions/browser/index.ts | 11 + extensions/browser/register.runtime.ts | 2 + extensions/browser/src/browser/cdp.helpers.ts | 27 ++ .../browser/src/browser/chrome.executables.ts | 11 +- extensions/browser/src/browser/config.ts | 357 ++++++++++++++++- extensions/browser/src/browser/trash.test.ts | 43 ++ extensions/browser/src/doctor-browser.ts | 150 +++++++ extensions/browser/src/security-audit.test.ts | 86 ++++ extensions/browser/src/security-audit.ts | 122 ++++++ extensions/lobster/src/lobster-tool.test.ts | 3 + src/cli/run-main.test.ts | 16 +- src/cli/run-main.ts | 34 +- src/commands/doctor-browser.ts | 151 +------ src/gateway/config-reload-plan.ts | 28 +- src/gateway/config-reload.test.ts | 8 + src/node-host/invoke.ts | 15 +- src/node-host/plugin-node-host.test.ts | 79 ++++ src/node-host/plugin-node-host.ts | 56 +++ src/node-host/runner.ts | 20 +- src/plugin-activation-boundary.test.ts | 3 +- src/plugin-sdk/browser-cdp.ts | 47 +-- src/plugin-sdk/browser-control-auth.ts | 46 +-- src/plugin-sdk/browser-host-inspection.ts | 135 +------ src/plugin-sdk/browser-maintenance.test.ts | 93 ++--- src/plugin-sdk/browser-maintenance.ts | 71 +--- src/plugin-sdk/browser-profiles.ts | 379 +----------------- src/plugin-sdk/browser-support.ts | 5 +- src/plugin-sdk/plugin-entry.ts | 22 +- src/plugins/api-builder.ts | 11 + src/plugins/loader.ts | 20 +- src/plugins/registry-empty.ts | 3 + src/plugins/registry.ts | 140 +++++++ .../runtime/runtime-registry-loader.ts | 34 +- src/plugins/types.ts | 31 ++ src/security/audit.ts | 173 ++++---- src/test-utils/channel-plugins.ts | 4 + test/helpers/plugins/plugin-api.ts | 3 + test/setup-openclaw-runtime.ts | 5 + 44 files changed, 1441 insertions(+), 1026 deletions(-) create mode 100644 extensions/browser/browser-doctor.ts create mode 100644 extensions/browser/browser-host-inspection.ts create mode 100644 extensions/browser/browser-maintenance.ts create mode 100644 extensions/browser/src/browser/trash.test.ts create mode 100644 extensions/browser/src/doctor-browser.ts create mode 100644 extensions/browser/src/security-audit.test.ts create mode 100644 extensions/browser/src/security-audit.ts create mode 100644 src/node-host/plugin-node-host.test.ts create mode 100644 src/node-host/plugin-node-host.ts diff --git a/extensions/browser/browser-cdp.ts b/extensions/browser/browser-cdp.ts index 7e390a7bfb2..691c3afaa40 100644 --- a/extensions/browser/browser-cdp.ts +++ b/extensions/browser/browser-cdp.ts @@ -1 +1 @@ -export { redactCdpUrl } from "./src/browser/cdp.helpers.js"; +export { parseBrowserHttpUrl, redactCdpUrl } from "./src/browser/cdp.helpers.js"; diff --git a/extensions/browser/browser-doctor.ts b/extensions/browser/browser-doctor.ts new file mode 100644 index 00000000000..450ff1d17a6 --- /dev/null +++ b/extensions/browser/browser-doctor.ts @@ -0,0 +1 @@ +export { noteChromeMcpBrowserReadiness } from "./src/doctor-browser.js"; diff --git a/extensions/browser/browser-host-inspection.ts b/extensions/browser/browser-host-inspection.ts new file mode 100644 index 00000000000..87a3a9fb812 --- /dev/null +++ b/extensions/browser/browser-host-inspection.ts @@ -0,0 +1,6 @@ +export type { BrowserExecutable } from "./src/browser/chrome.executables.js"; +export { + parseBrowserMajorVersion, + readBrowserVersion, + resolveGoogleChromeExecutableForPlatform, +} from "./src/browser/chrome.executables.js"; diff --git a/extensions/browser/browser-maintenance.ts b/extensions/browser/browser-maintenance.ts new file mode 100644 index 00000000000..3f8c71c2522 --- /dev/null +++ b/extensions/browser/browser-maintenance.ts @@ -0,0 +1,2 @@ +export { closeTrackedBrowserTabsForSessions } from "./src/browser/session-tab-registry.js"; +export { movePathToTrash } from "./src/browser/trash.js"; diff --git a/extensions/browser/browser-profiles.ts b/extensions/browser/browser-profiles.ts index 5cad1b9493e..45a8ec11601 100644 --- a/extensions/browser/browser-profiles.ts +++ b/extensions/browser/browser-profiles.ts @@ -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, diff --git a/extensions/browser/index.test.ts b/extensions/browser/index.test.ts index c24bee66bc6..c9fa9814f3a 100644 --- a/extensions/browser/index.test.ts +++ b/extensions/browser/index.test.ts @@ -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); diff --git a/extensions/browser/index.ts b/extensions/browser/index.ts index a607913e234..69c86bfb6de 100644 --- a/extensions/browser/index.ts +++ b/extensions/browser/index.ts @@ -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({ diff --git a/extensions/browser/register.runtime.ts b/extensions/browser/register.runtime.ts index 8fad4161108..348c301299e 100644 --- a/extensions/browser/register.runtime.ts +++ b/extensions/browser/register.runtime.ts @@ -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"; diff --git a/extensions/browser/src/browser/cdp.helpers.ts b/extensions/browser/src/browser/cdp.helpers.ts index 3bc02362b55..a39ca432da0 100644 --- a/extensions/browser/src/browser/cdp.helpers.ts +++ b/extensions/browser/src/browser/cdp.helpers.ts @@ -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 diff --git a/extensions/browser/src/browser/chrome.executables.ts b/extensions/browser/src/browser/chrome.executables.ts index d5ea5de6120..dfaf2f47d5f 100644 --- a/extensions/browser/src/browser/chrome.executables.ts +++ b/extensions/browser/src/browser/chrome.executables.ts @@ -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): 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; } diff --git a/extensions/browser/src/browser/config.ts b/extensions/browser/src/browser/config.ts index 19d202967aa..f1367bc3b69 100644 --- a/extensions/browser/src/browser/config.ts +++ b/extensions/browser/src/browser/config.ts @@ -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; + 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 | undefined, + defaultColor: string, + legacyCdpPort?: number, + derivedDefaultCdpPort?: number, + legacyCdpUrl?: string, +): Record { + 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, +): Record { + 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; diff --git a/extensions/browser/src/browser/trash.test.ts b/extensions/browser/src/browser/trash.test.ts new file mode 100644 index 00000000000..81532d225d3 --- /dev/null +++ b/extensions/browser/src/browser/trash.test.ts @@ -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"); + }); +}); diff --git a/extensions/browser/src/doctor-browser.ts b/extensions/browser/src/doctor-browser.ts new file mode 100644 index 00000000000..18fb493343e --- /dev/null +++ b/extensions/browser/src/doctor-browser.ts @@ -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 | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +type ExistingSessionProfile = { + name: string; + userDataDir?: string; +}; + +function collectChromeMcpProfiles(cfg: OpenClawConfig): ExistingSessionProfile[] { + const browser = asRecord(cfg.browser); + if (!browser) { + return []; + } + + const profiles = new Map(); + 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..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"); +} diff --git a/extensions/browser/src/security-audit.test.ts b/extensions/browser/src/security-audit.test.ts new file mode 100644 index 00000000000..3dcf1e7e9cc --- /dev/null +++ b/extensions/browser/src/security-audit.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { collectBrowserSecurityAuditFindings } from "./security-audit.js"; + +function collectFindings( + config: Parameters[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"), + }), + ]), + ); + }); +}); diff --git a/extensions/browser/src/security-audit.ts b/extensions/browser/src/security-audit.ts new file mode 100644 index 00000000000..5ee3c5ae614 --- /dev/null +++ b/extensions/browser/src/security-audit.ts @@ -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; + 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; +} diff --git a/extensions/lobster/src/lobster-tool.test.ts b/extensions/lobster/src/lobster-tool.test.ts index 16453c25bcb..df6cce1ded4 100644 --- a/extensions/lobster/src/lobster-tool.test.ts +++ b/extensions/lobster/src/lobster-tool.test.ts @@ -44,6 +44,9 @@ function fakeApi(overrides: Partial = {}): OpenClawPluginApi registerGatewayMethod() {}, registerCli() {}, registerService() {}, + registerReload() {}, + registerNodeHostCommand() {}, + registerSecurityAuditCollector() {}, registerConfigMigration() {}, registerAutoEnableProbe() {}, registerProvider() {}, diff --git a/src/cli/run-main.test.ts b/src/cli/run-main.test.ts index 5ffc6693919..a9e1da193a6 100644 --- a/src/cli/run-main.test.ts +++ b/src/cli/run-main.test.ts @@ -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"], }, diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index 5ca1ea316ef..fa30d732e35 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -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); } } } diff --git a/src/commands/doctor-browser.ts b/src/commands/doctor-browser.ts index c376b834d64..88ffaf4f0cd 100644 --- a/src/commands/doctor-browser.ts +++ b/src/commands/doctor-browser.ts @@ -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 | null { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : null; -} - -type ExistingSessionProfile = { - name: string; - userDataDir?: string; -}; - -function collectChromeMcpProfiles(cfg: OpenClawConfig): ExistingSessionProfile[] { - const browser = asRecord(cfg.browser); - if (!browser) { - return []; - } - - const profiles = new Map(); - 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..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"; diff --git a/src/gateway/config-reload-plan.ts b/src/gateway/config-reload-plan.ts index 0baa596fa38..1ab4dac66a7 100644 --- a/src/gateway/config-reload-plan.ts +++ b/src/gateway/config-reload-plan.ts @@ -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; } diff --git a/src/gateway/config-reload.test.ts b/src/gateway/config-reload.test.ts index 97d0b52d400..8bc63fe782e 100644 --- a/src/gateway/config-reload.test.ts +++ b/src/gateway/config-reload.test.ts @@ -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); diff --git a/src/node-host/invoke.ts b/src/node-host/invoke.ts index 989dda4af2b..a36b08bd808 100644 --- a/src/node-host/invoke.ts +++ b/src/node-host/invoke.ts @@ -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; } diff --git a/src/node-host/plugin-node-host.test.ts b/src/node-host/plugin-node-host.test.ts new file mode 100644 index 00000000000..1f45ecbbd9b --- /dev/null +++ b/src/node-host/plugin-node-host.test.ts @@ -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}'); + }); +}); diff --git a/src/node-host/plugin-node-host.ts b/src/node-host/plugin-node-host.ts new file mode 100644 index 00000000000..365ab74b25b --- /dev/null +++ b/src/node-host/plugin-node-host.ts @@ -0,0 +1,56 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { getActivePluginRegistry } from "../plugins/runtime.js"; + +let pluginRegistryLoaderModulePromise: + | Promise + | 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 { + (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(); + const commands = new Set(); + 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 { + const registry = getActivePluginRegistry(); + const match = (registry?.nodeHostCommands ?? []).find( + (entry) => entry.command.command === command, + ); + if (!match) { + return null; + } + return await match.command.handle(paramsJSON); +} diff --git a/src/node-host/runner.ts b/src/node-host/runner.ts index 290ce617909..d274c6c7cea 100644 --- a/src/node-host/runner.ts +++ b/src/node-host/runner.ts @@ -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 { 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 { 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, diff --git a/src/plugin-activation-boundary.test.ts b/src/plugin-activation-boundary.test.ts index 1cfc101103b..463e49c03c4 100644 --- a/src/plugin-activation-boundary.test.ts +++ b/src/plugin-activation-boundary.test.ts @@ -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, diff --git a/src/plugin-sdk/browser-cdp.ts b/src/plugin-sdk/browser-cdp.ts index 59dd140de50..42432e3335f 100644 --- a/src/plugin-sdk/browser-cdp.ts +++ b/src/plugin-sdk/browser-cdp.ts @@ -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"; diff --git a/src/plugin-sdk/browser-control-auth.ts b/src/plugin-sdk/browser-control-auth.ts index d29ccdbf9ea..66f7b9e2b1c 100644 --- a/src/plugin-sdk/browser-control-auth.ts +++ b/src/plugin-sdk/browser-control-auth.ts @@ -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({ - 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"; diff --git a/src/plugin-sdk/browser-host-inspection.ts b/src/plugin-sdk/browser-host-inspection.ts index 3c8a5dcb48f..ea358d80a62 100644 --- a/src/plugin-sdk/browser-host-inspection.ts +++ b/src/plugin-sdk/browser-host-inspection.ts @@ -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"; diff --git a/src/plugin-sdk/browser-maintenance.test.ts b/src/plugin-sdk/browser-maintenance.test.ts index d538f49a008..131ac2489e2 100644 --- a/src/plugin-sdk/browser-maintenance.test.ts +++ b/src/plugin-sdk/browser-maintenance.test.ts @@ -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("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("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"); }); }); diff --git a/src/plugin-sdk/browser-maintenance.ts b/src/plugin-sdk/browser-maintenance.ts index 688473dca99..4a2d7d339a0 100644 --- a/src/plugin-sdk/browser-maintenance.ts +++ b/src/plugin-sdk/browser-maintenance.ts @@ -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; - closeTab?: (tab: { targetId: string; baseUrl?: string; profile?: string }) => Promise; - onWarn?: (message: string) => void; -}) => Promise; - -type MovePathToTrash = (targetPath: string) => Promise; - -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"; diff --git a/src/plugin-sdk/browser-profiles.ts b/src/plugin-sdk/browser-profiles.ts index 6a50fa9d237..b2d04470262 100644 --- a/src/plugin-sdk/browser-profiles.ts +++ b/src/plugin-sdk/browser-profiles.ts @@ -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; - 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 | undefined, - defaultColor: string, - legacyCdpPort?: number, - derivedDefaultCdpPort?: number, - legacyCdpUrl?: string, -): Record { - 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, -): Record { - 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"; diff --git a/src/plugin-sdk/browser-support.ts b/src/plugin-sdk/browser-support.ts index cfdb5cb25e0..96bec76ca4d 100644 --- a/src/plugin-sdk/browser-support.ts +++ b/src/plugin-sdk/browser-support.ts @@ -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, diff --git a/src/plugin-sdk/plugin-entry.ts b/src/plugin-sdk/plugin-entry.ts index 28d50b523b3..a47592a6f4b 100644 --- a/src/plugin-sdk/plugin-entry.ts +++ b/src/plugin-sdk/plugin-entry.ts @@ -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; -} & Pick; +} & 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(); }, diff --git a/src/plugins/api-builder.ts b/src/plugins/api-builder.ts index d69f47571b1..33519cc8228 100644 --- a/src/plugins/api-builder.ts +++ b/src/plugins/api-builder.ts @@ -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, diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index f74472a7677..e4371251604 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -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, @@ -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); diff --git a/src/plugins/registry-empty.ts b/src/plugins/registry-empty.ts index 2a20eb69548..fb7529864d3 100644 --- a/src/plugins/registry-empty.ts +++ b/src/plugins/registry-empty.ts @@ -22,6 +22,9 @@ export function createEmptyPluginRegistry(): PluginRegistry { gatewayMethodScopes: {}, httpRoutes: [], cliRegistrars: [], + reloads: [], + nodeHostCommands: [], + securityAuditCollectors: [], services: [], commands: [], conversationBindingResolvedHandlers: [], diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index ac988ca3462..3066aac2ea9 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -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>; 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([ + ...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, diff --git a/src/plugins/runtime/runtime-registry-loader.ts b/src/plugins/runtime/runtime-registry-loader.ts index 42060e67e24..091358f4c9d 100644 --- a/src/plugins/runtime/runtime-registry-loader.ts +++ b/src/plugins/runtime/runtime-registry-loader.ts @@ -33,10 +33,17 @@ function activeRegistrySatisfiesScope( scope: PluginRegistryScope, active: ReturnType, 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 = { diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 41ed169833d..50164c7de08 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -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; +}; + +export type OpenClawPluginSecurityAuditContext = { + config: OpenClawConfig; + sourceConfig: OpenClawConfig; + env: NodeJS.ProcessEnv; + stateDir: string; + configPath: string; +}; + +export type OpenClawPluginSecurityAuditCollector = ( + ctx: OpenClawPluginSecurityAuditContext, +) => SecurityAuditFinding[] | Promise; + /** 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; activate?: (api: OpenClawPluginApi) => void | Promise; }; @@ -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; diff --git a/src/security/audit.ts b/src/security/audit.ts index 019f92509e5..78dccbb8145 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -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 | undefined; +let pluginLoaderModulePromise: Promise | 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; - 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 { + 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(); + 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