diff --git a/CHANGELOG.md b/CHANGELOG.md index 18ee6ef8ed2..bd9b0d619db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,8 @@ Docs: https://docs.openclaw.ai - Gateway/thread routing: preserve Slack, Telegram, and Mattermost thread-child delivery targets so bound subagent completion messages land in the originating thread instead of top-level channels. (#54840) Thanks @yzzymt. - ACP/stream relay: pass parent delivery context to ACP stream relay system events so `streamTo="parent"` updates route to the correct thread or topic instead of falling back to the main DM. (#57056) Thanks @pingren. - Agents/sessions: preserve announce `threadId` when `sessions.list` fallback rehydrates agent-to-agent announce targets so final announce messages stay in the originating thread/topic. (#63506) Thanks @SnowSky1. +- Browser/plugin SDK: route browser auth, profile, host-inspection, and doctor readiness helpers through browser plugin public facades so core compatibility helpers stop carrying duplicate runtime implementations. (#63957) Thanks @joshavant. + ## 2026.4.9 ### Changes diff --git a/extensions/browser/browser-config.ts b/extensions/browser/browser-config.ts index 61d8e1a03a1..808d1b63d09 100644 --- a/extensions/browser/browser-config.ts +++ b/extensions/browser/browser-config.ts @@ -5,13 +5,11 @@ export { DEFAULT_OPENCLAW_BROWSER_COLOR, DEFAULT_OPENCLAW_BROWSER_ENABLED, DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME, - parseBrowserHttpUrl, - redactCdpUrl, + DEFAULT_UPLOAD_DIR, resolveBrowserConfig, - resolveBrowserControlAuth, resolveProfile, - type BrowserControlAuth, type ResolvedBrowserConfig, type ResolvedBrowserProfile, -} from "./src/browser/config.js"; -export { DEFAULT_UPLOAD_DIR } from "./src/browser/paths.js"; +} from "./browser-profiles.js"; +export { resolveBrowserControlAuth, type BrowserControlAuth } from "./browser-control-auth.js"; +export { parseBrowserHttpUrl, redactCdpUrl } from "./src/browser/config.js"; diff --git a/extensions/browser/browser-control-auth.ts b/extensions/browser/browser-control-auth.ts index ee7076a6c09..72d63ded0a9 100644 --- a/extensions/browser/browser-control-auth.ts +++ b/extensions/browser/browser-control-auth.ts @@ -1,2 +1,6 @@ export type { BrowserControlAuth } from "./src/browser/control-auth.js"; -export { ensureBrowserControlAuth, resolveBrowserControlAuth } from "./src/browser/control-auth.js"; +export { + ensureBrowserControlAuth, + resolveBrowserControlAuth, + shouldAutoGenerateBrowserAuth, +} from "./src/browser/control-auth.js"; diff --git a/extensions/browser/src/browser/chrome.executables.test.ts b/extensions/browser/src/browser/chrome.executables.test.ts new file mode 100644 index 00000000000..2a5344bd158 --- /dev/null +++ b/extensions/browser/src/browser/chrome.executables.test.ts @@ -0,0 +1,42 @@ +import fs from "node:fs"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + parseBrowserMajorVersion, + resolveGoogleChromeExecutableForPlatform, +} from "./chrome.executables.js"; + +describe("chrome executables", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("parses odd dotted browser version tokens using the last match", () => { + expect(parseBrowserMajorVersion("Chromium 3.0/1.2.3")).toBe(1); + }); + + it("returns null when no dotted version token exists", () => { + expect(parseBrowserMajorVersion("no version here")).toBeNull(); + }); + + it("classifies beta Linux Google Chrome builds as canary", () => { + vi.spyOn(fs, "existsSync").mockImplementation((candidate) => { + return String(candidate) === "/usr/bin/google-chrome-beta"; + }); + + expect(resolveGoogleChromeExecutableForPlatform("linux")).toEqual({ + kind: "canary", + path: "/usr/bin/google-chrome-beta", + }); + }); + + it("classifies unstable Linux Google Chrome builds as canary", () => { + vi.spyOn(fs, "existsSync").mockImplementation((candidate) => { + return String(candidate) === "/usr/bin/google-chrome-unstable"; + }); + + expect(resolveGoogleChromeExecutableForPlatform("linux")).toEqual({ + kind: "canary", + path: "/usr/bin/google-chrome-unstable", + }); + }); +}); diff --git a/src/commands/doctor-browser.facade.test.ts b/src/commands/doctor-browser.facade.test.ts new file mode 100644 index 00000000000..9b110e05eb0 --- /dev/null +++ b/src/commands/doctor-browser.facade.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { noteChromeMcpBrowserReadiness } from "./doctor-browser.js"; + +const loadBundledPluginPublicSurfaceModuleSync = vi.hoisted(() => vi.fn()); + +vi.mock("../plugin-sdk/facade-loader.js", () => ({ + loadBundledPluginPublicSurfaceModuleSync, +})); + +describe("doctor browser facade", () => { + beforeEach(() => { + loadBundledPluginPublicSurfaceModuleSync.mockReset(); + }); + + it("delegates browser readiness checks to the browser facade surface", async () => { + const delegate = vi.fn().mockResolvedValue(undefined); + loadBundledPluginPublicSurfaceModuleSync.mockReturnValue({ + noteChromeMcpBrowserReadiness: delegate, + }); + + const cfg: OpenClawConfig = { + browser: { + defaultProfile: "user", + }, + }; + const noteFn = vi.fn(); + + await noteChromeMcpBrowserReadiness(cfg, { noteFn }); + + expect(loadBundledPluginPublicSurfaceModuleSync).toHaveBeenCalledWith({ + dirName: "browser", + artifactBasename: "browser-doctor.js", + }); + expect(delegate).toHaveBeenCalledWith(cfg, { noteFn }); + expect(noteFn).not.toHaveBeenCalled(); + }); + + it("warns and no-ops when the browser doctor surface is unavailable", async () => { + loadBundledPluginPublicSurfaceModuleSync.mockImplementation(() => { + throw new Error("missing browser doctor facade"); + }); + + const noteFn = vi.fn(); + + await expect(noteChromeMcpBrowserReadiness({}, { noteFn })).resolves.toBeUndefined(); + expect(noteFn).toHaveBeenCalledTimes(1); + expect(String(noteFn.mock.calls[0]?.[0])).toContain("Browser health check is unavailable"); + expect(String(noteFn.mock.calls[0]?.[0])).toContain("missing browser doctor facade"); + expect(noteFn.mock.calls[0]?.[1]).toBe("Browser"); + }); +}); diff --git a/src/commands/doctor-browser.ts b/src/commands/doctor-browser.ts index ec724975a82..67488351c81 100644 --- a/src/commands/doctor-browser.ts +++ b/src/commands/doctor-browser.ts @@ -1,146 +1,31 @@ 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 { loadBundledPluginPublicSurfaceModuleSync } from "../plugin-sdk/facade-loader.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; +type BrowserDoctorDeps = { + platform?: NodeJS.Platform; + noteFn?: typeof note; + resolveChromeExecutable?: (platform: NodeJS.Platform) => { path: string } | null; + readVersion?: (executablePath: string) => string | null; }; -function collectChromeMcpProfiles(cfg: OpenClawConfig): ExistingSessionProfile[] { - const browser = asNullableRecord(cfg.browser); - if (!browser) { - return []; - } +type BrowserDoctorSurface = { + noteChromeMcpBrowserReadiness: (cfg: OpenClawConfig, deps?: BrowserDoctorDeps) => Promise; +}; - 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)); +function loadBrowserDoctorSurface(): BrowserDoctorSurface { + return loadBundledPluginPublicSurfaceModuleSync({ + dirName: "browser", + artifactBasename: "browser-doctor.js", + }); } -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; +export async function noteChromeMcpBrowserReadiness(cfg: OpenClawConfig, deps?: BrowserDoctorDeps) { + try { + await loadBrowserDoctorSurface().noteChromeMcpBrowserReadiness(cfg, deps); + } catch (error) { + const noteFn = deps?.noteFn ?? note; + const message = error instanceof Error ? error.message : String(error); + noteFn(`- Browser health check is unavailable: ${message}`, "Browser"); } - - 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/commands/doctor.warns-state-directory-is-missing.e2e.test.ts b/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts index 9ddf5b1a644..a48cc53149d 100644 --- a/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts +++ b/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { createDoctorRuntime, ensureAuthProfileStore, @@ -106,6 +106,43 @@ describe("doctor command", () => { expect(String(stateNote?.[0])).toContain("CRITICAL"); }); + it("routes browser readiness through health contributions and degrades gracefully when browser facade is unavailable", async () => { + const loadBundledPluginPublicSurfaceModuleSync = vi.fn(() => { + throw new Error("missing browser doctor facade"); + }); + vi.doMock("../plugin-sdk/facade-loader.js", () => ({ + loadBundledPluginPublicSurfaceModuleSync, + })); + doctorCommand = await loadDoctorCommandForTest({ + unmockModules: [ + "../flows/doctor-health-contributions.js", + "./doctor-browser.js", + "./doctor-state-integrity.js", + ], + }); + + mockDoctorConfigSnapshot({ + config: { + browser: { + defaultProfile: "user", + }, + }, + }); + + await runDoctorNonInteractive(); + + expect(loadBundledPluginPublicSurfaceModuleSync).toHaveBeenCalledWith({ + dirName: "browser", + artifactBasename: "browser-doctor.js", + }); + const browserFallbackNote = terminalNoteMock.mock.calls.find( + ([message, title]) => + title === "Browser" && String(message).includes("Browser health check is unavailable"), + ); + expect(browserFallbackNote).toBeTruthy(); + expect(String(browserFallbackNote?.[0])).toContain("missing browser doctor facade"); + }); + it("warns about opencode provider overrides", async () => { mockDoctorConfigSnapshot({ config: { diff --git a/src/plugin-sdk/browser-control-auth.ts b/src/plugin-sdk/browser-control-auth.ts index 276a55f6817..5b230a74c8b 100644 --- a/src/plugin-sdk/browser-control-auth.ts +++ b/src/plugin-sdk/browser-control-auth.ts @@ -1,182 +1,49 @@ -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"; +import { loadBundledPluginPublicSurfaceModuleSync } from "./facade-loader.js"; export type BrowserControlAuth = { token?: string; password?: string; }; +type EnsureBrowserControlAuthParams = { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; +}; + +type EnsureBrowserControlAuthResult = { + auth: BrowserControlAuth; + generatedToken?: string; +}; + +type BrowserControlAuthSurface = { + resolveBrowserControlAuth: (cfg?: OpenClawConfig, env?: NodeJS.ProcessEnv) => BrowserControlAuth; + shouldAutoGenerateBrowserAuth: (env: NodeJS.ProcessEnv) => boolean; + ensureBrowserControlAuth: ( + params: EnsureBrowserControlAuthParams, + ) => Promise; +}; + +function loadBrowserControlAuthSurface(): BrowserControlAuthSurface { + return loadBundledPluginPublicSurfaceModuleSync({ + dirName: "browser", + artifactBasename: "browser-control-auth.js", + }); +} + 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, - }; + return loadBrowserControlAuthSurface().resolveBrowserControlAuth(cfg, env); } 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; + return loadBrowserControlAuthSurface().shouldAutoGenerateBrowserAuth(env); } -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, - }; +export async function ensureBrowserControlAuth( + params: EnsureBrowserControlAuthParams, +): Promise { + return await loadBrowserControlAuthSurface().ensureBrowserControlAuth(params); } diff --git a/src/plugin-sdk/browser-facades.test.ts b/src/plugin-sdk/browser-facades.test.ts new file mode 100644 index 00000000000..47906fe0718 --- /dev/null +++ b/src/plugin-sdk/browser-facades.test.ts @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const loadBundledPluginPublicSurfaceModuleSync = vi.hoisted(() => vi.fn()); + +vi.mock("./facade-loader.js", () => ({ + loadBundledPluginPublicSurfaceModuleSync, +})); + +describe("plugin-sdk browser facades", () => { + beforeEach(() => { + loadBundledPluginPublicSurfaceModuleSync.mockReset(); + }); + + it("delegates browser profile helpers to the browser facade", async () => { + const resolvedConfig = { + marker: "resolved-config", + } as unknown as import("./browser-profiles.js").ResolvedBrowserConfig; + const resolvedProfile = { + marker: "resolved-profile", + } as unknown as import("./browser-profiles.js").ResolvedBrowserProfile; + + const resolveBrowserConfig = vi.fn().mockReturnValue(resolvedConfig); + const resolveProfile = vi.fn().mockReturnValue(resolvedProfile); + loadBundledPluginPublicSurfaceModuleSync.mockReturnValue({ + resolveBrowserConfig, + resolveProfile, + }); + + const browserProfiles = await import("./browser-profiles.js"); + const cfg = { enabled: true } as unknown as import("../config/config.js").BrowserConfig; + const rootConfig = { gateway: { port: 18789 } } as import("../config/config.js").OpenClawConfig; + + expect(browserProfiles.resolveBrowserConfig(cfg, rootConfig)).toBe(resolvedConfig); + expect(browserProfiles.resolveProfile(resolvedConfig, "openclaw")).toBe(resolvedProfile); + expect(loadBundledPluginPublicSurfaceModuleSync).toHaveBeenCalledWith({ + dirName: "browser", + artifactBasename: "browser-profiles.js", + }); + expect(resolveBrowserConfig).toHaveBeenCalledWith(cfg, rootConfig); + expect(resolveProfile).toHaveBeenCalledWith(resolvedConfig, "openclaw"); + }); + + it("hard-fails when browser profile facade is unavailable", async () => { + loadBundledPluginPublicSurfaceModuleSync.mockImplementation(() => { + throw new Error("missing browser profiles facade"); + }); + + const browserProfiles = await import("./browser-profiles.js"); + + expect(() => browserProfiles.resolveBrowserConfig(undefined, undefined)).toThrow( + "missing browser profiles facade", + ); + }); + + it("delegates browser control auth helpers to the browser facade", async () => { + const resolvedAuth = { + token: "token-1", + password: undefined, + } as import("./browser-control-auth.js").BrowserControlAuth; + const ensuredAuth = { + auth: resolvedAuth, + generatedToken: "token-1", + }; + + const resolveBrowserControlAuth = vi.fn().mockReturnValue(resolvedAuth); + const shouldAutoGenerateBrowserAuth = vi.fn().mockReturnValue(true); + const ensureBrowserControlAuth = vi.fn().mockResolvedValue(ensuredAuth); + loadBundledPluginPublicSurfaceModuleSync.mockReturnValue({ + resolveBrowserControlAuth, + shouldAutoGenerateBrowserAuth, + ensureBrowserControlAuth, + }); + + const controlAuth = await import("./browser-control-auth.js"); + const cfg = { + gateway: { auth: { token: "token-1" } }, + } as import("../config/config.js").OpenClawConfig; + const env = {} as NodeJS.ProcessEnv; + + expect(controlAuth.resolveBrowserControlAuth(cfg, env)).toBe(resolvedAuth); + expect(controlAuth.shouldAutoGenerateBrowserAuth(env)).toBe(true); + await expect(controlAuth.ensureBrowserControlAuth({ cfg, env })).resolves.toEqual(ensuredAuth); + expect(loadBundledPluginPublicSurfaceModuleSync).toHaveBeenCalledWith({ + dirName: "browser", + artifactBasename: "browser-control-auth.js", + }); + }); + + it("hard-fails when browser control auth facade is unavailable", async () => { + loadBundledPluginPublicSurfaceModuleSync.mockImplementation(() => { + throw new Error("missing browser control auth facade"); + }); + + const controlAuth = await import("./browser-control-auth.js"); + + expect(() => controlAuth.resolveBrowserControlAuth(undefined, {} as NodeJS.ProcessEnv)).toThrow( + "missing browser control auth facade", + ); + }); + + it("delegates browser host inspection helpers to the browser facade", async () => { + const executable: import("./browser-host-inspection.js").BrowserExecutable = { + kind: "chrome", + path: "/usr/bin/google-chrome", + }; + + const resolveGoogleChromeExecutableForPlatform = vi.fn().mockReturnValue(executable); + const readBrowserVersion = vi.fn().mockReturnValue("Google Chrome 144.0.7534.0"); + const parseBrowserMajorVersion = vi.fn().mockReturnValue(144); + loadBundledPluginPublicSurfaceModuleSync.mockReturnValue({ + resolveGoogleChromeExecutableForPlatform, + readBrowserVersion, + parseBrowserMajorVersion, + }); + + const hostInspection = await import("./browser-host-inspection.js"); + + expect(hostInspection.resolveGoogleChromeExecutableForPlatform("linux")).toEqual(executable); + expect(hostInspection.readBrowserVersion(executable.path)).toBe("Google Chrome 144.0.7534.0"); + expect(hostInspection.parseBrowserMajorVersion("Google Chrome 144.0.7534.0")).toBe(144); + expect(loadBundledPluginPublicSurfaceModuleSync).toHaveBeenCalledWith({ + dirName: "browser", + artifactBasename: "browser-host-inspection.js", + }); + }); + + it("hard-fails when browser host inspection facade is unavailable", async () => { + loadBundledPluginPublicSurfaceModuleSync.mockImplementation(() => { + throw new Error("missing browser host inspection facade"); + }); + + const hostInspection = await import("./browser-host-inspection.js"); + + expect(() => hostInspection.resolveGoogleChromeExecutableForPlatform("linux")).toThrow( + "missing browser host inspection facade", + ); + }); +}); diff --git a/src/plugin-sdk/browser-host-inspection.test.ts b/src/plugin-sdk/browser-host-inspection.test.ts index 4c1317e6490..21f9593d351 100644 --- a/src/plugin-sdk/browser-host-inspection.test.ts +++ b/src/plugin-sdk/browser-host-inspection.test.ts @@ -1,42 +1,56 @@ -import fs from "node:fs"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { - parseBrowserMajorVersion, - resolveGoogleChromeExecutableForPlatform, -} from "./browser-host-inspection.js"; + +const loadBundledPluginPublicSurfaceModuleSync = vi.hoisted(() => vi.fn()); + +vi.mock("./facade-loader.js", () => ({ + loadBundledPluginPublicSurfaceModuleSync, +})); describe("browser host inspection", () => { beforeEach(() => { - vi.restoreAllMocks(); + loadBundledPluginPublicSurfaceModuleSync.mockReset(); }); - it("parses the last dotted browser version token", () => { - expect(parseBrowserMajorVersion("Google Chrome 144.0.7534.0")).toBe(144); - expect(parseBrowserMajorVersion("Chromium 3.0/1.2.3")).toBe(1); - expect(parseBrowserMajorVersion("no version here")).toBeNull(); - }); - - it("classifies beta Linux Chrome builds as prerelease", () => { - vi.spyOn(fs, "existsSync").mockImplementation((candidate) => { - const normalized = String(candidate); - return normalized === "/usr/bin/google-chrome-beta"; - }); - - expect(resolveGoogleChromeExecutableForPlatform("linux")).toEqual({ + it("delegates browser host inspection helpers through the browser facade", async () => { + const resolveGoogleChromeExecutableForPlatform = vi.fn().mockReturnValue({ kind: "canary", path: "/usr/bin/google-chrome-beta", }); + const readBrowserVersion = vi.fn().mockReturnValue("Google Chrome 144.0.7534.0"); + const parseBrowserMajorVersion = vi.fn().mockReturnValue(144); + + loadBundledPluginPublicSurfaceModuleSync.mockReturnValue({ + resolveGoogleChromeExecutableForPlatform, + readBrowserVersion, + parseBrowserMajorVersion, + }); + + const hostInspection = await import("./browser-host-inspection.js"); + + expect(hostInspection.resolveGoogleChromeExecutableForPlatform("linux")).toEqual({ + kind: "canary", + path: "/usr/bin/google-chrome-beta", + }); + expect(hostInspection.readBrowserVersion("/usr/bin/google-chrome-beta")).toBe( + "Google Chrome 144.0.7534.0", + ); + expect(hostInspection.parseBrowserMajorVersion("Google Chrome 144.0.7534.0")).toBe(144); + + expect(loadBundledPluginPublicSurfaceModuleSync).toHaveBeenCalledWith({ + dirName: "browser", + artifactBasename: "browser-host-inspection.js", + }); }); - it("classifies unstable Linux Chrome builds as prerelease", () => { - vi.spyOn(fs, "existsSync").mockImplementation((candidate) => { - const normalized = String(candidate); - return normalized === "/usr/bin/google-chrome-unstable"; + it("hard-fails when browser host inspection facade is unavailable", async () => { + loadBundledPluginPublicSurfaceModuleSync.mockImplementation(() => { + throw new Error("missing browser host inspection facade"); }); - expect(resolveGoogleChromeExecutableForPlatform("linux")).toEqual({ - kind: "canary", - path: "/usr/bin/google-chrome-unstable", - }); + const hostInspection = await import("./browser-host-inspection.js"); + + expect(() => hostInspection.resolveGoogleChromeExecutableForPlatform("linux")).toThrow( + "missing browser host inspection facade", + ); }); }); diff --git a/src/plugin-sdk/browser-host-inspection.ts b/src/plugin-sdk/browser-host-inspection.ts index 7183314b2ad..b3db53ae17f 100644 --- a/src/plugin-sdk/browser-host-inspection.ts +++ b/src/plugin-sdk/browser-host-inspection.ts @@ -1,134 +1,33 @@ -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"; +import { loadBundledPluginPublicSurfaceModuleSync } from "./facade-loader.js"; export type BrowserExecutable = { kind: "brave" | "canary" | "chromium" | "chrome" | "custom" | "edge"; path: string; }; -const CHROME_VERSION_RE = /\b(\d+)(?:\.\d+){1,3}\b/g; +type BrowserHostInspectionSurface = { + resolveGoogleChromeExecutableForPlatform: (platform: NodeJS.Platform) => BrowserExecutable | null; + readBrowserVersion: (executablePath: string) => string | null; + parseBrowserMajorVersion: (rawVersion: string | null | undefined) => number | null; +}; -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); +function loadBrowserHostInspectionSurface(): BrowserHostInspectionSurface { + return loadBundledPluginPublicSurfaceModuleSync({ + dirName: "browser", + artifactBasename: "browser-host-inspection.js", + }); } 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; + return loadBrowserHostInspectionSurface().resolveGoogleChromeExecutableForPlatform(platform); } export function readBrowserVersion(executablePath: string): string | null { - const output = execText(executablePath, ["--version"], 2000); - if (!output) { - return null; - } - return output.replace(/\s+/g, " ").trim(); + return loadBrowserHostInspectionSurface().readBrowserVersion(executablePath); } 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; + return loadBrowserHostInspectionSurface().parseBrowserMajorVersion(rawVersion); } diff --git a/src/plugin-sdk/browser-profiles.ts b/src/plugin-sdk/browser-profiles.ts index 9d841bc94da..31b5068957a 100644 --- a/src/plugin-sdk/browser-profiles.ts +++ b/src/plugin-sdk/browser-profiles.ts @@ -1,19 +1,8 @@ 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"; +import { loadBundledPluginPublicSurfaceModuleSync } from "./facade-loader.js"; export const DEFAULT_OPENCLAW_BROWSER_ENABLED = true; export const DEFAULT_BROWSER_EVALUATE_ENABLED = true; @@ -57,272 +46,34 @@ export type ResolvedBrowserProfile = { 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(); -} +type BrowserProfilesSurface = { + resolveBrowserConfig: ( + cfg: BrowserConfig | undefined, + rootConfig?: OpenClawConfig, + ) => ResolvedBrowserConfig; + resolveProfile: ( + resolved: ResolvedBrowserConfig, + profileName: string, + ) => ResolvedBrowserProfile | null; +}; -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; +function loadBrowserProfilesSurface(): BrowserProfilesSurface { + return loadBundledPluginPublicSurfaceModuleSync({ + dirName: "browser", + artifactBasename: "browser-profiles.js", + }); } 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, - }; + return loadBrowserProfilesSurface().resolveBrowserConfig(cfg, rootConfig); } 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, - }; + return loadBrowserProfilesSurface().resolveProfile(resolved, profileName); } diff --git a/src/plugins/contracts/plugin-sdk-subpaths.test.ts b/src/plugins/contracts/plugin-sdk-subpaths.test.ts index ec5dc4d61b3..fc0d728867b 100644 --- a/src/plugins/contracts/plugin-sdk-subpaths.test.ts +++ b/src/plugins/contracts/plugin-sdk-subpaths.test.ts @@ -53,17 +53,178 @@ const representativeRuntimeSmokeSubpaths = ["channel-runtime", "conversation-run const importResolvedPluginSdkSubpath = async (specifier: string) => import(specifier); -function readPluginSdkSource(subpath: string): string { - const file = resolve(PLUGIN_SDK_DIR, `${subpath}.ts`); - const cached = sourceCache.get(file); +type BrowserFacadeSourceContract = { + subpath: string; + artifactBasename: string; + mentions: readonly string[]; + omits: readonly string[]; +}; + +type BrowserHelperExportParityContract = { + corePath: string; + extensionPath: string; + expectedExports: readonly string[]; +}; + +const BROWSER_FACADE_SOURCE_CONTRACTS: readonly BrowserFacadeSourceContract[] = [ + { + subpath: "browser-control-auth", + artifactBasename: "browser-control-auth.js", + mentions: [ + "loadBundledPluginPublicSurfaceModuleSync", + "resolveBrowserControlAuth", + "shouldAutoGenerateBrowserAuth", + "ensureBrowserControlAuth", + ], + omits: [ + "resolveGatewayAuth", + "writeConfigFile", + "generateBrowserControlToken", + "ensureGatewayStartupAuth", + ], + }, + { + subpath: "browser-profiles", + artifactBasename: "browser-profiles.js", + mentions: [ + "loadBundledPluginPublicSurfaceModuleSync", + "resolveBrowserConfig", + "resolveProfile", + ], + omits: [ + "resolveBrowserSsrFPolicy", + "ensureDefaultProfile", + "ensureDefaultUserBrowserProfile", + "normalizeHexColor", + ], + }, + { + subpath: "browser-host-inspection", + artifactBasename: "browser-host-inspection.js", + mentions: [ + "loadBundledPluginPublicSurfaceModuleSync", + "resolveGoogleChromeExecutableForPlatform", + "readBrowserVersion", + "parseBrowserMajorVersion", + ], + omits: ["findFirstChromeExecutable", "findGoogleChromeExecutableLinux", "execText"], + }, +]; + +const BROWSER_HELPER_EXPORT_PARITY_CONTRACTS: readonly BrowserHelperExportParityContract[] = [ + { + corePath: "src/plugin-sdk/browser-control-auth.ts", + extensionPath: "extensions/browser/browser-control-auth.ts", + expectedExports: [ + "BrowserControlAuth", + "ensureBrowserControlAuth", + "resolveBrowserControlAuth", + "shouldAutoGenerateBrowserAuth", + ], + }, + { + corePath: "src/plugin-sdk/browser-profiles.ts", + extensionPath: "extensions/browser/browser-profiles.ts", + expectedExports: [ + "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", + "ResolvedBrowserConfig", + "ResolvedBrowserProfile", + "resolveBrowserConfig", + "resolveProfile", + ], + }, + { + corePath: "src/plugin-sdk/browser-host-inspection.ts", + extensionPath: "extensions/browser/browser-host-inspection.ts", + expectedExports: [ + "BrowserExecutable", + "parseBrowserMajorVersion", + "readBrowserVersion", + "resolveGoogleChromeExecutableForPlatform", + ], + }, +]; + +function readCachedSource(absolutePath: string): string { + const cached = sourceCache.get(absolutePath); if (cached !== undefined) { return cached; } - const text = readFileSync(file, "utf8"); - sourceCache.set(file, text); + const text = readFileSync(absolutePath, "utf8"); + sourceCache.set(absolutePath, text); return text; } +function readPluginSdkSource(subpath: string): string { + return readCachedSource(resolve(PLUGIN_SDK_DIR, `${subpath}.ts`)); +} + +function readRepoSource(relativePath: string): string { + return readCachedSource(resolve(REPO_ROOT, relativePath)); +} + +function collectNamedExportsFromClause(clause: string): string[] { + return clause + .split(",") + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0) + .map((segment) => segment.replace(/^type\s+/u, "")) + .map((segment) => { + const aliasMatch = segment.match(/\s+as\s+([A-Za-z_$][\w$]*)$/u); + if (aliasMatch?.[1]) { + return aliasMatch[1]; + } + return segment; + }); +} + +function collectNamedExportsFromSource(source: string): string[] { + const names = new Set(); + + const exportClausePattern = + /export\s+(?:type\s+)?\{([^}]*)\}\s*(?:from\s+["'][^"']+["'])?\s*;?/gms; + for (const match of source.matchAll(exportClausePattern)) { + for (const name of collectNamedExportsFromClause(match[1] ?? "")) { + names.add(name); + } + } + + for (const pattern of [ + /\bexport\s+(?:declare\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/gu, + /\bexport\s+(?:declare\s+)?const\s+([A-Za-z_$][\w$]*)/gu, + /\bexport\s+type\s+([A-Za-z_$][\w$]*)\s*=/gu, + /\bexport\s+interface\s+([A-Za-z_$][\w$]*)/gu, + /\bexport\s+class\s+([A-Za-z_$][\w$]*)/gu, + ]) { + for (const match of source.matchAll(pattern)) { + if (match[1]) { + names.add(match[1]); + } + } + } + + return [...names].toSorted(); +} + +function collectNamedExportsFromRepoFile(relativePath: string): string[] { + return collectNamedExportsFromSource(readRepoSource(relativePath)); +} + +function expectNamedExportParity(params: BrowserHelperExportParityContract) { + const coreExports = collectNamedExportsFromRepoFile(params.corePath); + const extensionExports = collectNamedExportsFromRepoFile(params.extensionPath); + expect(coreExports, `${params.corePath} exports changed`).toEqual([...params.expectedExports]); + expect(extensionExports, `${params.extensionPath} exports changed`).toEqual([ + ...params.expectedExports, + ]); +} + function listRepoTsFiles(dir: string): string[] { const entries = readdirSync(dir, { withFileTypes: true }); return entries.flatMap((entry) => { @@ -162,6 +323,12 @@ function expectSourceOmitsImportPattern(subpath: string, specifier: string) { expect(source).not.toMatch(new RegExp(`\\bimport\\(\\s*["']${escapedSpecifier}["']\\s*\\)`, "u")); } +function expectBrowserFacadeSourceContract(contract: BrowserFacadeSourceContract) { + expectSourceMentions(contract.subpath, contract.mentions); + expectSourceContains(contract.subpath, `artifactBasename: "${contract.artifactBasename}"`); + expectSourceOmits(contract.subpath, contract.omits); +} + function isGeneratedBundledFacadeSubpath(subpath: string): boolean { const source = readPluginSdkSource(subpath); return ( @@ -213,6 +380,18 @@ describe("plugin-sdk subpath exports", () => { expect(banned).toEqual([]); }); + it("keeps browser compatibility helper subpaths as thin facades", () => { + for (const contract of BROWSER_FACADE_SOURCE_CONTRACTS) { + expectBrowserFacadeSourceContract(contract); + } + }); + + it("keeps browser helper facade exports aligned with extension public wrappers", () => { + for (const contract of BROWSER_HELPER_EXPORT_PARITY_CONTRACTS) { + expectNamedExportParity(contract); + } + }); + it("keeps helper subpaths aligned", () => { expectSourceMentions("core", [ "emptyPluginConfigSchema",