From f374fff3bd393130327360eea67e57f653c45b22 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 9 Apr 2026 08:45:26 +0100 Subject: [PATCH] fix(browser): move browser sdk helper seams into core --- src/commands/doctor-browser.ts | 147 ++++++++- src/plugin-sdk/browser-cdp.ts | 47 ++- src/plugin-sdk/browser-control-auth.ts | 187 ++++++++++- src/plugin-sdk/browser-host-inspection.ts | 140 ++++++++- src/plugin-sdk/browser-maintenance.test.ts | 40 ++- src/plugin-sdk/browser-maintenance.ts | 65 +++- src/plugin-sdk/browser-profiles.ts | 343 ++++++++++++++++++++- 7 files changed, 924 insertions(+), 45 deletions(-) diff --git a/src/commands/doctor-browser.ts b/src/commands/doctor-browser.ts index 88ffaf4f0cd..ec724975a82 100644 --- a/src/commands/doctor-browser.ts +++ b/src/commands/doctor-browser.ts @@ -1 +1,146 @@ -export { noteChromeMcpBrowserReadiness } from "../../extensions/browser/browser-doctor.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { + parseBrowserMajorVersion, + readBrowserVersion, + resolveGoogleChromeExecutableForPlatform, +} from "../plugin-sdk/browser-host-inspection.js"; +import { asNullableRecord } from "../shared/record-coerce.js"; +import { normalizeOptionalString } from "../shared/string-coerce.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(", "); + +type ExistingSessionProfile = { + name: string; + userDataDir?: string; +}; + +function collectChromeMcpProfiles(cfg: OpenClawConfig): ExistingSessionProfile[] { + const browser = asNullableRecord(cfg.browser); + if (!browser) { + return []; + } + + const profiles = new Map(); + const defaultProfile = normalizeOptionalString(browser.defaultProfile) ?? ""; + if (defaultProfile === "user") { + profiles.set("user", { name: "user" }); + } + + const configuredProfiles = asNullableRecord(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 = asNullableRecord(rawProfile); + const driver = normalizeOptionalString(profile?.driver) ?? ""; + if (driver === "existing-session") { + profiles.set(profileName, { + name: profileName, + userDataDir: normalizeOptionalString(profile?.userDataDir), + }); + } + } + + return [...profiles.values()].toSorted((a, b) => a.name.localeCompare(b.name)); +} + +export async function noteChromeMcpBrowserReadiness( + cfg: OpenClawConfig, + deps?: { + platform?: NodeJS.Platform; + noteFn?: typeof note; + resolveChromeExecutable?: (platform: NodeJS.Platform) => { path: string } | null; + readVersion?: (executablePath: string) => string | null; + }, +) { + const profiles = collectChromeMcpProfiles(cfg); + if (profiles.length === 0) { + return; + } + + const noteFn = deps?.noteFn ?? note; + const platform = deps?.platform ?? process.platform; + const resolveChromeExecutable = + deps?.resolveChromeExecutable ?? resolveGoogleChromeExecutableForPlatform; + const readVersion = deps?.readVersion ?? readBrowserVersion; + const explicitProfiles = profiles.filter((profile) => profile.userDataDir); + const autoConnectProfiles = profiles.filter((profile) => !profile.userDataDir); + const profileLabel = profiles.map((profile) => profile.name).join(", "); + + if (autoConnectProfiles.length === 0) { + noteFn( + [ + `- Chrome MCP existing-session is configured for profile(s): ${profileLabel}.`, + "- These profiles use an explicit Chromium user data directory instead of Chrome's default auto-connect path.", + `- Verify the matching Chromium-based browser is version ${CHROME_MCP_MIN_MAJOR}+ on the same host as the Gateway or node.`, + `- Enable remote debugging in that browser's inspect page (${REMOTE_DEBUGGING_PAGES}).`, + "- Keep the browser running and accept the attach consent prompt the first time OpenClaw connects.", + ].join("\n"), + "Browser", + ); + return; + } + + const chrome = resolveChromeExecutable(platform); + const autoProfileLabel = autoConnectProfiles.map((profile) => profile.name).join(", "); + + if (!chrome) { + const lines = [ + `- Chrome MCP existing-session is configured for profile(s): ${profileLabel}.`, + `- Google Chrome was not found on this host for auto-connect profile(s): ${autoProfileLabel}. OpenClaw does not bundle Chrome.`, + `- Install Google Chrome ${CHROME_MCP_MIN_MAJOR}+ on the same host as the Gateway or node, or set browser.profiles..userDataDir for a different Chromium-based browser.`, + `- Enable remote debugging in the browser inspect page (${REMOTE_DEBUGGING_PAGES}).`, + "- Keep the browser running and accept the attach consent prompt the first time OpenClaw connects.", + "- Docker, headless, and sandbox browser flows stay on raw CDP; this check only applies to host-local Chrome MCP attach.", + ]; + if (explicitProfiles.length > 0) { + lines.push( + `- Profiles with explicit userDataDir skip Chrome auto-detection: ${explicitProfiles + .map((profile) => profile.name) + .join(", ")}.`, + ); + } + noteFn(lines.join("\n"), "Browser"); + return; + } + + const versionRaw = readVersion(chrome.path); + const major = parseBrowserMajorVersion(versionRaw); + const lines = [ + `- Chrome MCP existing-session is configured for profile(s): ${profileLabel}.`, + `- Chrome path: ${chrome.path}`, + ]; + + if (!versionRaw || major === null) { + lines.push( + `- Could not determine the installed Chrome version. Chrome MCP requires Google Chrome ${CHROME_MCP_MIN_MAJOR}+ on this host.`, + ); + } else if (major < CHROME_MCP_MIN_MAJOR) { + lines.push( + `- Detected Chrome ${versionRaw}, which is too old for Chrome MCP existing-session attach. Upgrade to Chrome ${CHROME_MCP_MIN_MAJOR}+.`, + ); + } else { + lines.push(`- Detected Chrome ${versionRaw}.`); + } + + lines.push(`- Enable remote debugging in the browser inspect page (${REMOTE_DEBUGGING_PAGES}).`); + lines.push( + "- Keep the browser running and accept the attach consent prompt the first time OpenClaw connects.", + ); + if (explicitProfiles.length > 0) { + lines.push( + `- Profiles with explicit userDataDir still need manual validation of the matching Chromium-based browser: ${explicitProfiles + .map((profile) => profile.name) + .join(", ")}.`, + ); + } + + noteFn(lines.join("\n"), "Browser"); +} diff --git a/src/plugin-sdk/browser-cdp.ts b/src/plugin-sdk/browser-cdp.ts index 42432e3335f..746c5bd88bf 100644 --- a/src/plugin-sdk/browser-cdp.ts +++ b/src/plugin-sdk/browser-cdp.ts @@ -1 +1,46 @@ -export { parseBrowserHttpUrl, redactCdpUrl } from "../../extensions/browser/browser-cdp.js"; +import { redactSensitiveText } from "../logging/redact.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 > 65_535) { + 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); + } +} diff --git a/src/plugin-sdk/browser-control-auth.ts b/src/plugin-sdk/browser-control-auth.ts index 66f7b9e2b1c..276a55f6817 100644 --- a/src/plugin-sdk/browser-control-auth.ts +++ b/src/plugin-sdk/browser-control-auth.ts @@ -1,5 +1,182 @@ -export type { BrowserControlAuth } from "../../extensions/browser/browser-control-auth.js"; -export { - ensureBrowserControlAuth, - resolveBrowserControlAuth, -} from "../../extensions/browser/browser-control-auth.js"; +import crypto from "node:crypto"; +import type { OpenClawConfig } from "../config/config.js"; +import { loadConfig, writeConfigFile } from "../config/config.js"; +import { resolveGatewayAuth } from "../gateway/auth.js"; +import { ensureGatewayStartupAuth } from "../gateway/startup-auth.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "../shared/string-coerce.js"; + +export type BrowserControlAuth = { + token?: string; + password?: string; +}; + +export function resolveBrowserControlAuth( + cfg?: OpenClawConfig, + env: NodeJS.ProcessEnv = process.env, +): BrowserControlAuth { + const auth = resolveGatewayAuth({ + authConfig: cfg?.gateway?.auth, + env, + tailscaleMode: cfg?.gateway?.tailscale?.mode, + }); + const token = normalizeOptionalString(auth.token) ?? ""; + const password = normalizeOptionalString(auth.password) ?? ""; + return { + token: token || undefined, + password: password || undefined, + }; +} + +export function shouldAutoGenerateBrowserAuth(env: NodeJS.ProcessEnv): boolean { + const nodeEnv = normalizeLowercaseStringOrEmpty(env.NODE_ENV); + if (nodeEnv === "test") { + return false; + } + const vitest = normalizeLowercaseStringOrEmpty(env.VITEST); + if (vitest && vitest !== "0" && vitest !== "false" && vitest !== "off") { + return false; + } + return true; +} + +function hasExplicitNonStringGatewayCredentialForMode(params: { + cfg?: OpenClawConfig; + mode: "none" | "trusted-proxy"; +}): boolean { + const { cfg, mode } = params; + const auth = cfg?.gateway?.auth; + if (!auth) { + return false; + } + if (mode === "none") { + return auth.token != null && typeof auth.token !== "string"; + } + return auth.password != null && typeof auth.password !== "string"; +} + +function generateBrowserControlToken(): string { + return crypto.randomBytes(24).toString("hex"); +} + +async function generateAndPersistBrowserControlToken(params: { + cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; +}): Promise<{ + auth: BrowserControlAuth; + generatedToken?: string; +}> { + const token = generateBrowserControlToken(); + const nextCfg: OpenClawConfig = { + ...params.cfg, + gateway: { + ...params.cfg.gateway, + auth: { + ...params.cfg.gateway?.auth, + token, + }, + }, + }; + await writeConfigFile(nextCfg); + + const persistedAuth = resolveBrowserControlAuth(loadConfig(), params.env); + if (persistedAuth.token || persistedAuth.password) { + return { + auth: persistedAuth, + generatedToken: persistedAuth.token === token ? token : undefined, + }; + } + + return { auth: { token }, generatedToken: token }; +} + +async function generateAndPersistBrowserControlPassword(params: { + cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; +}): Promise<{ + auth: BrowserControlAuth; + generatedToken?: string; +}> { + const password = generateBrowserControlToken(); + const nextCfg: OpenClawConfig = { + ...params.cfg, + gateway: { + ...params.cfg.gateway, + auth: { + ...params.cfg.gateway?.auth, + password, + }, + }, + }; + await writeConfigFile(nextCfg); + + const persistedAuth = resolveBrowserControlAuth(loadConfig(), params.env); + if (persistedAuth.token || persistedAuth.password) { + return { + auth: persistedAuth, + generatedToken: persistedAuth.password === password ? password : undefined, + }; + } + + return { auth: { password }, generatedToken: password }; +} + +export async function ensureBrowserControlAuth(params: { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; +}): Promise<{ + auth: BrowserControlAuth; + generatedToken?: string; +}> { + const env = params.env ?? process.env; + const auth = resolveBrowserControlAuth(params.cfg, env); + if (auth.token || auth.password) { + return { auth }; + } + if (!shouldAutoGenerateBrowserAuth(env)) { + return { auth }; + } + + if (params.cfg.gateway?.auth?.mode === "password") { + return { auth }; + } + + const latestCfg = loadConfig(); + const latestAuth = resolveBrowserControlAuth(latestCfg, env); + if (latestAuth.token || latestAuth.password) { + return { auth: latestAuth }; + } + if (latestCfg.gateway?.auth?.mode === "password") { + return { auth: latestAuth }; + } + const latestMode = latestCfg.gateway?.auth?.mode; + if (latestMode === "none" || latestMode === "trusted-proxy") { + if ( + hasExplicitNonStringGatewayCredentialForMode({ + cfg: latestCfg, + mode: latestMode, + }) + ) { + return { auth: latestAuth }; + } + if (latestMode === "trusted-proxy") { + return await generateAndPersistBrowserControlPassword({ cfg: latestCfg, env }); + } + return await generateAndPersistBrowserControlToken({ cfg: latestCfg, env }); + } + + const ensured = await ensureGatewayStartupAuth({ + cfg: latestCfg, + env, + persist: true, + }); + return { + auth: { + token: ensured.auth.token, + password: ensured.auth.password, + }, + generatedToken: ensured.generatedToken, + }; +} diff --git a/src/plugin-sdk/browser-host-inspection.ts b/src/plugin-sdk/browser-host-inspection.ts index ea358d80a62..7183314b2ad 100644 --- a/src/plugin-sdk/browser-host-inspection.ts +++ b/src/plugin-sdk/browser-host-inspection.ts @@ -1,6 +1,134 @@ -export type { BrowserExecutable } from "../../extensions/browser/browser-host-inspection.js"; -export { - parseBrowserMajorVersion, - readBrowserVersion, - resolveGoogleChromeExecutableForPlatform, -} from "../../extensions/browser/browser-host-inspection.js"; +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "../shared/string-coerce.js"; + +export type BrowserExecutable = { + kind: "brave" | "canary" | "chromium" | "chrome" | "custom" | "edge"; + 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 normalizeOptionalString(output) ?? null; + } catch { + return null; + } +} + +function findFirstChromeExecutable(candidates: string[]): BrowserExecutable | null { + for (const candidate of candidates) { + if (exists(candidate)) { + const normalizedPath = normalizeLowercaseStringOrEmpty(candidate); + 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; +} diff --git a/src/plugin-sdk/browser-maintenance.test.ts b/src/plugin-sdk/browser-maintenance.test.ts index 131ac2489e2..86e63d9d839 100644 --- a/src/plugin-sdk/browser-maintenance.test.ts +++ b/src/plugin-sdk/browser-maintenance.test.ts @@ -1,27 +1,32 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const closeTrackedBrowserTabsForSessionsImpl = vi.hoisted(() => vi.fn()); -const movePathToTrashImpl = vi.hoisted(() => vi.fn()); +const loadBundledPluginPublicSurfaceModuleSync = vi.hoisted(() => vi.fn()); +const runExec = vi.hoisted(() => vi.fn()); -vi.mock("../../extensions/browser/browser-maintenance.js", () => ({ - closeTrackedBrowserTabsForSessions: closeTrackedBrowserTabsForSessionsImpl, - movePathToTrash: movePathToTrashImpl, +vi.mock("./facade-loader.js", () => ({ + loadBundledPluginPublicSurfaceModuleSync, +})); + +vi.mock("../process/exec.js", () => ({ + runExec, })); describe("browser maintenance", () => { beforeEach(() => { closeTrackedBrowserTabsForSessionsImpl.mockReset(); - movePathToTrashImpl.mockReset(); + loadBundledPluginPublicSurfaceModuleSync.mockReset(); + runExec.mockReset(); + loadBundledPluginPublicSurfaceModuleSync.mockReturnValue({ + closeTrackedBrowserTabsForSessions: closeTrackedBrowserTabsForSessionsImpl, + }); }); 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(closeTrackedBrowserTabsForSessionsImpl).toHaveBeenCalledWith({ sessionKeys: [] }); - expect(movePathToTrashImpl).not.toHaveBeenCalled(); + expect(loadBundledPluginPublicSurfaceModuleSync).not.toHaveBeenCalled(); }); it("delegates cleanup through the browser maintenance surface", async () => { @@ -32,17 +37,26 @@ describe("browser maintenance", () => { await expect( closeTrackedBrowserTabsForSessions({ sessionKeys: ["agent:main:test"] }), ).resolves.toBe(2); + expect(loadBundledPluginPublicSurfaceModuleSync).toHaveBeenCalledWith({ + dirName: "browser", + artifactBasename: "browser-maintenance.js", + }); expect(closeTrackedBrowserTabsForSessionsImpl).toHaveBeenCalledWith({ sessionKeys: ["agent:main:test"], }); }); - it("delegates move-to-trash through the browser maintenance surface", async () => { - movePathToTrashImpl.mockImplementation(async (targetPath: string) => `${targetPath}.trashed`); + it("uses the local trash command before falling back", async () => { + runExec.mockResolvedValue({ + stdout: "", + stderr: "", + code: 0, + signal: null, + }); const { movePathToTrash } = await import("./browser-maintenance.js"); - await expect(movePathToTrash("/tmp/demo")).resolves.toBe("/tmp/demo.trashed"); - expect(movePathToTrashImpl).toHaveBeenCalledWith("/tmp/demo"); + await expect(movePathToTrash("/tmp/demo")).resolves.toBe("/tmp/demo"); + expect(runExec).toHaveBeenCalledWith("trash", ["/tmp/demo"], { timeoutMs: 10_000 }); }); }); diff --git a/src/plugin-sdk/browser-maintenance.ts b/src/plugin-sdk/browser-maintenance.ts index 4a2d7d339a0..63acfc3c6d4 100644 --- a/src/plugin-sdk/browser-maintenance.ts +++ b/src/plugin-sdk/browser-maintenance.ts @@ -1,4 +1,61 @@ -export { - closeTrackedBrowserTabsForSessions, - movePathToTrash, -} from "../../extensions/browser/browser-maintenance.js"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { generateSecureToken } from "../infra/secure-random.js"; +import { runExec } from "../process/exec.js"; +import { loadBundledPluginPublicSurfaceModuleSync } from "./facade-loader.js"; + +type CloseTrackedBrowserTabsParams = { + sessionKeys: Array; + closeTab?: (tab: { targetId: string; baseUrl?: string; profile?: string }) => Promise; + onWarn?: (message: string) => void; +}; + +type BrowserMaintenanceSurface = { + closeTrackedBrowserTabsForSessions: (params: CloseTrackedBrowserTabsParams) => Promise; +}; + +function hasRequestedSessionKeys(sessionKeys: Array): boolean { + return sessionKeys.some((key) => Boolean(key?.trim())); +} + +function loadBrowserMaintenanceSurface(): BrowserMaintenanceSurface { + return loadBundledPluginPublicSurfaceModuleSync({ + dirName: "browser", + artifactBasename: "browser-maintenance.js", + }); +} + +export async function closeTrackedBrowserTabsForSessions( + params: CloseTrackedBrowserTabsParams, +): Promise { + if (!hasRequestedSessionKeys(params.sessionKeys)) { + return 0; + } + + let surface: BrowserMaintenanceSurface; + try { + surface = loadBrowserMaintenanceSurface(); + } catch (error) { + params.onWarn?.(`browser cleanup unavailable: ${String(error)}`); + return 0; + } + return await surface.closeTrackedBrowserTabsForSessions(params); +} + +export async function movePathToTrash(targetPath: string): Promise { + try { + await runExec("trash", [targetPath], { timeoutMs: 10_000 }); + return targetPath; + } catch { + const trashDir = path.join(os.homedir(), ".Trash"); + fs.mkdirSync(trashDir, { recursive: true }); + const base = path.basename(targetPath); + let dest = path.join(trashDir, `${base}-${Date.now()}`); + if (fs.existsSync(dest)) { + dest = path.join(trashDir, `${base}-${Date.now()}-${generateSecureToken(6)}`); + } + fs.renameSync(targetPath, dest); + return dest; + } +} diff --git a/src/plugin-sdk/browser-profiles.ts b/src/plugin-sdk/browser-profiles.ts index b2d04470262..9d841bc94da 100644 --- a/src/plugin-sdk/browser-profiles.ts +++ b/src/plugin-sdk/browser-profiles.ts @@ -1,15 +1,328 @@ -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"; +import path from "node:path"; +import type { BrowserConfig, BrowserProfileConfig, OpenClawConfig } from "../config/config.js"; +import { resolveGatewayPort } from "../config/config.js"; +import { + DEFAULT_BROWSER_CDP_PORT_RANGE_START, + DEFAULT_BROWSER_CONTROL_PORT, + deriveDefaultBrowserCdpPortRange, + deriveDefaultBrowserControlPort, +} from "../config/port-defaults.js"; +import { isLoopbackHost } from "../gateway/net.js"; +import type { SsrFPolicy } from "../infra/net/ssrf.js"; +import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; +import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { normalizeOptionalTrimmedStringList } from "../shared/string-normalization.js"; +import { resolveUserPath } from "../utils.js"; +import { parseBrowserHttpUrl } from "./browser-cdp.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; +export const DEFAULT_UPLOAD_DIR = path.join(resolvePreferredOpenClawTmpDir(), "uploads"); + +export type ResolvedBrowserConfig = { + enabled: boolean; + evaluateEnabled: boolean; + controlPort: number; + cdpPortRangeStart: number; + cdpPortRangeEnd: number; + cdpProtocol: "http" | "https"; + cdpHost: string; + cdpIsLoopback: boolean; + remoteCdpTimeoutMs: number; + remoteCdpHandshakeTimeoutMs: number; + color: string; + executablePath?: string; + headless: boolean; + noSandbox: boolean; + attachOnly: boolean; + defaultProfile: string; + profiles: Record; + ssrfPolicy?: SsrFPolicy; + extraArgs: string[]; +}; + +export type ResolvedBrowserProfile = { + name: string; + cdpPort: number; + cdpUrl: string; + cdpHost: string; + cdpIsLoopback: boolean; + userDataDir?: string; + color: string; + driver: "openclaw" | "existing-session"; + attachOnly: boolean; +}; + +function 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 > 65_535) { + throw new Error(`browser.cdpPortRangeStart must be between 1 and 65535, got: ${start}`); + } + const maxStart = 65_535 - 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 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 = normalizeOptionalTrimmedStringList(rawPolicy?.allowedHostnames); + const hostnameAllowlist = normalizeOptionalTrimmedStringList(rawPolicy?.hostnameAllowlist); + const hasExplicitPrivateSetting = + allowPrivateNetwork !== undefined || dangerouslyAllowPrivateNetwork !== undefined; + const resolvedAllowPrivateNetwork = + dangerouslyAllowPrivateNetwork === true || + allowPrivateNetwork === true || + !hasExplicitPrivateSetting; + + if ( + !resolvedAllowPrivateNetwork && + !hasExplicitPrivateSetting && + !allowedHostnames && + !hostnameAllowlist + ) { + return undefined; + } + + return { + ...(resolvedAllowPrivateNetwork ? { dangerouslyAllowPrivateNetwork: true } : {}), + ...(allowedHostnames ? { allowedHostnames } : {}), + ...(hostnameAllowlist ? { hostnameAllowlist } : {}), + }; +} + +function ensureDefaultProfile( + profiles: Record | undefined, + defaultColor: string, + legacyCdpPort?: number, + derivedDefaultCdpPort?: number, + legacyCdpUrl?: string, +): Record { + const result = { ...profiles }; + if (!result[DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME]) { + result[DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME] = { + cdpPort: legacyCdpPort ?? derivedDefaultCdpPort ?? DEFAULT_BROWSER_CDP_PORT_RANGE_START, + color: defaultColor, + ...(legacyCdpUrl ? { cdpUrl: legacyCdpUrl } : {}), + }; + } + return result; +} + +function ensureDefaultUserBrowserProfile( + profiles: Record, +): Record { + const result = { ...profiles }; + if (result.user) { + return result; + } + result.user = { + driver: "existing-session", + attachOnly: true, + color: "#00AA00", + }; + return result; +} + +export function resolveBrowserConfig( + cfg: BrowserConfig | undefined, + rootConfig?: OpenClawConfig, +): ResolvedBrowserConfig { + const enabled = cfg?.enabled ?? DEFAULT_OPENCLAW_BROWSER_ENABLED; + const evaluateEnabled = cfg?.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED; + const gatewayPort = resolveGatewayPort(rootConfig); + const controlPort = deriveDefaultBrowserControlPort(gatewayPort ?? DEFAULT_BROWSER_CONTROL_PORT); + const defaultColor = normalizeHexColor(cfg?.color); + const remoteCdpTimeoutMs = normalizeTimeoutMs(cfg?.remoteCdpTimeoutMs, 1500); + const remoteCdpHandshakeTimeoutMs = normalizeTimeoutMs( + cfg?.remoteCdpHandshakeTimeoutMs, + Math.max(2000, remoteCdpTimeoutMs * 2), + ); + + const derivedCdpRange = deriveDefaultBrowserCdpPortRange(controlPort); + const cdpRangeSpan = derivedCdpRange.end - derivedCdpRange.start; + const cdpPortRangeStart = resolveCdpPortRangeStart( + cfg?.cdpPortRangeStart, + derivedCdpRange.start, + cdpRangeSpan, + ); + const cdpPortRangeEnd = cdpPortRangeStart + cdpRangeSpan; + + const rawCdpUrl = (cfg?.cdpUrl ?? "").trim(); + let cdpInfo: + | { + parsed: URL; + port: number; + normalized: string; + } + | undefined; + if (rawCdpUrl) { + cdpInfo = parseBrowserHttpUrl(rawCdpUrl, "browser.cdpUrl"); + } else { + const derivedPort = controlPort + 1; + if (derivedPort > 65_535) { + 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 = normalizeOptionalString(cfg?.executablePath); + const defaultProfileFromConfig = normalizeOptionalString(cfg?.defaultProfile); + + 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, + }; +}