mirror of https://github.com/openclaw/openclaw.git
refactor: move browser runtime seams behind plugin metadata
This commit is contained in:
parent
1351bacaa4
commit
471d056e2f
|
|
@ -1 +1 @@
|
|||
export { redactCdpUrl } from "./src/browser/cdp.helpers.js";
|
||||
export { parseBrowserHttpUrl, redactCdpUrl } from "./src/browser/cdp.helpers.js";
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
export { noteChromeMcpBrowserReadiness } from "./src/doctor-browser.js";
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export type { BrowserExecutable } from "./src/browser/chrome.executables.js";
|
||||
export {
|
||||
parseBrowserMajorVersion,
|
||||
readBrowserVersion,
|
||||
resolveGoogleChromeExecutableForPlatform,
|
||||
} from "./src/browser/chrome.executables.js";
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { closeTrackedBrowserTabsForSessions } from "./src/browser/session-tab-registry.js";
|
||||
export { movePathToTrash } from "./src/browser/trash.js";
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export type BrowserExecutable = {
|
|||
path: string;
|
||||
};
|
||||
|
||||
const CHROME_VERSION_RE = /(\d+)(?:\.\d+){0,3}/;
|
||||
const CHROME_VERSION_RE = /\b(\d+)(?:\.\d+){1,3}\b/g;
|
||||
|
||||
const CHROMIUM_BUNDLE_IDS = new Set([
|
||||
"com.google.Chrome",
|
||||
|
|
@ -464,9 +464,13 @@ function findFirstExecutable(candidates: Array<BrowserExecutable>): BrowserExecu
|
|||
function findFirstChromeExecutable(candidates: string[]): BrowserExecutable | null {
|
||||
for (const candidate of candidates) {
|
||||
if (exists(candidate)) {
|
||||
const normalizedPath = candidate.toLowerCase();
|
||||
return {
|
||||
kind:
|
||||
candidate.toLowerCase().includes("sxs") || candidate.toLowerCase().includes("canary")
|
||||
normalizedPath.includes("beta") ||
|
||||
normalizedPath.includes("canary") ||
|
||||
normalizedPath.includes("sxs") ||
|
||||
normalizedPath.includes("unstable")
|
||||
? "canary"
|
||||
: "chrome",
|
||||
path: candidate,
|
||||
|
|
@ -683,7 +687,8 @@ export function readBrowserVersion(executablePath: string): string | null {
|
|||
}
|
||||
|
||||
export function parseBrowserMajorVersion(rawVersion: string | null | undefined): number | null {
|
||||
const match = String(rawVersion ?? "").match(CHROME_VERSION_RE);
|
||||
const matches = [...String(rawVersion ?? "").matchAll(CHROME_VERSION_RE)];
|
||||
const match = matches.at(-1);
|
||||
if (!match?.[1]) {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,28 @@
|
|||
import {
|
||||
type BrowserConfig,
|
||||
type BrowserProfileConfig,
|
||||
type OpenClawConfig,
|
||||
} from "../config/config.js";
|
||||
import { resolveGatewayPort } from "../config/paths.js";
|
||||
import {
|
||||
DEFAULT_BROWSER_CONTROL_PORT,
|
||||
deriveDefaultBrowserCdpPortRange,
|
||||
deriveDefaultBrowserControlPort,
|
||||
} from "../config/port-defaults.js";
|
||||
import type { SsrFPolicy } from "../infra/net/ssrf.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { parseBrowserHttpUrl, redactCdpUrl, isLoopbackHost } from "./cdp.helpers.js";
|
||||
import {
|
||||
DEFAULT_AI_SNAPSHOT_MAX_CHARS,
|
||||
DEFAULT_BROWSER_DEFAULT_PROFILE_NAME,
|
||||
DEFAULT_BROWSER_EVALUATE_ENABLED,
|
||||
DEFAULT_OPENCLAW_BROWSER_COLOR,
|
||||
DEFAULT_OPENCLAW_BROWSER_ENABLED,
|
||||
DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME,
|
||||
} from "./constants.js";
|
||||
import { resolveBrowserControlAuth, type BrowserControlAuth } from "./control-auth.js";
|
||||
import { DEFAULT_UPLOAD_DIR } from "./paths.js";
|
||||
|
||||
export {
|
||||
DEFAULT_AI_SNAPSHOT_MAX_CHARS,
|
||||
DEFAULT_BROWSER_DEFAULT_PROFILE_NAME,
|
||||
|
|
@ -5,15 +30,329 @@ export {
|
|||
DEFAULT_OPENCLAW_BROWSER_COLOR,
|
||||
DEFAULT_OPENCLAW_BROWSER_ENABLED,
|
||||
DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME,
|
||||
resolveBrowserConfig,
|
||||
resolveProfile,
|
||||
type ResolvedBrowserConfig,
|
||||
type ResolvedBrowserProfile,
|
||||
} from "openclaw/plugin-sdk/browser-profiles";
|
||||
export { parseBrowserHttpUrl, redactCdpUrl } from "openclaw/plugin-sdk/browser-cdp";
|
||||
export type { BrowserControlAuth } from "openclaw/plugin-sdk/browser-control-auth";
|
||||
export { resolveBrowserControlAuth } from "openclaw/plugin-sdk/browser-control-auth";
|
||||
export { parseBrowserHttpUrl as parseHttpUrl } from "openclaw/plugin-sdk/browser-cdp";
|
||||
DEFAULT_UPLOAD_DIR,
|
||||
parseBrowserHttpUrl,
|
||||
redactCdpUrl,
|
||||
resolveBrowserControlAuth,
|
||||
};
|
||||
export type { BrowserControlAuth };
|
||||
export { parseBrowserHttpUrl as parseHttpUrl };
|
||||
|
||||
export type ResolvedBrowserConfig = {
|
||||
enabled: boolean;
|
||||
evaluateEnabled: boolean;
|
||||
controlPort: number;
|
||||
cdpPortRangeStart: number;
|
||||
cdpPortRangeEnd: number;
|
||||
cdpProtocol: "http" | "https";
|
||||
cdpHost: string;
|
||||
cdpIsLoopback: boolean;
|
||||
remoteCdpTimeoutMs: number;
|
||||
remoteCdpHandshakeTimeoutMs: number;
|
||||
color: string;
|
||||
executablePath?: string;
|
||||
headless: boolean;
|
||||
noSandbox: boolean;
|
||||
attachOnly: boolean;
|
||||
defaultProfile: string;
|
||||
profiles: Record<string, BrowserProfileConfig>;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
extraArgs: string[];
|
||||
};
|
||||
|
||||
export type ResolvedBrowserProfile = {
|
||||
name: string;
|
||||
cdpPort: number;
|
||||
cdpUrl: string;
|
||||
cdpHost: string;
|
||||
cdpIsLoopback: boolean;
|
||||
userDataDir?: string;
|
||||
color: string;
|
||||
driver: "openclaw" | "existing-session";
|
||||
attachOnly: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_BROWSER_CDP_PORT_RANGE_START = 18800;
|
||||
|
||||
function normalizeHexColor(raw: string | undefined): string {
|
||||
const value = (raw ?? "").trim();
|
||||
if (!value) {
|
||||
return DEFAULT_OPENCLAW_BROWSER_COLOR;
|
||||
}
|
||||
const normalized = value.startsWith("#") ? value : `#${value}`;
|
||||
if (!/^#[0-9a-fA-F]{6}$/.test(normalized)) {
|
||||
return DEFAULT_OPENCLAW_BROWSER_COLOR;
|
||||
}
|
||||
return normalized.toUpperCase();
|
||||
}
|
||||
|
||||
function normalizeTimeoutMs(raw: number | undefined, fallback: number): number {
|
||||
const value = typeof raw === "number" && Number.isFinite(raw) ? Math.floor(raw) : fallback;
|
||||
return value < 0 ? fallback : value;
|
||||
}
|
||||
|
||||
function resolveCdpPortRangeStart(
|
||||
rawStart: number | undefined,
|
||||
fallbackStart: number,
|
||||
rangeSpan: number,
|
||||
): number {
|
||||
const start =
|
||||
typeof rawStart === "number" && Number.isFinite(rawStart)
|
||||
? Math.floor(rawStart)
|
||||
: fallbackStart;
|
||||
if (start < 1 || start > 65535) {
|
||||
throw new Error(`browser.cdpPortRangeStart must be between 1 and 65535, got: ${start}`);
|
||||
}
|
||||
const maxStart = 65535 - rangeSpan;
|
||||
if (start > maxStart) {
|
||||
throw new Error(
|
||||
`browser.cdpPortRangeStart (${start}) is too high for a ${rangeSpan + 1}-port range; max is ${maxStart}.`,
|
||||
);
|
||||
}
|
||||
return start;
|
||||
}
|
||||
|
||||
function normalizeStringList(raw: string[] | undefined): string[] | undefined {
|
||||
if (!Array.isArray(raw) || raw.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const values = raw
|
||||
.map((value) => value.trim())
|
||||
.filter((value): value is string => value.length > 0);
|
||||
return values.length > 0 ? values : undefined;
|
||||
}
|
||||
|
||||
function resolveBrowserSsrFPolicy(cfg: BrowserConfig | undefined): SsrFPolicy | undefined {
|
||||
const rawPolicy = cfg?.ssrfPolicy as
|
||||
| (BrowserConfig["ssrfPolicy"] & { allowPrivateNetwork?: boolean })
|
||||
| undefined;
|
||||
const allowPrivateNetwork = rawPolicy?.allowPrivateNetwork;
|
||||
const dangerouslyAllowPrivateNetwork = rawPolicy?.dangerouslyAllowPrivateNetwork;
|
||||
const allowedHostnames = normalizeStringList(rawPolicy?.allowedHostnames);
|
||||
const hostnameAllowlist = normalizeStringList(rawPolicy?.hostnameAllowlist);
|
||||
const hasExplicitPrivateSetting =
|
||||
allowPrivateNetwork !== undefined || dangerouslyAllowPrivateNetwork !== undefined;
|
||||
const resolvedAllowPrivateNetwork =
|
||||
dangerouslyAllowPrivateNetwork === true ||
|
||||
allowPrivateNetwork === true ||
|
||||
!hasExplicitPrivateSetting;
|
||||
|
||||
if (
|
||||
!resolvedAllowPrivateNetwork &&
|
||||
!hasExplicitPrivateSetting &&
|
||||
!allowedHostnames &&
|
||||
!hostnameAllowlist
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
...(resolvedAllowPrivateNetwork ? { dangerouslyAllowPrivateNetwork: true } : {}),
|
||||
...(allowedHostnames ? { allowedHostnames } : {}),
|
||||
...(hostnameAllowlist ? { hostnameAllowlist } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function ensureDefaultProfile(
|
||||
profiles: Record<string, BrowserProfileConfig> | undefined,
|
||||
defaultColor: string,
|
||||
legacyCdpPort?: number,
|
||||
derivedDefaultCdpPort?: number,
|
||||
legacyCdpUrl?: string,
|
||||
): Record<string, BrowserProfileConfig> {
|
||||
const result = { ...profiles };
|
||||
if (!result[DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME]) {
|
||||
result[DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME] = {
|
||||
cdpPort: legacyCdpPort ?? derivedDefaultCdpPort ?? DEFAULT_BROWSER_CDP_PORT_RANGE_START,
|
||||
color: defaultColor,
|
||||
...(legacyCdpUrl ? { cdpUrl: legacyCdpUrl } : {}),
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function ensureDefaultUserBrowserProfile(
|
||||
profiles: Record<string, BrowserProfileConfig>,
|
||||
): Record<string, BrowserProfileConfig> {
|
||||
const result = { ...profiles };
|
||||
if (result.user) {
|
||||
return result;
|
||||
}
|
||||
result.user = {
|
||||
driver: "existing-session",
|
||||
attachOnly: true,
|
||||
color: "#00AA00",
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
export function resolveBrowserConfig(
|
||||
cfg: BrowserConfig | undefined,
|
||||
rootConfig?: OpenClawConfig,
|
||||
): ResolvedBrowserConfig {
|
||||
const enabled = cfg?.enabled ?? DEFAULT_OPENCLAW_BROWSER_ENABLED;
|
||||
const evaluateEnabled = cfg?.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED;
|
||||
const gatewayPort = resolveGatewayPort(rootConfig);
|
||||
const controlPort = deriveDefaultBrowserControlPort(gatewayPort ?? DEFAULT_BROWSER_CONTROL_PORT);
|
||||
const defaultColor = normalizeHexColor(cfg?.color);
|
||||
const remoteCdpTimeoutMs = normalizeTimeoutMs(cfg?.remoteCdpTimeoutMs, 1500);
|
||||
const remoteCdpHandshakeTimeoutMs = normalizeTimeoutMs(
|
||||
cfg?.remoteCdpHandshakeTimeoutMs,
|
||||
Math.max(2000, remoteCdpTimeoutMs * 2),
|
||||
);
|
||||
|
||||
const derivedCdpRange = deriveDefaultBrowserCdpPortRange(controlPort);
|
||||
const cdpRangeSpan = derivedCdpRange.end - derivedCdpRange.start;
|
||||
const cdpPortRangeStart = resolveCdpPortRangeStart(
|
||||
cfg?.cdpPortRangeStart,
|
||||
derivedCdpRange.start,
|
||||
cdpRangeSpan,
|
||||
);
|
||||
const cdpPortRangeEnd = cdpPortRangeStart + cdpRangeSpan;
|
||||
|
||||
const rawCdpUrl = (cfg?.cdpUrl ?? "").trim();
|
||||
let cdpInfo:
|
||||
| {
|
||||
parsed: URL;
|
||||
port: number;
|
||||
normalized: string;
|
||||
}
|
||||
| undefined;
|
||||
if (rawCdpUrl) {
|
||||
cdpInfo = parseBrowserHttpUrl(rawCdpUrl, "browser.cdpUrl");
|
||||
} else {
|
||||
const derivedPort = controlPort + 1;
|
||||
if (derivedPort > 65535) {
|
||||
throw new Error(
|
||||
`Derived CDP port (${derivedPort}) is too high; check gateway port configuration.`,
|
||||
);
|
||||
}
|
||||
const derived = new URL(`http://127.0.0.1:${derivedPort}`);
|
||||
cdpInfo = {
|
||||
parsed: derived,
|
||||
port: derivedPort,
|
||||
normalized: derived.toString().replace(/\/$/, ""),
|
||||
};
|
||||
}
|
||||
|
||||
const headless = cfg?.headless === true;
|
||||
const noSandbox = cfg?.noSandbox === true;
|
||||
const attachOnly = cfg?.attachOnly === true;
|
||||
const executablePath = cfg?.executablePath?.trim() || undefined;
|
||||
const defaultProfileFromConfig = cfg?.defaultProfile?.trim() || undefined;
|
||||
|
||||
const legacyCdpPort = rawCdpUrl ? cdpInfo.port : undefined;
|
||||
const isWsUrl = cdpInfo.parsed.protocol === "ws:" || cdpInfo.parsed.protocol === "wss:";
|
||||
const legacyCdpUrl = rawCdpUrl && isWsUrl ? cdpInfo.normalized : undefined;
|
||||
const profiles = ensureDefaultUserBrowserProfile(
|
||||
ensureDefaultProfile(
|
||||
cfg?.profiles,
|
||||
defaultColor,
|
||||
legacyCdpPort,
|
||||
cdpPortRangeStart,
|
||||
legacyCdpUrl,
|
||||
),
|
||||
);
|
||||
const cdpProtocol = cdpInfo.parsed.protocol === "https:" ? "https" : "http";
|
||||
|
||||
const defaultProfile =
|
||||
defaultProfileFromConfig ??
|
||||
(profiles[DEFAULT_BROWSER_DEFAULT_PROFILE_NAME]
|
||||
? DEFAULT_BROWSER_DEFAULT_PROFILE_NAME
|
||||
: profiles[DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME]
|
||||
? DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME
|
||||
: "user");
|
||||
|
||||
const extraArgs = Array.isArray(cfg?.extraArgs)
|
||||
? cfg.extraArgs.filter(
|
||||
(value): value is string => typeof value === "string" && value.trim().length > 0,
|
||||
)
|
||||
: [];
|
||||
|
||||
return {
|
||||
enabled,
|
||||
evaluateEnabled,
|
||||
controlPort,
|
||||
cdpPortRangeStart,
|
||||
cdpPortRangeEnd,
|
||||
cdpProtocol,
|
||||
cdpHost: cdpInfo.parsed.hostname,
|
||||
cdpIsLoopback: isLoopbackHost(cdpInfo.parsed.hostname),
|
||||
remoteCdpTimeoutMs,
|
||||
remoteCdpHandshakeTimeoutMs,
|
||||
color: defaultColor,
|
||||
executablePath,
|
||||
headless,
|
||||
noSandbox,
|
||||
attachOnly,
|
||||
defaultProfile,
|
||||
profiles,
|
||||
ssrfPolicy: resolveBrowserSsrFPolicy(cfg),
|
||||
extraArgs,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveProfile(
|
||||
resolved: ResolvedBrowserConfig,
|
||||
profileName: string,
|
||||
): ResolvedBrowserProfile | null {
|
||||
const profile = resolved.profiles[profileName];
|
||||
if (!profile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawProfileUrl = profile.cdpUrl?.trim() ?? "";
|
||||
let cdpHost = resolved.cdpHost;
|
||||
let cdpPort = profile.cdpPort ?? 0;
|
||||
let cdpUrl = "";
|
||||
const driver = profile.driver === "existing-session" ? "existing-session" : "openclaw";
|
||||
|
||||
if (driver === "existing-session") {
|
||||
return {
|
||||
name: profileName,
|
||||
cdpPort: 0,
|
||||
cdpUrl: "",
|
||||
cdpHost: "",
|
||||
cdpIsLoopback: true,
|
||||
userDataDir: resolveUserPath(profile.userDataDir?.trim() || "") || undefined,
|
||||
color: profile.color,
|
||||
driver,
|
||||
attachOnly: true,
|
||||
};
|
||||
}
|
||||
|
||||
const hasStaleWsPath =
|
||||
rawProfileUrl !== "" &&
|
||||
cdpPort > 0 &&
|
||||
/^wss?:\/\//i.test(rawProfileUrl) &&
|
||||
/\/devtools\/browser\//i.test(rawProfileUrl);
|
||||
|
||||
if (hasStaleWsPath) {
|
||||
const parsed = new URL(rawProfileUrl);
|
||||
cdpHost = parsed.hostname;
|
||||
cdpUrl = `${resolved.cdpProtocol}://${cdpHost}:${cdpPort}`;
|
||||
} else if (rawProfileUrl) {
|
||||
const parsed = parseBrowserHttpUrl(rawProfileUrl, `browser.profiles.${profileName}.cdpUrl`);
|
||||
cdpHost = parsed.parsed.hostname;
|
||||
cdpPort = parsed.port;
|
||||
cdpUrl = parsed.normalized;
|
||||
} else if (cdpPort) {
|
||||
cdpUrl = `${resolved.cdpProtocol}://${resolved.cdpHost}:${cdpPort}`;
|
||||
} else {
|
||||
throw new Error(`Profile "${profileName}" must define cdpPort or cdpUrl.`);
|
||||
}
|
||||
|
||||
return {
|
||||
name: profileName,
|
||||
cdpPort,
|
||||
cdpUrl,
|
||||
cdpHost,
|
||||
cdpIsLoopback: isLoopbackHost(cdpHost),
|
||||
color: profile.color,
|
||||
driver,
|
||||
attachOnly: profile.attachOnly ?? resolved.attachOnly,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldStartLocalBrowserServer(_resolved: unknown) {
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
import { note } from "openclaw/plugin-sdk/browser-support";
|
||||
import {
|
||||
parseBrowserMajorVersion,
|
||||
readBrowserVersion,
|
||||
resolveGoogleChromeExecutableForPlatform,
|
||||
} from "./browser/chrome.executables.js";
|
||||
import type { OpenClawConfig } from "./config/config.js";
|
||||
|
||||
const CHROME_MCP_MIN_MAJOR = 144;
|
||||
const REMOTE_DEBUGGING_PAGES = [
|
||||
"chrome://inspect/#remote-debugging",
|
||||
"brave://inspect/#remote-debugging",
|
||||
"edge://inspect/#remote-debugging",
|
||||
].join(", ");
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
type ExistingSessionProfile = {
|
||||
name: string;
|
||||
userDataDir?: string;
|
||||
};
|
||||
|
||||
function collectChromeMcpProfiles(cfg: OpenClawConfig): ExistingSessionProfile[] {
|
||||
const browser = asRecord(cfg.browser);
|
||||
if (!browser) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const profiles = new Map<string, ExistingSessionProfile>();
|
||||
const defaultProfile =
|
||||
typeof browser.defaultProfile === "string" ? browser.defaultProfile.trim() : "";
|
||||
if (defaultProfile === "user") {
|
||||
profiles.set("user", { name: "user" });
|
||||
}
|
||||
|
||||
const configuredProfiles = asRecord(browser.profiles);
|
||||
if (!configuredProfiles) {
|
||||
return [...profiles.values()].toSorted((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
for (const [profileName, rawProfile] of Object.entries(configuredProfiles)) {
|
||||
const profile = asRecord(rawProfile);
|
||||
const driver = typeof profile?.driver === "string" ? profile.driver.trim() : "";
|
||||
if (driver === "existing-session") {
|
||||
const userDataDir =
|
||||
typeof profile?.userDataDir === "string" ? profile.userDataDir.trim() : undefined;
|
||||
profiles.set(profileName, { name: profileName, userDataDir: userDataDir || undefined });
|
||||
}
|
||||
}
|
||||
|
||||
return [...profiles.values()].toSorted((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
export async function noteChromeMcpBrowserReadiness(
|
||||
cfg: OpenClawConfig,
|
||||
deps?: {
|
||||
platform?: NodeJS.Platform;
|
||||
noteFn?: typeof note;
|
||||
resolveChromeExecutable?: (platform: NodeJS.Platform) => { path: string } | null;
|
||||
readVersion?: (executablePath: string) => string | null;
|
||||
},
|
||||
) {
|
||||
const profiles = collectChromeMcpProfiles(cfg);
|
||||
if (profiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const noteFn = deps?.noteFn ?? note;
|
||||
const platform = deps?.platform ?? process.platform;
|
||||
const resolveChromeExecutable =
|
||||
deps?.resolveChromeExecutable ?? resolveGoogleChromeExecutableForPlatform;
|
||||
const readVersion = deps?.readVersion ?? readBrowserVersion;
|
||||
const explicitProfiles = profiles.filter((profile) => profile.userDataDir);
|
||||
const autoConnectProfiles = profiles.filter((profile) => !profile.userDataDir);
|
||||
const profileLabel = profiles.map((profile) => profile.name).join(", ");
|
||||
|
||||
if (autoConnectProfiles.length === 0) {
|
||||
noteFn(
|
||||
[
|
||||
`- Chrome MCP existing-session is configured for profile(s): ${profileLabel}.`,
|
||||
"- These profiles use an explicit Chromium user data directory instead of Chrome's default auto-connect path.",
|
||||
`- Verify the matching Chromium-based browser is version ${CHROME_MCP_MIN_MAJOR}+ on the same host as the Gateway or node.`,
|
||||
`- Enable remote debugging in that browser's inspect page (${REMOTE_DEBUGGING_PAGES}).`,
|
||||
"- Keep the browser running and accept the attach consent prompt the first time OpenClaw connects.",
|
||||
].join("\n"),
|
||||
"Browser",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const chrome = resolveChromeExecutable(platform);
|
||||
const autoProfileLabel = autoConnectProfiles.map((profile) => profile.name).join(", ");
|
||||
|
||||
if (!chrome) {
|
||||
const lines = [
|
||||
`- Chrome MCP existing-session is configured for profile(s): ${profileLabel}.`,
|
||||
`- Google Chrome was not found on this host for auto-connect profile(s): ${autoProfileLabel}. OpenClaw does not bundle Chrome.`,
|
||||
`- Install Google Chrome ${CHROME_MCP_MIN_MAJOR}+ on the same host as the Gateway or node, or set browser.profiles.<name>.userDataDir for a different Chromium-based browser.`,
|
||||
`- Enable remote debugging in the browser inspect page (${REMOTE_DEBUGGING_PAGES}).`,
|
||||
"- Keep the browser running and accept the attach consent prompt the first time OpenClaw connects.",
|
||||
"- Docker, headless, and sandbox browser flows stay on raw CDP; this check only applies to host-local Chrome MCP attach.",
|
||||
];
|
||||
if (explicitProfiles.length > 0) {
|
||||
lines.push(
|
||||
`- Profiles with explicit userDataDir skip Chrome auto-detection: ${explicitProfiles
|
||||
.map((profile) => profile.name)
|
||||
.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
noteFn(lines.join("\n"), "Browser");
|
||||
return;
|
||||
}
|
||||
|
||||
const versionRaw = readVersion(chrome.path);
|
||||
const major = parseBrowserMajorVersion(versionRaw);
|
||||
const lines = [
|
||||
`- Chrome MCP existing-session is configured for profile(s): ${profileLabel}.`,
|
||||
`- Chrome path: ${chrome.path}`,
|
||||
];
|
||||
|
||||
if (!versionRaw || major === null) {
|
||||
lines.push(
|
||||
`- Could not determine the installed Chrome version. Chrome MCP requires Google Chrome ${CHROME_MCP_MIN_MAJOR}+ on this host.`,
|
||||
);
|
||||
} else if (major < CHROME_MCP_MIN_MAJOR) {
|
||||
lines.push(
|
||||
`- Detected Chrome ${versionRaw}, which is too old for Chrome MCP existing-session attach. Upgrade to Chrome ${CHROME_MCP_MIN_MAJOR}+.`,
|
||||
);
|
||||
} else {
|
||||
lines.push(`- Detected Chrome ${versionRaw}.`);
|
||||
}
|
||||
|
||||
lines.push(`- Enable remote debugging in the browser inspect page (${REMOTE_DEBUGGING_PAGES}).`);
|
||||
lines.push(
|
||||
"- Keep the browser running and accept the attach consent prompt the first time OpenClaw connects.",
|
||||
);
|
||||
if (explicitProfiles.length > 0) {
|
||||
lines.push(
|
||||
`- Profiles with explicit userDataDir still need manual validation of the matching Chromium-based browser: ${explicitProfiles
|
||||
.map((profile) => profile.name)
|
||||
.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
|
||||
noteFn(lines.join("\n"), "Browser");
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { collectBrowserSecurityAuditFindings } from "./security-audit.js";
|
||||
|
||||
function collectFindings(
|
||||
config: Parameters<typeof collectBrowserSecurityAuditFindings>[0]["config"],
|
||||
) {
|
||||
return collectBrowserSecurityAuditFindings({
|
||||
config,
|
||||
sourceConfig: config,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
stateDir: "/tmp/openclaw-state",
|
||||
configPath: "/tmp/openclaw.json",
|
||||
});
|
||||
}
|
||||
|
||||
describe("browser security audit collector", () => {
|
||||
it("flags browser control without auth", () => {
|
||||
const findings = collectFindings({
|
||||
gateway: {
|
||||
controlUi: { enabled: false },
|
||||
auth: {},
|
||||
},
|
||||
browser: {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(findings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
checkId: "browser.control_no_auth",
|
||||
severity: "critical",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("warns on remote http CDP profiles", () => {
|
||||
const findings = collectFindings({
|
||||
browser: {
|
||||
profiles: {
|
||||
remote: {
|
||||
cdpUrl: "http://example.com:9222",
|
||||
color: "#0066CC",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(findings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
checkId: "browser.remote_cdp_http",
|
||||
severity: "warn",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("redacts private-host CDP URLs in findings", () => {
|
||||
const findings = collectFindings({
|
||||
browser: {
|
||||
ssrfPolicy: {
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
},
|
||||
profiles: {
|
||||
remote: {
|
||||
cdpUrl:
|
||||
"http://169.254.169.254:9222/json/version?token=supersecrettokenvalue1234567890",
|
||||
color: "#0066CC",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(findings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
checkId: "browser.remote_cdp_private_host",
|
||||
severity: "warn",
|
||||
detail: expect.stringContaining("token=supers…7890"),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
import type { OpenClawPluginSecurityAuditContext } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input";
|
||||
import { formatCliCommand } from "openclaw/plugin-sdk/setup-tools";
|
||||
import { isPrivateNetworkOptInEnabled, isPrivateIpAddress } from "openclaw/plugin-sdk/ssrf-policy";
|
||||
import { redactCdpUrl, resolveBrowserConfig, resolveProfile } from "./browser/config.js";
|
||||
import { resolveBrowserControlAuth } from "./browser/control-auth.js";
|
||||
|
||||
const BLOCKED_HOSTNAMES = new Set([
|
||||
"localhost",
|
||||
"localhost.localdomain",
|
||||
"metadata.google.internal",
|
||||
]);
|
||||
|
||||
function hasNonEmptyString(value: unknown): boolean {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function isTrustedPrivateHostname(hostname: string): boolean {
|
||||
const normalized = hostname.trim().toLowerCase();
|
||||
return normalized.length > 0 && BLOCKED_HOSTNAMES.has(normalized);
|
||||
}
|
||||
|
||||
export function collectBrowserSecurityAuditFindings(ctx: OpenClawPluginSecurityAuditContext) {
|
||||
const findings: Array<{
|
||||
checkId: string;
|
||||
severity: "warn" | "critical";
|
||||
title: string;
|
||||
detail: string;
|
||||
remediation?: string;
|
||||
}> = [];
|
||||
|
||||
let resolved: ReturnType<typeof resolveBrowserConfig>;
|
||||
try {
|
||||
resolved = resolveBrowserConfig(ctx.config.browser, ctx.config);
|
||||
} catch (err) {
|
||||
findings.push({
|
||||
checkId: "browser.control_invalid_config",
|
||||
severity: "warn" as const,
|
||||
title: "Browser control config looks invalid",
|
||||
detail: String(err),
|
||||
remediation: `Fix browser.cdpUrl in ${ctx.configPath} and re-run "${formatCliCommand("openclaw security audit --deep")}".`,
|
||||
});
|
||||
return findings;
|
||||
}
|
||||
|
||||
if (!resolved.enabled) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
const browserAuth = resolveBrowserControlAuth(ctx.config, ctx.env);
|
||||
const explicitAuthMode = ctx.config.gateway?.auth?.mode;
|
||||
const tokenConfigured =
|
||||
Boolean(browserAuth.token) ||
|
||||
hasNonEmptyString(ctx.env.OPENCLAW_GATEWAY_TOKEN) ||
|
||||
hasConfiguredSecretInput(ctx.config.gateway?.auth?.token, ctx.config.secrets?.defaults);
|
||||
const passwordCanWin =
|
||||
explicitAuthMode === "password" ||
|
||||
(explicitAuthMode !== "token" &&
|
||||
explicitAuthMode !== "none" &&
|
||||
explicitAuthMode !== "trusted-proxy" &&
|
||||
!tokenConfigured);
|
||||
const passwordConfigured =
|
||||
Boolean(browserAuth.password) ||
|
||||
(passwordCanWin &&
|
||||
(hasNonEmptyString(ctx.env.OPENCLAW_GATEWAY_PASSWORD) ||
|
||||
hasConfiguredSecretInput(
|
||||
ctx.config.gateway?.auth?.password,
|
||||
ctx.config.secrets?.defaults,
|
||||
)));
|
||||
if (!tokenConfigured && !passwordConfigured) {
|
||||
findings.push({
|
||||
checkId: "browser.control_no_auth",
|
||||
severity: "critical" as const,
|
||||
title: "Browser control has no auth",
|
||||
detail:
|
||||
"Browser control HTTP routes are enabled but no gateway.auth token/password is configured. " +
|
||||
"Any local process (or SSRF to loopback) can call browser control endpoints.",
|
||||
remediation:
|
||||
"Set gateway.auth.token (recommended) or gateway.auth.password so browser control HTTP routes require authentication. Restarting the gateway will auto-generate gateway.auth.token when browser control is enabled.",
|
||||
});
|
||||
}
|
||||
|
||||
for (const name of Object.keys(resolved.profiles)) {
|
||||
const profile = resolveProfile(resolved, name);
|
||||
if (!profile || profile.cdpIsLoopback) {
|
||||
continue;
|
||||
}
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(profile.cdpUrl);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const redactedCdpUrl = redactCdpUrl(profile.cdpUrl) ?? profile.cdpUrl;
|
||||
if (url.protocol === "http:") {
|
||||
findings.push({
|
||||
checkId: "browser.remote_cdp_http",
|
||||
severity: "warn" as const,
|
||||
title: "Remote CDP uses HTTP",
|
||||
detail: `browser profile "${name}" uses http CDP (${redactedCdpUrl}); this is OK only if it's tailnet-only or behind an encrypted tunnel.`,
|
||||
remediation: "Prefer HTTPS/TLS or a tailnet-only endpoint for remote CDP.",
|
||||
});
|
||||
}
|
||||
if (
|
||||
isPrivateNetworkOptInEnabled(resolved.ssrfPolicy) &&
|
||||
(isTrustedPrivateHostname(url.hostname) || isPrivateIpAddress(url.hostname))
|
||||
) {
|
||||
findings.push({
|
||||
checkId: "browser.remote_cdp_private_host",
|
||||
severity: "warn" as const,
|
||||
title: "Remote CDP targets a private/internal host",
|
||||
detail:
|
||||
`browser profile "${name}" points at a private/internal CDP host (${redactedCdpUrl}). ` +
|
||||
"This is expected for LAN/tailnet/WSL-style setups, but treat it as a trusted-network endpoint.",
|
||||
remediation:
|
||||
"Prefer a tailnet or tunnel for remote CDP. If you want strict blocking, set browser.ssrfPolicy.dangerouslyAllowPrivateNetwork=false and allow only explicit hosts.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
|
@ -44,6 +44,9 @@ function fakeApi(overrides: Partial<OpenClawPluginApi> = {}): OpenClawPluginApi
|
|||
registerGatewayMethod() {},
|
||||
registerCli() {},
|
||||
registerService() {},
|
||||
registerReload() {},
|
||||
registerNodeHostCommand() {},
|
||||
registerSecurityAuditCollector() {},
|
||||
registerConfigMigration() {},
|
||||
registerAutoEnableProbe() {},
|
||||
registerProvider() {},
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,150 +1 @@
|
|||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
parseBrowserMajorVersion,
|
||||
readBrowserVersion,
|
||||
resolveGoogleChromeExecutableForPlatform,
|
||||
} from "../plugin-sdk/browser-host-inspection.js";
|
||||
import { note } from "../terminal/note.js";
|
||||
|
||||
const CHROME_MCP_MIN_MAJOR = 144;
|
||||
const REMOTE_DEBUGGING_PAGES = [
|
||||
"chrome://inspect/#remote-debugging",
|
||||
"brave://inspect/#remote-debugging",
|
||||
"edge://inspect/#remote-debugging",
|
||||
].join(", ");
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
type ExistingSessionProfile = {
|
||||
name: string;
|
||||
userDataDir?: string;
|
||||
};
|
||||
|
||||
function collectChromeMcpProfiles(cfg: OpenClawConfig): ExistingSessionProfile[] {
|
||||
const browser = asRecord(cfg.browser);
|
||||
if (!browser) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const profiles = new Map<string, ExistingSessionProfile>();
|
||||
const defaultProfile =
|
||||
typeof browser.defaultProfile === "string" ? browser.defaultProfile.trim() : "";
|
||||
if (defaultProfile === "user") {
|
||||
profiles.set("user", { name: "user" });
|
||||
}
|
||||
|
||||
const configuredProfiles = asRecord(browser.profiles);
|
||||
if (!configuredProfiles) {
|
||||
return [...profiles.values()].toSorted((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
for (const [profileName, rawProfile] of Object.entries(configuredProfiles)) {
|
||||
const profile = asRecord(rawProfile);
|
||||
const driver = typeof profile?.driver === "string" ? profile.driver.trim() : "";
|
||||
if (driver === "existing-session") {
|
||||
const userDataDir =
|
||||
typeof profile?.userDataDir === "string" ? profile.userDataDir.trim() : undefined;
|
||||
profiles.set(profileName, { name: profileName, userDataDir: userDataDir || undefined });
|
||||
}
|
||||
}
|
||||
|
||||
return [...profiles.values()].toSorted((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
export async function noteChromeMcpBrowserReadiness(
|
||||
cfg: OpenClawConfig,
|
||||
deps?: {
|
||||
platform?: NodeJS.Platform;
|
||||
noteFn?: typeof note;
|
||||
resolveChromeExecutable?: (platform: NodeJS.Platform) => { path: string } | null;
|
||||
readVersion?: (executablePath: string) => string | null;
|
||||
},
|
||||
) {
|
||||
const profiles = collectChromeMcpProfiles(cfg);
|
||||
if (profiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const noteFn = deps?.noteFn ?? note;
|
||||
const platform = deps?.platform ?? process.platform;
|
||||
const resolveChromeExecutable =
|
||||
deps?.resolveChromeExecutable ?? resolveGoogleChromeExecutableForPlatform;
|
||||
const readVersion = deps?.readVersion ?? readBrowserVersion;
|
||||
const explicitProfiles = profiles.filter((profile) => profile.userDataDir);
|
||||
const autoConnectProfiles = profiles.filter((profile) => !profile.userDataDir);
|
||||
const profileLabel = profiles.map((profile) => profile.name).join(", ");
|
||||
|
||||
if (autoConnectProfiles.length === 0) {
|
||||
noteFn(
|
||||
[
|
||||
`- Chrome MCP existing-session is configured for profile(s): ${profileLabel}.`,
|
||||
"- These profiles use an explicit Chromium user data directory instead of Chrome's default auto-connect path.",
|
||||
`- Verify the matching Chromium-based browser is version ${CHROME_MCP_MIN_MAJOR}+ on the same host as the Gateway or node.`,
|
||||
`- Enable remote debugging in that browser's inspect page (${REMOTE_DEBUGGING_PAGES}).`,
|
||||
"- Keep the browser running and accept the attach consent prompt the first time OpenClaw connects.",
|
||||
].join("\n"),
|
||||
"Browser",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const chrome = resolveChromeExecutable(platform);
|
||||
const autoProfileLabel = autoConnectProfiles.map((profile) => profile.name).join(", ");
|
||||
|
||||
if (!chrome) {
|
||||
const lines = [
|
||||
`- Chrome MCP existing-session is configured for profile(s): ${profileLabel}.`,
|
||||
`- Google Chrome was not found on this host for auto-connect profile(s): ${autoProfileLabel}. OpenClaw does not bundle Chrome.`,
|
||||
`- Install Google Chrome ${CHROME_MCP_MIN_MAJOR}+ on the same host as the Gateway or node, or set browser.profiles.<name>.userDataDir for a different Chromium-based browser.`,
|
||||
`- Enable remote debugging in the browser inspect page (${REMOTE_DEBUGGING_PAGES}).`,
|
||||
"- Keep the browser running and accept the attach consent prompt the first time OpenClaw connects.",
|
||||
"- Docker, headless, and sandbox browser flows stay on raw CDP; this check only applies to host-local Chrome MCP attach.",
|
||||
];
|
||||
if (explicitProfiles.length > 0) {
|
||||
lines.push(
|
||||
`- Profiles with explicit userDataDir skip Chrome auto-detection: ${explicitProfiles
|
||||
.map((profile) => profile.name)
|
||||
.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
noteFn(lines.join("\n"), "Browser");
|
||||
return;
|
||||
}
|
||||
|
||||
const versionRaw = readVersion(chrome.path);
|
||||
const major = parseBrowserMajorVersion(versionRaw);
|
||||
const lines = [
|
||||
`- Chrome MCP existing-session is configured for profile(s): ${profileLabel}.`,
|
||||
`- Chrome path: ${chrome.path}`,
|
||||
];
|
||||
|
||||
if (!versionRaw || major === null) {
|
||||
lines.push(
|
||||
`- Could not determine the installed Chrome version. Chrome MCP requires Google Chrome ${CHROME_MCP_MIN_MAJOR}+ on this host.`,
|
||||
);
|
||||
} else if (major < CHROME_MCP_MIN_MAJOR) {
|
||||
lines.push(
|
||||
`- Detected Chrome ${versionRaw}, which is too old for Chrome MCP existing-session attach. Upgrade to Chrome ${CHROME_MCP_MIN_MAJOR}+.`,
|
||||
);
|
||||
} else {
|
||||
lines.push(`- Detected Chrome ${versionRaw}.`);
|
||||
}
|
||||
|
||||
lines.push(`- Enable remote debugging in the browser inspect page (${REMOTE_DEBUGGING_PAGES}).`);
|
||||
lines.push(
|
||||
"- Keep the browser running and accept the attach consent prompt the first time OpenClaw connects.",
|
||||
);
|
||||
if (explicitProfiles.length > 0) {
|
||||
lines.push(
|
||||
`- Profiles with explicit userDataDir still need manual validation of the matching Chromium-based browser: ${explicitProfiles
|
||||
.map((profile) => profile.name)
|
||||
.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
|
||||
noteFn(lines.join("\n"), "Browser");
|
||||
}
|
||||
export { noteChromeMcpBrowserReadiness } from "../../extensions/browser/browser-doctor.js";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { getActivePluginRegistry } from "../plugins/runtime.js";
|
||||
|
||||
let pluginRegistryLoaderModulePromise:
|
||||
| Promise<typeof import("../plugins/runtime/runtime-registry-loader.js")>
|
||||
| undefined;
|
||||
|
||||
async function loadPluginRegistryLoaderModule() {
|
||||
pluginRegistryLoaderModulePromise ??= import("../plugins/runtime/runtime-registry-loader.js");
|
||||
return await pluginRegistryLoaderModulePromise;
|
||||
}
|
||||
|
||||
export async function ensureNodeHostPluginRegistry(params: {
|
||||
config: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<void> {
|
||||
(await loadPluginRegistryLoaderModule()).ensurePluginRegistryLoaded({
|
||||
scope: "all",
|
||||
config: params.config,
|
||||
activationSourceConfig: params.config,
|
||||
env: params.env,
|
||||
});
|
||||
}
|
||||
|
||||
export function listRegisteredNodeHostCapsAndCommands(): {
|
||||
caps: string[];
|
||||
commands: string[];
|
||||
} {
|
||||
const registry = getActivePluginRegistry();
|
||||
const caps = new Set<string>();
|
||||
const commands = new Set<string>();
|
||||
for (const entry of registry?.nodeHostCommands ?? []) {
|
||||
if (entry.command.cap) {
|
||||
caps.add(entry.command.cap);
|
||||
}
|
||||
commands.add(entry.command.command);
|
||||
}
|
||||
return {
|
||||
caps: [...caps].toSorted((left, right) => left.localeCompare(right)),
|
||||
commands: [...commands].toSorted((left, right) => left.localeCompare(right)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function invokeRegisteredNodeHostCommand(
|
||||
command: string,
|
||||
paramsJSON?: string | null,
|
||||
): Promise<string | null> {
|
||||
const registry = getActivePluginRegistry();
|
||||
const match = (registry?.nodeHostCommands ?? []).find(
|
||||
(entry) => entry.command.command === command,
|
||||
);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return await match.command.handle(paramsJSON);
|
||||
}
|
||||
|
|
@ -5,13 +5,8 @@ import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
|
|||
import type { SkillBinTrustEntry } from "../infra/exec-approvals.js";
|
||||
import { resolveExecutableFromPathEnv } from "../infra/executable-path.js";
|
||||
import { getMachineDisplayName } from "../infra/machine-name.js";
|
||||
import {
|
||||
NODE_BROWSER_PROXY_COMMAND,
|
||||
NODE_EXEC_APPROVALS_COMMANDS,
|
||||
NODE_SYSTEM_RUN_COMMANDS,
|
||||
} from "../infra/node-commands.js";
|
||||
import { NODE_EXEC_APPROVALS_COMMANDS, NODE_SYSTEM_RUN_COMMANDS } from "../infra/node-commands.js";
|
||||
import { ensureOpenClawCliOnPath } from "../infra/path-env.js";
|
||||
import { resolveBrowserConfig } from "../plugin-sdk/browser-profiles.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import { ensureNodeHostConfig, saveNodeHostConfig, type NodeHostGatewayConfig } from "./config.js";
|
||||
|
|
@ -21,6 +16,10 @@ import {
|
|||
buildNodeInvokeResultParams,
|
||||
handleInvoke,
|
||||
} from "./invoke.js";
|
||||
import {
|
||||
ensureNodeHostPluginRegistry,
|
||||
listRegisteredNodeHostCapsAndCommands,
|
||||
} from "./plugin-node-host.js";
|
||||
|
||||
export { buildNodeInvokeResultParams };
|
||||
|
||||
|
|
@ -164,9 +163,8 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
|
|||
await saveNodeHostConfig(config);
|
||||
|
||||
const cfg = loadConfig();
|
||||
const resolvedBrowser = resolveBrowserConfig(cfg.browser, cfg);
|
||||
const browserProxyEnabled =
|
||||
cfg.nodeHost?.browserProxy?.enabled !== false && resolvedBrowser.enabled;
|
||||
await ensureNodeHostPluginRegistry({ config: cfg, env: process.env });
|
||||
const pluginNodeHost = listRegisteredNodeHostCapsAndCommands();
|
||||
const { token, password } = await resolveNodeHostGatewayCredentials({
|
||||
config: cfg,
|
||||
env: process.env,
|
||||
|
|
@ -190,11 +188,11 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
|
|||
mode: GATEWAY_CLIENT_MODES.NODE,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
caps: ["system", ...(browserProxyEnabled ? ["browser"] : [])],
|
||||
caps: ["system", ...pluginNodeHost.caps],
|
||||
commands: [
|
||||
...NODE_SYSTEM_RUN_COMMANDS,
|
||||
...NODE_EXEC_APPROVALS_COMMANDS,
|
||||
...(browserProxyEnabled ? [NODE_BROWSER_PROXY_COMMAND] : []),
|
||||
...pluginNodeHost.commands,
|
||||
],
|
||||
pathEnv,
|
||||
permissions: undefined,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -1,41 +1,5 @@
|
|||
import { resolveGatewayAuth } from "../gateway/auth.js";
|
||||
import type { OpenClawConfig } from "./browser-support.js";
|
||||
|
||||
export type BrowserControlAuth = {
|
||||
token?: string;
|
||||
password?: string;
|
||||
};
|
||||
|
||||
export function resolveBrowserControlAuth(
|
||||
cfg: OpenClawConfig | undefined,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): BrowserControlAuth {
|
||||
const auth = resolveGatewayAuth({
|
||||
authConfig: cfg?.gateway?.auth,
|
||||
env,
|
||||
tailscaleMode: cfg?.gateway?.tailscale?.mode,
|
||||
});
|
||||
const token = typeof auth.token === "string" ? auth.token.trim() : "";
|
||||
const password = typeof auth.password === "string" ? auth.password.trim() : "";
|
||||
return {
|
||||
token: token || undefined,
|
||||
password: password || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
type BrowserControlAuthModule = typeof import("@openclaw/browser/browser-control-auth.js");
|
||||
import { loadBundledPluginPublicSurfaceModuleSync } from "./facade-runtime.js";
|
||||
|
||||
function loadBrowserControlAuthModule(): BrowserControlAuthModule {
|
||||
return loadBundledPluginPublicSurfaceModuleSync<BrowserControlAuthModule>({
|
||||
dirName: "browser",
|
||||
artifactBasename: "browser-control-auth.js",
|
||||
});
|
||||
}
|
||||
|
||||
export const ensureBrowserControlAuth: BrowserControlAuthModule["ensureBrowserControlAuth"] = ((
|
||||
...args
|
||||
) =>
|
||||
loadBrowserControlAuthModule().ensureBrowserControlAuth(
|
||||
...args,
|
||||
)) as BrowserControlAuthModule["ensureBrowserControlAuth"];
|
||||
export type { BrowserControlAuth } from "../../extensions/browser/browser-control-auth.js";
|
||||
export {
|
||||
ensureBrowserControlAuth,
|
||||
resolveBrowserControlAuth,
|
||||
} from "../../extensions/browser/browser-control-auth.js";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -1,85 +1,48 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const runCommandWithTimeout = vi.hoisted(() => vi.fn());
|
||||
const mkdir = vi.hoisted(() => vi.fn());
|
||||
const access = vi.hoisted(() => vi.fn());
|
||||
const rename = vi.hoisted(() => vi.fn());
|
||||
const tryLoadActivatedBundledPluginPublicSurfaceModuleSync = vi.hoisted(() => vi.fn());
|
||||
const closeTrackedBrowserTabsForSessionsImpl = vi.hoisted(() => vi.fn());
|
||||
const movePathToTrashImpl = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../process/exec.js", () => ({
|
||||
runCommandWithTimeout,
|
||||
vi.mock("../../extensions/browser/browser-maintenance.js", () => ({
|
||||
closeTrackedBrowserTabsForSessions: closeTrackedBrowserTabsForSessionsImpl,
|
||||
movePathToTrash: movePathToTrashImpl,
|
||||
}));
|
||||
|
||||
vi.mock("./facade-runtime.js", () => ({
|
||||
tryLoadActivatedBundledPluginPublicSurfaceModuleSync,
|
||||
}));
|
||||
|
||||
vi.mock("node:fs/promises", async () => {
|
||||
const { mockNodeBuiltinModule } = await import("../../test/helpers/node-builtin-mocks.js");
|
||||
return mockNodeBuiltinModule(
|
||||
() => vi.importActual<typeof import("node:fs/promises")>("node:fs/promises"),
|
||||
{ mkdir, access, rename },
|
||||
{ mirrorToDefault: true },
|
||||
);
|
||||
});
|
||||
|
||||
vi.mock("node:os", async () => {
|
||||
const { mockNodeBuiltinModule } = await import("../../test/helpers/node-builtin-mocks.js");
|
||||
return mockNodeBuiltinModule(
|
||||
() => vi.importActual<typeof import("node:os")>("node:os"),
|
||||
{ homedir: () => "/home/test" },
|
||||
{ mirrorToDefault: true },
|
||||
);
|
||||
});
|
||||
|
||||
describe("browser maintenance", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
runCommandWithTimeout.mockReset();
|
||||
mkdir.mockReset();
|
||||
access.mockReset();
|
||||
rename.mockReset();
|
||||
tryLoadActivatedBundledPluginPublicSurfaceModuleSync.mockReset();
|
||||
vi.spyOn(Date, "now").mockReturnValue(123);
|
||||
closeTrackedBrowserTabsForSessionsImpl.mockReset();
|
||||
movePathToTrashImpl.mockReset();
|
||||
});
|
||||
|
||||
it("skips browser runtime lookup when no session keys are provided", async () => {
|
||||
it("skips browser cleanup when no session keys are provided", async () => {
|
||||
closeTrackedBrowserTabsForSessionsImpl.mockResolvedValue(0);
|
||||
|
||||
const { closeTrackedBrowserTabsForSessions } = await import("./browser-maintenance.js");
|
||||
|
||||
await expect(closeTrackedBrowserTabsForSessions({ sessionKeys: [] })).resolves.toBe(0);
|
||||
expect(tryLoadActivatedBundledPluginPublicSurfaceModuleSync).not.toHaveBeenCalled();
|
||||
expect(closeTrackedBrowserTabsForSessionsImpl).toHaveBeenCalledWith({ sessionKeys: [] });
|
||||
expect(movePathToTrashImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns the target path when trash exits successfully", async () => {
|
||||
const { movePathToTrash } = await import("./browser-maintenance.js");
|
||||
runCommandWithTimeout.mockResolvedValue({
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
killed: false,
|
||||
termination: "exit",
|
||||
});
|
||||
it("delegates cleanup through the browser maintenance surface", async () => {
|
||||
closeTrackedBrowserTabsForSessionsImpl.mockResolvedValue(2);
|
||||
|
||||
await expect(movePathToTrash("/tmp/demo")).resolves.toBe("/tmp/demo");
|
||||
expect(mkdir).not.toHaveBeenCalled();
|
||||
expect(rename).not.toHaveBeenCalled();
|
||||
const { closeTrackedBrowserTabsForSessions } = await import("./browser-maintenance.js");
|
||||
|
||||
await expect(
|
||||
closeTrackedBrowserTabsForSessions({ sessionKeys: ["agent:main:test"] }),
|
||||
).resolves.toBe(2);
|
||||
expect(closeTrackedBrowserTabsForSessionsImpl).toHaveBeenCalledWith({
|
||||
sessionKeys: ["agent:main:test"],
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to rename when trash exits non-zero", async () => {
|
||||
const { movePathToTrash } = await import("./browser-maintenance.js");
|
||||
runCommandWithTimeout.mockResolvedValue({
|
||||
stdout: "",
|
||||
stderr: "permission denied",
|
||||
code: 1,
|
||||
signal: null,
|
||||
killed: false,
|
||||
termination: "exit",
|
||||
});
|
||||
access.mockRejectedValue(new Error("missing"));
|
||||
it("delegates move-to-trash through the browser maintenance surface", async () => {
|
||||
movePathToTrashImpl.mockImplementation(async (targetPath: string) => `${targetPath}.trashed`);
|
||||
|
||||
await expect(movePathToTrash("/tmp/demo")).resolves.toBe("/home/test/.Trash/demo-123");
|
||||
expect(mkdir).toHaveBeenCalledWith("/home/test/.Trash", { recursive: true });
|
||||
expect(rename).toHaveBeenCalledWith("/tmp/demo", "/home/test/.Trash/demo-123");
|
||||
const { movePathToTrash } = await import("./browser-maintenance.js");
|
||||
|
||||
await expect(movePathToTrash("/tmp/demo")).resolves.toBe("/tmp/demo.trashed");
|
||||
expect(movePathToTrashImpl).toHaveBeenCalledWith("/tmp/demo");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,67 +1,4 @@
|
|||
import { randomBytes } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import { tryLoadActivatedBundledPluginPublicSurfaceModuleSync } from "./facade-runtime.js";
|
||||
|
||||
type CloseTrackedBrowserTabsForSessions = (params: {
|
||||
sessionKeys: Array<string | undefined>;
|
||||
closeTab?: (tab: { targetId: string; baseUrl?: string; profile?: string }) => Promise<void>;
|
||||
onWarn?: (message: string) => void;
|
||||
}) => Promise<number>;
|
||||
|
||||
type MovePathToTrash = (targetPath: string) => Promise<string>;
|
||||
|
||||
function createTrashCollisionSuffix(): string {
|
||||
return randomBytes(6).toString("hex");
|
||||
}
|
||||
|
||||
export const closeTrackedBrowserTabsForSessions: CloseTrackedBrowserTabsForSessions = async (
|
||||
params,
|
||||
) => {
|
||||
if (!Array.isArray(params?.sessionKeys) || params.sessionKeys.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
// Session reset always attempts browser cleanup, even when browser is disabled.
|
||||
// Keep that path a no-op unless the browser runtime is actually active.
|
||||
const closeTrackedTabs = tryLoadActivatedBundledPluginPublicSurfaceModuleSync<{
|
||||
closeTrackedBrowserTabsForSessions: CloseTrackedBrowserTabsForSessions;
|
||||
}>({
|
||||
dirName: "browser",
|
||||
artifactBasename: "runtime-api.js",
|
||||
})?.closeTrackedBrowserTabsForSessions;
|
||||
if (typeof closeTrackedTabs !== "function") {
|
||||
return 0;
|
||||
}
|
||||
return await closeTrackedTabs(params);
|
||||
};
|
||||
|
||||
export const movePathToTrash: MovePathToTrash = async (targetPath) => {
|
||||
try {
|
||||
const result = await runCommandWithTimeout(["trash", targetPath], { timeoutMs: 10_000 });
|
||||
if (result.code !== 0) {
|
||||
throw new Error(`trash exited with code ${result.code ?? "unknown"}`);
|
||||
}
|
||||
return targetPath;
|
||||
} catch {
|
||||
const homeDir = os.homedir();
|
||||
const pathRuntime = homeDir.startsWith("/") ? path.posix : path;
|
||||
const trashDir = pathRuntime.join(homeDir, ".Trash");
|
||||
await fs.mkdir(trashDir, { recursive: true });
|
||||
const base = pathRuntime.basename(targetPath);
|
||||
const timestamp = Date.now();
|
||||
let destination = pathRuntime.join(trashDir, `${base}-${timestamp}`);
|
||||
try {
|
||||
await fs.access(destination);
|
||||
destination = pathRuntime.join(
|
||||
trashDir,
|
||||
`${base}-${timestamp}-${createTrashCollisionSuffix()}`,
|
||||
);
|
||||
} catch {
|
||||
// The initial destination is free to use.
|
||||
}
|
||||
await fs.rename(targetPath, destination);
|
||||
return destination;
|
||||
}
|
||||
};
|
||||
export {
|
||||
closeTrackedBrowserTabsForSessions,
|
||||
movePathToTrash,
|
||||
} from "../../extensions/browser/browser-maintenance.js";
|
||||
|
|
|
|||
|
|
@ -1,364 +1,15 @@
|
|||
import path from "node:path";
|
||||
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
|
||||
import { parseBrowserHttpUrl } from "./browser-cdp.js";
|
||||
import {
|
||||
DEFAULT_BROWSER_CONTROL_PORT,
|
||||
deriveDefaultBrowserCdpPortRange,
|
||||
deriveDefaultBrowserControlPort,
|
||||
isLoopbackHost,
|
||||
resolveGatewayPort,
|
||||
resolveUserPath,
|
||||
} from "./browser-config-support.js";
|
||||
import type {
|
||||
BrowserConfig,
|
||||
BrowserProfileConfig,
|
||||
OpenClawConfig,
|
||||
SsrFPolicy,
|
||||
} from "./browser-support.js";
|
||||
|
||||
export const DEFAULT_OPENCLAW_BROWSER_ENABLED = true;
|
||||
export const DEFAULT_BROWSER_EVALUATE_ENABLED = true;
|
||||
export const DEFAULT_OPENCLAW_BROWSER_COLOR = "#FF4500";
|
||||
export const DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME = "openclaw";
|
||||
export const DEFAULT_BROWSER_DEFAULT_PROFILE_NAME = "openclaw";
|
||||
export const DEFAULT_AI_SNAPSHOT_MAX_CHARS = 80_000;
|
||||
|
||||
const DEFAULT_BROWSER_CDP_PORT_RANGE_START = 18800;
|
||||
const DEFAULT_FALLBACK_BROWSER_TMP_DIR = "/tmp/openclaw";
|
||||
const DEFAULT_UPLOADS_DIR_NAME = "uploads";
|
||||
|
||||
export type ResolvedBrowserConfig = {
|
||||
enabled: boolean;
|
||||
evaluateEnabled: boolean;
|
||||
controlPort: number;
|
||||
cdpPortRangeStart: number;
|
||||
cdpPortRangeEnd: number;
|
||||
cdpProtocol: "http" | "https";
|
||||
cdpHost: string;
|
||||
cdpIsLoopback: boolean;
|
||||
remoteCdpTimeoutMs: number;
|
||||
remoteCdpHandshakeTimeoutMs: number;
|
||||
color: string;
|
||||
executablePath?: string;
|
||||
headless: boolean;
|
||||
noSandbox: boolean;
|
||||
attachOnly: boolean;
|
||||
defaultProfile: string;
|
||||
profiles: Record<string, BrowserProfileConfig>;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
extraArgs: string[];
|
||||
};
|
||||
|
||||
export type ResolvedBrowserProfile = {
|
||||
name: string;
|
||||
cdpPort: number;
|
||||
cdpUrl: string;
|
||||
cdpHost: string;
|
||||
cdpIsLoopback: boolean;
|
||||
userDataDir?: string;
|
||||
color: string;
|
||||
driver: "openclaw" | "existing-session";
|
||||
attachOnly: boolean;
|
||||
};
|
||||
|
||||
function canUseNodeFs(): boolean {
|
||||
const getBuiltinModule = (
|
||||
process as NodeJS.Process & {
|
||||
getBuiltinModule?: (id: string) => unknown;
|
||||
}
|
||||
).getBuiltinModule;
|
||||
if (typeof getBuiltinModule !== "function") {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return getBuiltinModule("fs") !== undefined;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_BROWSER_TMP_DIR = canUseNodeFs()
|
||||
? resolvePreferredOpenClawTmpDir()
|
||||
: DEFAULT_FALLBACK_BROWSER_TMP_DIR;
|
||||
|
||||
export const DEFAULT_UPLOAD_DIR = path.join(DEFAULT_BROWSER_TMP_DIR, DEFAULT_UPLOADS_DIR_NAME);
|
||||
|
||||
function normalizeHexColor(raw: string | undefined): string {
|
||||
const value = (raw ?? "").trim();
|
||||
if (!value) {
|
||||
return DEFAULT_OPENCLAW_BROWSER_COLOR;
|
||||
}
|
||||
const normalized = value.startsWith("#") ? value : `#${value}`;
|
||||
if (!/^#[0-9a-fA-F]{6}$/.test(normalized)) {
|
||||
return DEFAULT_OPENCLAW_BROWSER_COLOR;
|
||||
}
|
||||
return normalized.toUpperCase();
|
||||
}
|
||||
|
||||
function normalizeTimeoutMs(raw: number | undefined, fallback: number): number {
|
||||
const value = typeof raw === "number" && Number.isFinite(raw) ? Math.floor(raw) : fallback;
|
||||
return value < 0 ? fallback : value;
|
||||
}
|
||||
|
||||
function resolveCdpPortRangeStart(
|
||||
rawStart: number | undefined,
|
||||
fallbackStart: number,
|
||||
rangeSpan: number,
|
||||
): number {
|
||||
const start =
|
||||
typeof rawStart === "number" && Number.isFinite(rawStart)
|
||||
? Math.floor(rawStart)
|
||||
: fallbackStart;
|
||||
if (start < 1 || start > 65535) {
|
||||
throw new Error(`browser.cdpPortRangeStart must be between 1 and 65535, got: ${start}`);
|
||||
}
|
||||
const maxStart = 65535 - rangeSpan;
|
||||
if (start > maxStart) {
|
||||
throw new Error(
|
||||
`browser.cdpPortRangeStart (${start}) is too high for a ${rangeSpan + 1}-port range; max is ${maxStart}.`,
|
||||
);
|
||||
}
|
||||
return start;
|
||||
}
|
||||
|
||||
function normalizeStringList(raw: string[] | undefined): string[] | undefined {
|
||||
if (!Array.isArray(raw) || raw.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const values = raw
|
||||
.map((value) => value.trim())
|
||||
.filter((value): value is string => value.length > 0);
|
||||
return values.length > 0 ? values : undefined;
|
||||
}
|
||||
|
||||
function resolveBrowserSsrFPolicy(cfg: BrowserConfig | undefined): SsrFPolicy | undefined {
|
||||
const rawPolicy = cfg?.ssrfPolicy as
|
||||
| (BrowserConfig["ssrfPolicy"] & { allowPrivateNetwork?: boolean })
|
||||
| undefined;
|
||||
const allowPrivateNetwork = rawPolicy?.allowPrivateNetwork;
|
||||
const dangerouslyAllowPrivateNetwork = rawPolicy?.dangerouslyAllowPrivateNetwork;
|
||||
const allowedHostnames = normalizeStringList(rawPolicy?.allowedHostnames);
|
||||
const hostnameAllowlist = normalizeStringList(rawPolicy?.hostnameAllowlist);
|
||||
const hasExplicitPrivateSetting =
|
||||
allowPrivateNetwork !== undefined || dangerouslyAllowPrivateNetwork !== undefined;
|
||||
const resolvedAllowPrivateNetwork =
|
||||
dangerouslyAllowPrivateNetwork === true ||
|
||||
allowPrivateNetwork === true ||
|
||||
!hasExplicitPrivateSetting;
|
||||
|
||||
if (
|
||||
!resolvedAllowPrivateNetwork &&
|
||||
!hasExplicitPrivateSetting &&
|
||||
!allowedHostnames &&
|
||||
!hostnameAllowlist
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
...(resolvedAllowPrivateNetwork ? { dangerouslyAllowPrivateNetwork: true } : {}),
|
||||
...(allowedHostnames ? { allowedHostnames } : {}),
|
||||
...(hostnameAllowlist ? { hostnameAllowlist } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function ensureDefaultProfile(
|
||||
profiles: Record<string, BrowserProfileConfig> | undefined,
|
||||
defaultColor: string,
|
||||
legacyCdpPort?: number,
|
||||
derivedDefaultCdpPort?: number,
|
||||
legacyCdpUrl?: string,
|
||||
): Record<string, BrowserProfileConfig> {
|
||||
const result = { ...profiles };
|
||||
if (!result[DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME]) {
|
||||
result[DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME] = {
|
||||
cdpPort: legacyCdpPort ?? derivedDefaultCdpPort ?? DEFAULT_BROWSER_CDP_PORT_RANGE_START,
|
||||
color: defaultColor,
|
||||
...(legacyCdpUrl ? { cdpUrl: legacyCdpUrl } : {}),
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function ensureDefaultUserBrowserProfile(
|
||||
profiles: Record<string, BrowserProfileConfig>,
|
||||
): Record<string, BrowserProfileConfig> {
|
||||
const result = { ...profiles };
|
||||
if (result.user) {
|
||||
return result;
|
||||
}
|
||||
result.user = {
|
||||
driver: "existing-session",
|
||||
attachOnly: true,
|
||||
color: "#00AA00",
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
export function resolveBrowserConfig(
|
||||
cfg: BrowserConfig | undefined,
|
||||
rootConfig?: OpenClawConfig,
|
||||
): ResolvedBrowserConfig {
|
||||
const enabled = cfg?.enabled ?? DEFAULT_OPENCLAW_BROWSER_ENABLED;
|
||||
const evaluateEnabled = cfg?.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED;
|
||||
const gatewayPort = resolveGatewayPort(rootConfig);
|
||||
const controlPort = deriveDefaultBrowserControlPort(gatewayPort ?? DEFAULT_BROWSER_CONTROL_PORT);
|
||||
const defaultColor = normalizeHexColor(cfg?.color);
|
||||
const remoteCdpTimeoutMs = normalizeTimeoutMs(cfg?.remoteCdpTimeoutMs, 1500);
|
||||
const remoteCdpHandshakeTimeoutMs = normalizeTimeoutMs(
|
||||
cfg?.remoteCdpHandshakeTimeoutMs,
|
||||
Math.max(2000, remoteCdpTimeoutMs * 2),
|
||||
);
|
||||
|
||||
const derivedCdpRange = deriveDefaultBrowserCdpPortRange(controlPort);
|
||||
const cdpRangeSpan = derivedCdpRange.end - derivedCdpRange.start;
|
||||
const cdpPortRangeStart = resolveCdpPortRangeStart(
|
||||
cfg?.cdpPortRangeStart,
|
||||
derivedCdpRange.start,
|
||||
cdpRangeSpan,
|
||||
);
|
||||
const cdpPortRangeEnd = cdpPortRangeStart + cdpRangeSpan;
|
||||
|
||||
const rawCdpUrl = (cfg?.cdpUrl ?? "").trim();
|
||||
let cdpInfo:
|
||||
| {
|
||||
parsed: URL;
|
||||
port: number;
|
||||
normalized: string;
|
||||
}
|
||||
| undefined;
|
||||
if (rawCdpUrl) {
|
||||
cdpInfo = parseBrowserHttpUrl(rawCdpUrl, "browser.cdpUrl");
|
||||
} else {
|
||||
const derivedPort = controlPort + 1;
|
||||
if (derivedPort > 65535) {
|
||||
throw new Error(
|
||||
`Derived CDP port (${derivedPort}) is too high; check gateway port configuration.`,
|
||||
);
|
||||
}
|
||||
const derived = new URL(`http://127.0.0.1:${derivedPort}`);
|
||||
cdpInfo = {
|
||||
parsed: derived,
|
||||
port: derivedPort,
|
||||
normalized: derived.toString().replace(/\/$/, ""),
|
||||
};
|
||||
}
|
||||
|
||||
const headless = cfg?.headless === true;
|
||||
const noSandbox = cfg?.noSandbox === true;
|
||||
const attachOnly = cfg?.attachOnly === true;
|
||||
const executablePath = cfg?.executablePath?.trim() || undefined;
|
||||
const defaultProfileFromConfig = cfg?.defaultProfile?.trim() || undefined;
|
||||
|
||||
const legacyCdpPort = rawCdpUrl ? cdpInfo.port : undefined;
|
||||
const isWsUrl = cdpInfo.parsed.protocol === "ws:" || cdpInfo.parsed.protocol === "wss:";
|
||||
const legacyCdpUrl = rawCdpUrl && isWsUrl ? cdpInfo.normalized : undefined;
|
||||
const profiles = ensureDefaultUserBrowserProfile(
|
||||
ensureDefaultProfile(
|
||||
cfg?.profiles,
|
||||
defaultColor,
|
||||
legacyCdpPort,
|
||||
cdpPortRangeStart,
|
||||
legacyCdpUrl,
|
||||
),
|
||||
);
|
||||
const cdpProtocol = cdpInfo.parsed.protocol === "https:" ? "https" : "http";
|
||||
|
||||
const defaultProfile =
|
||||
defaultProfileFromConfig ??
|
||||
(profiles[DEFAULT_BROWSER_DEFAULT_PROFILE_NAME]
|
||||
? DEFAULT_BROWSER_DEFAULT_PROFILE_NAME
|
||||
: profiles[DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME]
|
||||
? DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME
|
||||
: "user");
|
||||
|
||||
const extraArgs = Array.isArray(cfg?.extraArgs)
|
||||
? cfg.extraArgs.filter(
|
||||
(value): value is string => typeof value === "string" && value.trim().length > 0,
|
||||
)
|
||||
: [];
|
||||
|
||||
return {
|
||||
enabled,
|
||||
evaluateEnabled,
|
||||
controlPort,
|
||||
cdpPortRangeStart,
|
||||
cdpPortRangeEnd,
|
||||
cdpProtocol,
|
||||
cdpHost: cdpInfo.parsed.hostname,
|
||||
cdpIsLoopback: isLoopbackHost(cdpInfo.parsed.hostname),
|
||||
remoteCdpTimeoutMs,
|
||||
remoteCdpHandshakeTimeoutMs,
|
||||
color: defaultColor,
|
||||
executablePath,
|
||||
headless,
|
||||
noSandbox,
|
||||
attachOnly,
|
||||
defaultProfile,
|
||||
profiles,
|
||||
ssrfPolicy: resolveBrowserSsrFPolicy(cfg),
|
||||
extraArgs,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveProfile(
|
||||
resolved: ResolvedBrowserConfig,
|
||||
profileName: string,
|
||||
): ResolvedBrowserProfile | null {
|
||||
const profile = resolved.profiles[profileName];
|
||||
if (!profile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawProfileUrl = profile.cdpUrl?.trim() ?? "";
|
||||
let cdpHost = resolved.cdpHost;
|
||||
let cdpPort = profile.cdpPort ?? 0;
|
||||
let cdpUrl = "";
|
||||
const driver = profile.driver === "existing-session" ? "existing-session" : "openclaw";
|
||||
|
||||
if (driver === "existing-session") {
|
||||
return {
|
||||
name: profileName,
|
||||
cdpPort: 0,
|
||||
cdpUrl: "",
|
||||
cdpHost: "",
|
||||
cdpIsLoopback: true,
|
||||
userDataDir: resolveUserPath(profile.userDataDir?.trim() || "") || undefined,
|
||||
color: profile.color,
|
||||
driver,
|
||||
attachOnly: true,
|
||||
};
|
||||
}
|
||||
|
||||
const hasStaleWsPath =
|
||||
rawProfileUrl !== "" &&
|
||||
cdpPort > 0 &&
|
||||
/^wss?:\/\//i.test(rawProfileUrl) &&
|
||||
/\/devtools\/browser\//i.test(rawProfileUrl);
|
||||
|
||||
if (hasStaleWsPath) {
|
||||
const parsed = new URL(rawProfileUrl);
|
||||
cdpHost = parsed.hostname;
|
||||
cdpUrl = `${resolved.cdpProtocol}://${cdpHost}:${cdpPort}`;
|
||||
} else if (rawProfileUrl) {
|
||||
const parsed = parseBrowserHttpUrl(rawProfileUrl, `browser.profiles.${profileName}.cdpUrl`);
|
||||
cdpHost = parsed.parsed.hostname;
|
||||
cdpPort = parsed.port;
|
||||
cdpUrl = parsed.normalized;
|
||||
} else if (cdpPort) {
|
||||
cdpUrl = `${resolved.cdpProtocol}://${resolved.cdpHost}:${cdpPort}`;
|
||||
} else {
|
||||
throw new Error(`Profile "${profileName}" must define cdpPort or cdpUrl.`);
|
||||
}
|
||||
|
||||
return {
|
||||
name: profileName,
|
||||
cdpPort,
|
||||
cdpUrl,
|
||||
cdpHost,
|
||||
cdpIsLoopback: isLoopbackHost(cdpHost),
|
||||
color: profile.color,
|
||||
driver,
|
||||
attachOnly: profile.attachOnly ?? resolved.attachOnly,
|
||||
};
|
||||
}
|
||||
export {
|
||||
DEFAULT_AI_SNAPSHOT_MAX_CHARS,
|
||||
DEFAULT_BROWSER_DEFAULT_PROFILE_NAME,
|
||||
DEFAULT_BROWSER_EVALUATE_ENABLED,
|
||||
DEFAULT_OPENCLAW_BROWSER_COLOR,
|
||||
DEFAULT_OPENCLAW_BROWSER_ENABLED,
|
||||
DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME,
|
||||
DEFAULT_UPLOAD_DIR,
|
||||
resolveBrowserConfig,
|
||||
resolveProfile,
|
||||
} from "../../extensions/browser/browser-profiles.js";
|
||||
export type {
|
||||
ResolvedBrowserConfig,
|
||||
ResolvedBrowserProfile,
|
||||
} from "../../extensions/browser/browser-profiles.js";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@ import type {
|
|||
OpenClawPluginCommandDefinition,
|
||||
OpenClawPluginConfigSchema,
|
||||
OpenClawPluginDefinition,
|
||||
OpenClawPluginNodeHostCommand,
|
||||
OpenClawPluginReloadRegistration,
|
||||
OpenClawPluginSecurityAuditCollector,
|
||||
OpenClawPluginSecurityAuditContext,
|
||||
OpenClawPluginService,
|
||||
OpenClawPluginServiceContext,
|
||||
OpenClawPluginToolContext,
|
||||
|
|
@ -71,6 +75,10 @@ export type {
|
|||
AnyAgentTool,
|
||||
MediaUnderstandingProviderPlugin,
|
||||
OpenClawPluginApi,
|
||||
OpenClawPluginNodeHostCommand,
|
||||
OpenClawPluginReloadRegistration,
|
||||
OpenClawPluginSecurityAuditCollector,
|
||||
OpenClawPluginSecurityAuditContext,
|
||||
OpenClawPluginToolContext,
|
||||
OpenClawPluginToolFactory,
|
||||
PluginCommandContext,
|
||||
|
|
@ -143,6 +151,9 @@ type DefinePluginEntryOptions = {
|
|||
description: string;
|
||||
kind?: OpenClawPluginDefinition["kind"];
|
||||
configSchema?: OpenClawPluginConfigSchema | (() => OpenClawPluginConfigSchema);
|
||||
reload?: OpenClawPluginDefinition["reload"];
|
||||
nodeHostCommands?: OpenClawPluginDefinition["nodeHostCommands"];
|
||||
securityAuditCollectors?: OpenClawPluginDefinition["securityAuditCollectors"];
|
||||
register: (api: OpenClawPluginApi) => void;
|
||||
};
|
||||
|
||||
|
|
@ -153,7 +164,10 @@ type DefinedPluginEntry = {
|
|||
description: string;
|
||||
configSchema: OpenClawPluginConfigSchema;
|
||||
register: NonNullable<OpenClawPluginDefinition["register"]>;
|
||||
} & Pick<OpenClawPluginDefinition, "kind">;
|
||||
} & Pick<
|
||||
OpenClawPluginDefinition,
|
||||
"kind" | "reload" | "nodeHostCommands" | "securityAuditCollectors"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Canonical entry helper for non-channel plugins.
|
||||
|
|
@ -168,6 +182,9 @@ export function definePluginEntry({
|
|||
description,
|
||||
kind,
|
||||
configSchema = emptyPluginConfigSchema,
|
||||
reload,
|
||||
nodeHostCommands,
|
||||
securityAuditCollectors,
|
||||
register,
|
||||
}: DefinePluginEntryOptions): DefinedPluginEntry {
|
||||
const getConfigSchema = createCachedLazyValueGetter(configSchema);
|
||||
|
|
@ -176,6 +193,9 @@ export function definePluginEntry({
|
|||
name,
|
||||
description,
|
||||
...(kind ? { kind } : {}),
|
||||
...(reload ? { reload } : {}),
|
||||
...(nodeHostCommands ? { nodeHostCommands } : {}),
|
||||
...(securityAuditCollectors ? { securityAuditCollectors } : {}),
|
||||
get configSchema() {
|
||||
return getConfigSchema();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1093,7 +1093,13 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
|||
},
|
||||
});
|
||||
|
||||
const { registry, createApi } = createPluginRegistry({
|
||||
const {
|
||||
registry,
|
||||
createApi,
|
||||
registerReload,
|
||||
registerNodeHostCommand,
|
||||
registerSecurityAuditCollector,
|
||||
} = createPluginRegistry({
|
||||
logger,
|
||||
runtime,
|
||||
coreGatewayHandlers: options.coreGatewayHandlers as Record<string, GatewayRequestHandler>,
|
||||
|
|
@ -1536,6 +1542,18 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
|||
}
|
||||
}
|
||||
|
||||
if (registrationMode === "full") {
|
||||
if (definition?.reload) {
|
||||
registerReload(record, definition.reload);
|
||||
}
|
||||
for (const nodeHostCommand of definition?.nodeHostCommands ?? []) {
|
||||
registerNodeHostCommand(record, nodeHostCommand);
|
||||
}
|
||||
for (const collector of definition?.securityAuditCollectors ?? []) {
|
||||
registerSecurityAuditCollector(record, collector);
|
||||
}
|
||||
}
|
||||
|
||||
if (validateOnly) {
|
||||
registry.plugins.push(record);
|
||||
seenIds.set(pluginId, candidate.origin);
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ export function createEmptyPluginRegistry(): PluginRegistry {
|
|||
gatewayMethodScopes: {},
|
||||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
reloads: [],
|
||||
nodeHostCommands: [],
|
||||
securityAuditCollectors: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
conversationBindingResolvedHandlers: [],
|
||||
|
|
|
|||
|
|
@ -9,6 +9,11 @@ import type {
|
|||
} from "../gateway/server-methods/types.js";
|
||||
import { registerInternalHook } from "../hooks/internal-hooks.js";
|
||||
import type { HookEntry } from "../hooks/types.js";
|
||||
import {
|
||||
NODE_EXEC_APPROVALS_COMMANDS,
|
||||
NODE_SYSTEM_NOTIFY_COMMAND,
|
||||
NODE_SYSTEM_RUN_COMMANDS,
|
||||
} from "../infra/node-commands.js";
|
||||
import { normalizePluginGatewayMethodScope } from "../shared/gateway-method-policy.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { buildPluginApi } from "./api-builder.js";
|
||||
|
|
@ -52,6 +57,9 @@ import type {
|
|||
OpenClawPluginHttpRouteHandler,
|
||||
OpenClawPluginHttpRouteParams,
|
||||
OpenClawPluginHookOptions,
|
||||
OpenClawPluginNodeHostCommand,
|
||||
OpenClawPluginReloadRegistration,
|
||||
OpenClawPluginSecurityAuditCollector,
|
||||
MediaUnderstandingProviderPlugin,
|
||||
ProviderPlugin,
|
||||
RealtimeVoiceProviderPlugin,
|
||||
|
|
@ -172,6 +180,30 @@ export type PluginServiceRegistration = {
|
|||
rootDir?: string;
|
||||
};
|
||||
|
||||
export type PluginReloadRegistration = {
|
||||
pluginId: string;
|
||||
pluginName?: string;
|
||||
registration: OpenClawPluginReloadRegistration;
|
||||
source: string;
|
||||
rootDir?: string;
|
||||
};
|
||||
|
||||
export type PluginNodeHostCommandRegistration = {
|
||||
pluginId: string;
|
||||
pluginName?: string;
|
||||
command: OpenClawPluginNodeHostCommand;
|
||||
source: string;
|
||||
rootDir?: string;
|
||||
};
|
||||
|
||||
export type PluginSecurityAuditCollectorRegistration = {
|
||||
pluginId: string;
|
||||
pluginName?: string;
|
||||
collector: OpenClawPluginSecurityAuditCollector;
|
||||
source: string;
|
||||
rootDir?: string;
|
||||
};
|
||||
|
||||
export type PluginCommandRegistration = {
|
||||
pluginId: string;
|
||||
pluginName?: string;
|
||||
|
|
@ -259,6 +291,9 @@ export type PluginRegistry = {
|
|||
gatewayMethodScopes?: Partial<Record<string, OperatorScope>>;
|
||||
httpRoutes: PluginHttpRouteRegistration[];
|
||||
cliRegistrars: PluginCliRegistration[];
|
||||
reloads?: PluginReloadRegistration[];
|
||||
nodeHostCommands?: PluginNodeHostCommandRegistration[];
|
||||
securityAuditCollectors?: PluginSecurityAuditCollectorRegistration[];
|
||||
services: PluginServiceRegistration[];
|
||||
commands: PluginCommandRegistration[];
|
||||
conversationBindingResolvedHandlers: PluginConversationBindingResolvedHandlerRegistration[];
|
||||
|
|
@ -824,6 +859,104 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
|||
});
|
||||
};
|
||||
|
||||
const reservedNodeHostCommands = new Set<string>([
|
||||
...NODE_SYSTEM_RUN_COMMANDS,
|
||||
...NODE_EXEC_APPROVALS_COMMANDS,
|
||||
NODE_SYSTEM_NOTIFY_COMMAND,
|
||||
]);
|
||||
|
||||
const registerReload = (record: PluginRecord, registration: OpenClawPluginReloadRegistration) => {
|
||||
const normalize = (values?: string[]) =>
|
||||
(values ?? []).map((value) => value.trim()).filter(Boolean);
|
||||
const normalized: OpenClawPluginReloadRegistration = {
|
||||
restartPrefixes: normalize(registration.restartPrefixes),
|
||||
hotPrefixes: normalize(registration.hotPrefixes),
|
||||
noopPrefixes: normalize(registration.noopPrefixes),
|
||||
};
|
||||
if (
|
||||
(normalized.restartPrefixes?.length ?? 0) === 0 &&
|
||||
(normalized.hotPrefixes?.length ?? 0) === 0 &&
|
||||
(normalized.noopPrefixes?.length ?? 0) === 0
|
||||
) {
|
||||
pushDiagnostic({
|
||||
level: "warn",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message: "reload registration missing prefixes",
|
||||
});
|
||||
return;
|
||||
}
|
||||
registry.reloads ??= [];
|
||||
registry.reloads.push({
|
||||
pluginId: record.id,
|
||||
pluginName: record.name,
|
||||
registration: normalized,
|
||||
source: record.source,
|
||||
rootDir: record.rootDir,
|
||||
});
|
||||
};
|
||||
|
||||
const registerNodeHostCommand = (
|
||||
record: PluginRecord,
|
||||
nodeCommand: OpenClawPluginNodeHostCommand,
|
||||
) => {
|
||||
const command = nodeCommand.command.trim();
|
||||
if (!command) {
|
||||
pushDiagnostic({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message: "node host command registration missing command",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (reservedNodeHostCommands.has(command)) {
|
||||
pushDiagnostic({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message: `node host command reserved by core: ${command}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
registry.nodeHostCommands ??= [];
|
||||
const existing = registry.nodeHostCommands.find((entry) => entry.command.command === command);
|
||||
if (existing) {
|
||||
pushDiagnostic({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message: `node host command already registered: ${command} (${existing.pluginId})`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
registry.nodeHostCommands.push({
|
||||
pluginId: record.id,
|
||||
pluginName: record.name,
|
||||
command: {
|
||||
...nodeCommand,
|
||||
command,
|
||||
cap: nodeCommand.cap?.trim() || undefined,
|
||||
},
|
||||
source: record.source,
|
||||
rootDir: record.rootDir,
|
||||
});
|
||||
};
|
||||
|
||||
const registerSecurityAuditCollector = (
|
||||
record: PluginRecord,
|
||||
collector: OpenClawPluginSecurityAuditCollector,
|
||||
) => {
|
||||
registry.securityAuditCollectors ??= [];
|
||||
registry.securityAuditCollectors.push({
|
||||
pluginId: record.id,
|
||||
pluginName: record.name,
|
||||
collector,
|
||||
source: record.source,
|
||||
rootDir: record.rootDir,
|
||||
});
|
||||
};
|
||||
|
||||
const registerService = (record: PluginRecord, service: OpenClawPluginService) => {
|
||||
const id = service.id.trim();
|
||||
if (!id) {
|
||||
|
|
@ -1051,6 +1184,10 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
|||
registerGatewayMethod: (method, handler, opts) =>
|
||||
registerGatewayMethod(record, method, handler, opts),
|
||||
registerService: (service) => registerService(record, service),
|
||||
registerReload: (registration) => registerReload(record, registration),
|
||||
registerNodeHostCommand: (command) => registerNodeHostCommand(record, command),
|
||||
registerSecurityAuditCollector: (collector) =>
|
||||
registerSecurityAuditCollector(record, collector),
|
||||
registerInteractiveHandler: (registration) => {
|
||||
const result = registerPluginInteractiveHandler(record.id, registration, {
|
||||
pluginName: record.name,
|
||||
|
|
@ -1247,6 +1384,9 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
|||
registerWebSearchProvider,
|
||||
registerGatewayMethod,
|
||||
registerCli,
|
||||
registerReload,
|
||||
registerNodeHostCommand,
|
||||
registerSecurityAuditCollector,
|
||||
registerService,
|
||||
registerCommand,
|
||||
registerHook,
|
||||
|
|
|
|||
|
|
@ -33,10 +33,17 @@ function activeRegistrySatisfiesScope(
|
|||
scope: PluginRegistryScope,
|
||||
active: ReturnType<typeof getActivePluginRegistry>,
|
||||
expectedChannelPluginIds: readonly string[],
|
||||
requestedPluginIds: readonly string[],
|
||||
): boolean {
|
||||
if (!active) {
|
||||
return false;
|
||||
}
|
||||
if (requestedPluginIds.length > 0) {
|
||||
const activePluginIds = new Set(
|
||||
active.plugins.filter((plugin) => plugin.status === "loaded").map((plugin) => plugin.id),
|
||||
);
|
||||
return requestedPluginIds.every((pluginId) => activePluginIds.has(pluginId));
|
||||
}
|
||||
const activeChannelPluginIds = new Set(active.channels.map((entry) => entry.plugin.id));
|
||||
switch (scope) {
|
||||
case "configured-channels":
|
||||
|
|
@ -55,9 +62,13 @@ export function ensurePluginRegistryLoaded(options?: {
|
|||
config?: OpenClawConfig;
|
||||
activationSourceConfig?: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
onlyPluginIds?: string[];
|
||||
}): void {
|
||||
const scope = options?.scope ?? "all";
|
||||
if (scopeRank(pluginRegistryLoaded) >= scopeRank(scope)) {
|
||||
const requestedPluginIds =
|
||||
options?.onlyPluginIds?.map((pluginId) => pluginId.trim()).filter(Boolean) ?? [];
|
||||
const scopedLoad = requestedPluginIds.length > 0;
|
||||
if (!scopedLoad && scopeRank(pluginRegistryLoaded) >= scopeRank(scope)) {
|
||||
return;
|
||||
}
|
||||
const env = options?.env ?? process.env;
|
||||
|
|
@ -68,8 +79,9 @@ export function ensurePluginRegistryLoaded(options?: {
|
|||
resolvedConfig,
|
||||
resolveDefaultAgentId(resolvedConfig),
|
||||
);
|
||||
const expectedChannelPluginIds =
|
||||
scope === "configured-channels"
|
||||
const expectedChannelPluginIds = scopedLoad
|
||||
? requestedPluginIds
|
||||
: scope === "configured-channels"
|
||||
? resolveConfiguredChannelPluginIds({
|
||||
config: resolvedConfig,
|
||||
workspaceDir,
|
||||
|
|
@ -84,10 +96,12 @@ export function ensurePluginRegistryLoaded(options?: {
|
|||
: [];
|
||||
const active = getActivePluginRegistry();
|
||||
if (
|
||||
pluginRegistryLoaded === "none" &&
|
||||
activeRegistrySatisfiesScope(scope, active, expectedChannelPluginIds)
|
||||
(pluginRegistryLoaded === "none" || scopedLoad) &&
|
||||
activeRegistrySatisfiesScope(scope, active, expectedChannelPluginIds, expectedChannelPluginIds)
|
||||
) {
|
||||
pluginRegistryLoaded = scope;
|
||||
if (!scopedLoad) {
|
||||
pluginRegistryLoaded = scope;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const logger: PluginLogger = {
|
||||
|
|
@ -103,11 +117,11 @@ export function ensurePluginRegistryLoaded(options?: {
|
|||
workspaceDir,
|
||||
logger,
|
||||
throwOnLoadError: true,
|
||||
...(scope === "configured-channels" || scope === "channels"
|
||||
? { onlyPluginIds: expectedChannelPluginIds }
|
||||
: {}),
|
||||
...(expectedChannelPluginIds.length > 0 ? { onlyPluginIds: expectedChannelPluginIds } : {}),
|
||||
});
|
||||
pluginRegistryLoaded = scope;
|
||||
if (!scopedLoad) {
|
||||
pluginRegistryLoaded = scope;
|
||||
}
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ import type {
|
|||
RuntimeWebFetchMetadata,
|
||||
RuntimeWebSearchMetadata,
|
||||
} from "../secrets/runtime-web-tools.types.js";
|
||||
import type { SecurityAuditFinding } from "../security/audit.js";
|
||||
import type {
|
||||
SpeechDirectiveTokenParseContext,
|
||||
SpeechDirectiveTokenParseResult,
|
||||
|
|
@ -1928,6 +1929,30 @@ export type OpenClawPluginCliCommandDescriptor = {
|
|||
hasSubcommands: boolean;
|
||||
};
|
||||
|
||||
export type OpenClawPluginReloadRegistration = {
|
||||
restartPrefixes?: string[];
|
||||
hotPrefixes?: string[];
|
||||
noopPrefixes?: string[];
|
||||
};
|
||||
|
||||
export type OpenClawPluginNodeHostCommand = {
|
||||
command: string;
|
||||
cap?: string;
|
||||
handle: (paramsJSON?: string | null) => Promise<string>;
|
||||
};
|
||||
|
||||
export type OpenClawPluginSecurityAuditContext = {
|
||||
config: OpenClawConfig;
|
||||
sourceConfig: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
stateDir: string;
|
||||
configPath: string;
|
||||
};
|
||||
|
||||
export type OpenClawPluginSecurityAuditCollector = (
|
||||
ctx: OpenClawPluginSecurityAuditContext,
|
||||
) => SecurityAuditFinding[] | Promise<SecurityAuditFinding[]>;
|
||||
|
||||
/** Context passed to long-lived plugin services. */
|
||||
export type OpenClawPluginServiceContext = {
|
||||
config: OpenClawConfig;
|
||||
|
|
@ -1955,6 +1980,9 @@ export type OpenClawPluginDefinition = {
|
|||
version?: string;
|
||||
kind?: PluginKind | PluginKind[];
|
||||
configSchema?: OpenClawPluginConfigSchema;
|
||||
reload?: OpenClawPluginReloadRegistration;
|
||||
nodeHostCommands?: OpenClawPluginNodeHostCommand[];
|
||||
securityAuditCollectors?: OpenClawPluginSecurityAuditCollector[];
|
||||
register?: (api: OpenClawPluginApi) => void | Promise<void>;
|
||||
activate?: (api: OpenClawPluginApi) => void | Promise<void>;
|
||||
};
|
||||
|
|
@ -2040,6 +2068,9 @@ export type OpenClawPluginApi = {
|
|||
descriptors?: OpenClawPluginCliCommandDescriptor[];
|
||||
},
|
||||
) => void;
|
||||
registerReload: (registration: OpenClawPluginReloadRegistration) => void;
|
||||
registerNodeHostCommand: (command: OpenClawPluginNodeHostCommand) => void;
|
||||
registerSecurityAuditCollector: (collector: OpenClawPluginSecurityAuditCollector) => void;
|
||||
registerService: (service: OpenClawPluginService) => void;
|
||||
/** Register a lightweight config migration that can run before plugin runtime loads. */
|
||||
registerConfigMigration: (migrate: PluginConfigMigration) => void;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type { listChannelPlugins } from "../channels/plugins/index.js";
|
|||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import type { ConfigFileSnapshot, OpenClawConfig } from "../config/config.js";
|
||||
import { resolveConfigPath, resolveStateDir } from "../config/paths.js";
|
||||
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
|
||||
import { hasConfiguredSecretInput } from "../config/types.secrets.js";
|
||||
import { resolveGatewayAuth } from "../gateway/auth.js";
|
||||
import { type ExecApprovalsFile, loadExecApprovals } from "../infra/exec-approvals.js";
|
||||
|
|
@ -16,10 +17,7 @@ import {
|
|||
} from "../infra/exec-safe-bin-runtime-policy.js";
|
||||
import { listRiskyConfiguredSafeBins } from "../infra/exec-safe-bin-semantics.js";
|
||||
import { normalizeTrustedSafeBinDirs } from "../infra/exec-safe-bin-trust.js";
|
||||
import { isBlockedHostnameOrIp, isPrivateNetworkAllowedByPolicy } from "../infra/net/ssrf.js";
|
||||
import { redactCdpUrl } from "../plugin-sdk/browser-cdp.js";
|
||||
import { resolveBrowserControlAuth } from "../plugin-sdk/browser-control-auth.js";
|
||||
import { resolveBrowserConfig, resolveProfile } from "../plugin-sdk/browser-profiles.js";
|
||||
import { getActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { DEFAULT_AGENT_ID } from "../routing/session-key.js";
|
||||
import {
|
||||
formatPermissionDetail,
|
||||
|
|
@ -123,6 +121,7 @@ let auditChannelModulePromise:
|
|||
let pluginRegistryLoaderModulePromise:
|
||||
| Promise<typeof import("../plugins/runtime/runtime-registry-loader.js")>
|
||||
| undefined;
|
||||
let pluginLoaderModulePromise: Promise<typeof import("../plugins/loader.js")> | undefined;
|
||||
let gatewayProbeDepsPromise:
|
||||
| Promise<{
|
||||
buildGatewayConnectionDetails: typeof import("../gateway/call.js").buildGatewayConnectionDetails;
|
||||
|
|
@ -156,6 +155,11 @@ async function loadPluginRegistryLoaderModule() {
|
|||
return await pluginRegistryLoaderModulePromise;
|
||||
}
|
||||
|
||||
async function loadPluginLoaderModule() {
|
||||
pluginLoaderModulePromise ??= import("../plugins/loader.js");
|
||||
return await pluginLoaderModulePromise;
|
||||
}
|
||||
|
||||
async function loadGatewayProbeDeps() {
|
||||
gatewayProbeDepsPromise ??= Promise.all([
|
||||
import("../gateway/call.js"),
|
||||
|
|
@ -699,99 +703,78 @@ function isStrictLoopbackTrustedProxyEntry(entry: string): boolean {
|
|||
return false;
|
||||
}
|
||||
|
||||
function collectBrowserControlFindings(
|
||||
cfg: OpenClawConfig,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): SecurityAuditFinding[] {
|
||||
const findings: SecurityAuditFinding[] = [];
|
||||
|
||||
let resolved: ReturnType<typeof resolveBrowserConfig>;
|
||||
try {
|
||||
resolved = resolveBrowserConfig(cfg.browser, cfg);
|
||||
} catch (err) {
|
||||
findings.push({
|
||||
checkId: "browser.control_invalid_config",
|
||||
severity: "warn",
|
||||
title: "Browser control config looks invalid",
|
||||
detail: String(err),
|
||||
remediation: `Fix browser.cdpUrl in ${resolveConfigPath()} and re-run "${formatCliCommand("openclaw security audit --deep")}".`,
|
||||
async function collectPluginSecurityAuditFindings(
|
||||
context: AuditExecutionContext,
|
||||
): Promise<SecurityAuditFinding[]> {
|
||||
let collectors = getActivePluginRegistry()?.securityAuditCollectors ?? [];
|
||||
if (collectors.length === 0) {
|
||||
const autoEnabled = applyPluginAutoEnable({
|
||||
config: context.sourceConfig,
|
||||
env: context.env,
|
||||
});
|
||||
return findings;
|
||||
}
|
||||
|
||||
if (!resolved.enabled) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
const browserAuth = resolveBrowserControlAuth(cfg, env);
|
||||
const explicitAuthMode = cfg.gateway?.auth?.mode;
|
||||
const tokenConfigured =
|
||||
Boolean(browserAuth.token) ||
|
||||
hasNonEmptyString(env.OPENCLAW_GATEWAY_TOKEN) ||
|
||||
hasConfiguredSecretInput(cfg.gateway?.auth?.token, cfg.secrets?.defaults);
|
||||
const passwordCanWin =
|
||||
explicitAuthMode === "password" ||
|
||||
(explicitAuthMode !== "token" &&
|
||||
explicitAuthMode !== "none" &&
|
||||
explicitAuthMode !== "trusted-proxy" &&
|
||||
!tokenConfigured);
|
||||
const passwordConfigured =
|
||||
Boolean(browserAuth.password) ||
|
||||
(passwordCanWin &&
|
||||
(hasNonEmptyString(env.OPENCLAW_GATEWAY_PASSWORD) ||
|
||||
hasConfiguredSecretInput(cfg.gateway?.auth?.password, cfg.secrets?.defaults)));
|
||||
if (!tokenConfigured && !passwordConfigured) {
|
||||
findings.push({
|
||||
checkId: "browser.control_no_auth",
|
||||
severity: "critical",
|
||||
title: "Browser control has no auth",
|
||||
detail:
|
||||
"Browser control HTTP routes are enabled but no gateway.auth token/password is configured. " +
|
||||
"Any local process (or SSRF to loopback) can call browser control endpoints.",
|
||||
remediation:
|
||||
"Set gateway.auth.token (recommended) or gateway.auth.password so browser control HTTP routes require authentication. Restarting the gateway will auto-generate gateway.auth.token when browser control is enabled.",
|
||||
const requestedPluginIds = new Set<string>();
|
||||
for (const pluginId of Object.keys(autoEnabled.autoEnabledReasons)) {
|
||||
const normalized = pluginId.trim();
|
||||
if (normalized) {
|
||||
requestedPluginIds.add(normalized);
|
||||
}
|
||||
}
|
||||
for (const pluginId of autoEnabled.config.plugins?.allow ?? []) {
|
||||
if (typeof pluginId !== "string") {
|
||||
continue;
|
||||
}
|
||||
const normalized = pluginId.trim();
|
||||
if (normalized) {
|
||||
requestedPluginIds.add(normalized);
|
||||
}
|
||||
}
|
||||
for (const [pluginId, entry] of Object.entries(autoEnabled.config.plugins?.entries ?? {})) {
|
||||
if (entry?.enabled === false) {
|
||||
continue;
|
||||
}
|
||||
const normalized = pluginId.trim();
|
||||
if (normalized) {
|
||||
requestedPluginIds.add(normalized);
|
||||
}
|
||||
}
|
||||
if (requestedPluginIds.size === 0) {
|
||||
return [];
|
||||
}
|
||||
const snapshot = (await loadPluginLoaderModule()).loadOpenClawPlugins({
|
||||
config: autoEnabled.config,
|
||||
activationSourceConfig: context.sourceConfig,
|
||||
autoEnabledReasons: autoEnabled.autoEnabledReasons,
|
||||
env: context.env,
|
||||
activate: false,
|
||||
cache: false,
|
||||
mode: "validate",
|
||||
onlyPluginIds: [...requestedPluginIds],
|
||||
});
|
||||
collectors = snapshot.securityAuditCollectors ?? [];
|
||||
}
|
||||
|
||||
for (const name of Object.keys(resolved.profiles)) {
|
||||
const profile = resolveProfile(resolved, name);
|
||||
if (!profile || profile.cdpIsLoopback) {
|
||||
continue;
|
||||
}
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(profile.cdpUrl);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const redactedCdpUrl = redactCdpUrl(profile.cdpUrl) ?? profile.cdpUrl;
|
||||
if (url.protocol === "http:") {
|
||||
findings.push({
|
||||
checkId: "browser.remote_cdp_http",
|
||||
severity: "warn",
|
||||
title: "Remote CDP uses HTTP",
|
||||
detail: `browser profile "${name}" uses http CDP (${redactedCdpUrl}); this is OK only if it's tailnet-only or behind an encrypted tunnel.`,
|
||||
remediation: `Prefer HTTPS/TLS or a tailnet-only endpoint for remote CDP.`,
|
||||
});
|
||||
}
|
||||
if (
|
||||
isPrivateNetworkAllowedByPolicy(resolved.ssrfPolicy) &&
|
||||
isBlockedHostnameOrIp(url.hostname)
|
||||
) {
|
||||
findings.push({
|
||||
checkId: "browser.remote_cdp_private_host",
|
||||
severity: "warn",
|
||||
title: "Remote CDP targets a private/internal host",
|
||||
detail:
|
||||
`browser profile "${name}" points at a private/internal CDP host (${redactedCdpUrl}). ` +
|
||||
"This is expected for LAN/tailnet/WSL-style setups, but treat it as a trusted-network endpoint.",
|
||||
remediation:
|
||||
"Prefer a tailnet or tunnel for remote CDP. If you want strict blocking, set browser.ssrfPolicy.dangerouslyAllowPrivateNetwork=false and allow only explicit hosts.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
const collectorResults = await Promise.all(
|
||||
collectors.map(async (entry) => {
|
||||
try {
|
||||
return await entry.collector({
|
||||
config: context.cfg,
|
||||
sourceConfig: context.sourceConfig,
|
||||
env: context.env,
|
||||
stateDir: context.stateDir,
|
||||
configPath: context.configPath,
|
||||
});
|
||||
} catch (err) {
|
||||
return [
|
||||
{
|
||||
checkId: `plugins.${entry.pluginId}.security_audit_failed`,
|
||||
severity: "warn" as const,
|
||||
title: "Plugin security audit collector failed",
|
||||
detail: `${entry.pluginId}: ${String(err)}`,
|
||||
},
|
||||
];
|
||||
}
|
||||
}),
|
||||
);
|
||||
return collectorResults.flat();
|
||||
}
|
||||
|
||||
function collectLoggingFindings(cfg: OpenClawConfig): SecurityAuditFinding[] {
|
||||
|
|
@ -1335,7 +1318,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise<Secu
|
|||
findings.push(...auditNonDeep.collectSyncedFolderFindings({ stateDir, configPath }));
|
||||
|
||||
findings.push(...collectGatewayConfigFindings(cfg, context.sourceConfig, env));
|
||||
findings.push(...collectBrowserControlFindings(cfg, env));
|
||||
findings.push(...(await collectPluginSecurityAuditFindings(context)));
|
||||
findings.push(...collectLoggingFindings(cfg));
|
||||
findings.push(...collectElevatedFindings(cfg));
|
||||
findings.push(...collectExecRuntimeFindings(cfg));
|
||||
|
|
|
|||
|
|
@ -36,8 +36,12 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl
|
|||
webSearchProviders: [],
|
||||
memoryEmbeddingProviders: [],
|
||||
gatewayHandlers: {},
|
||||
gatewayMethodScopes: {},
|
||||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
reloads: [],
|
||||
nodeHostCommands: [],
|
||||
securityAuditCollectors: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
conversationBindingResolvedHandlers: [],
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ export function createTestPluginApi(api: TestPluginApiInput): OpenClawPluginApi
|
|||
registerGatewayMethod() {},
|
||||
registerCli() {},
|
||||
registerService() {},
|
||||
registerReload() {},
|
||||
registerNodeHostCommand() {},
|
||||
registerSecurityAuditCollector() {},
|
||||
registerConfigMigration() {},
|
||||
registerAutoEnableProbe() {},
|
||||
registerProvider() {},
|
||||
|
|
|
|||
|
|
@ -81,9 +81,14 @@ function createTestRegistryForSetup(
|
|||
videoGenerationProviders: [],
|
||||
webFetchProviders: [],
|
||||
webSearchProviders: [],
|
||||
memoryEmbeddingProviders: [],
|
||||
gatewayHandlers: {},
|
||||
gatewayMethodScopes: {},
|
||||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
reloads: [],
|
||||
nodeHostCommands: [],
|
||||
securityAuditCollectors: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
conversationBindingResolvedHandlers: [],
|
||||
|
|
|
|||
Loading…
Reference in New Issue