mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-22 14:41:34 +00:00
refactor: move browser runtime seams behind plugin metadata
This commit is contained in:
@@ -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();
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user