mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 01:31:08 +00:00
Browser: consolidate duplicate helper surfaces via facade delegation (#63957)
* Plugin SDK: route browser helper surfaces through browser facade * Browser doctor flow: add facade path regression and export parity guards * Contracts: dedupe browser facade parity checks without reducing coverage * Browser tests: restore host-inspection semantics coverage in extension * fix: add changelog note for browser facade consolidation (#63957) (thanks @joshavant)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
42
extensions/browser/src/browser/chrome.executables.test.ts
Normal file
42
extensions/browser/src/browser/chrome.executables.test.ts
Normal file
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
52
src/commands/doctor-browser.facade.test.ts
Normal file
52
src/commands/doctor-browser.facade.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<void>;
|
||||
};
|
||||
|
||||
const profiles = new Map<string, ExistingSessionProfile>();
|
||||
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<BrowserDoctorSurface>({
|
||||
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.<name>.userDataDir for a different Chromium-based browser.`,
|
||||
`- Enable remote debugging in the browser inspect page (${REMOTE_DEBUGGING_PAGES}).`,
|
||||
"- Keep the browser running and accept the attach consent prompt the first time OpenClaw connects.",
|
||||
"- Docker, headless, and sandbox browser flows stay on raw CDP; this check only applies to host-local Chrome MCP attach.",
|
||||
];
|
||||
if (explicitProfiles.length > 0) {
|
||||
lines.push(
|
||||
`- Profiles with explicit userDataDir skip Chrome auto-detection: ${explicitProfiles
|
||||
.map((profile) => profile.name)
|
||||
.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
noteFn(lines.join("\n"), "Browser");
|
||||
return;
|
||||
}
|
||||
|
||||
const versionRaw = readVersion(chrome.path);
|
||||
const major = parseBrowserMajorVersion(versionRaw);
|
||||
const lines = [
|
||||
`- Chrome MCP existing-session is configured for profile(s): ${profileLabel}.`,
|
||||
`- Chrome path: ${chrome.path}`,
|
||||
];
|
||||
|
||||
if (!versionRaw || major === null) {
|
||||
lines.push(
|
||||
`- Could not determine the installed Chrome version. Chrome MCP requires Google Chrome ${CHROME_MCP_MIN_MAJOR}+ on this host.`,
|
||||
);
|
||||
} else if (major < CHROME_MCP_MIN_MAJOR) {
|
||||
lines.push(
|
||||
`- Detected Chrome ${versionRaw}, which is too old for Chrome MCP existing-session attach. Upgrade to Chrome ${CHROME_MCP_MIN_MAJOR}+.`,
|
||||
);
|
||||
} else {
|
||||
lines.push(`- Detected Chrome ${versionRaw}.`);
|
||||
}
|
||||
|
||||
lines.push(`- Enable remote debugging in the browser inspect page (${REMOTE_DEBUGGING_PAGES}).`);
|
||||
lines.push(
|
||||
"- Keep the browser running and accept the attach consent prompt the first time OpenClaw connects.",
|
||||
);
|
||||
if (explicitProfiles.length > 0) {
|
||||
lines.push(
|
||||
`- Profiles with explicit userDataDir still need manual validation of the matching Chromium-based browser: ${explicitProfiles
|
||||
.map((profile) => profile.name)
|
||||
.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
|
||||
noteFn(lines.join("\n"), "Browser");
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<EnsureBrowserControlAuthResult>;
|
||||
};
|
||||
|
||||
function loadBrowserControlAuthSurface(): BrowserControlAuthSurface {
|
||||
return loadBundledPluginPublicSurfaceModuleSync<BrowserControlAuthSurface>({
|
||||
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<EnsureBrowserControlAuthResult> {
|
||||
return await loadBrowserControlAuthSurface().ensureBrowserControlAuth(params);
|
||||
}
|
||||
|
||||
138
src/plugin-sdk/browser-facades.test.ts
Normal file
138
src/plugin-sdk/browser-facades.test.ts
Normal file
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<BrowserHostInspectionSurface>({
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<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;
|
||||
function loadBrowserProfilesSurface(): BrowserProfilesSurface {
|
||||
return loadBundledPluginPublicSurfaceModuleSync<BrowserProfilesSurface>({
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<string>();
|
||||
|
||||
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",
|
||||
|
||||
Reference in New Issue
Block a user