feat(browser): expose doctor diagnostics to agents

Co-authored-by: Sean Coley <github@seancoley.me>
This commit is contained in:
Peter Steinberger
2026-04-25 01:14:45 +01:00
parent b5a5b59742
commit 30aa1f890a
17 changed files with 578 additions and 63 deletions

View File

@@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai
- Slack/messages: serialize write-client requests and whole outbound sends per target so rapid multi-message Slack replies preserve send order. Fixes #69101. (#69105) Thanks @nightq and @ztexydt-cqh.
- Slack/exec approvals: resolve native approval button clicks over the Gateway instead of delivering `/approve ...` as plain agent text, preserving retry buttons if Gateway resolution fails. Fixes #71023. (#71025) Thanks @marusan03.
- Browser/tool: expose browser doctor diagnostics to agents and extend `openclaw doctor` browser readiness notes for managed Chromium launch prerequisites. (#62948, #62936) Thanks @seanc-dev.
- Slack/files: return non-image `download-file` results as local file paths instead of image payloads, and include Slack file IDs in inbound file placeholders so agents can call `download-file`. Fixes #71212. Thanks @teamrazo.
- Browser control: scope standalone loopback auth to the resolved active gateway credential and fail closed when password mode lacks a resolved password, so inactive tokens or passwords no longer authorize browser routes. Fixes #65626. (#65639) Thanks @coygeek.
- Control UI/Codex harness: emit native Codex app-server assistant and lifecycle completion events so live webchat runs stop spinning without needing a transcript reload fallback. (#70815) Thanks @lesaai.

View File

@@ -33,6 +33,8 @@ openclaw browser --browser-profile openclaw open https://example.com
openclaw browser --browser-profile openclaw snapshot
```
Agents can run the same readiness check with `browser({ action: "doctor" })`.
## Quick troubleshooting
If `start` fails with `not reachable after start`, troubleshoot CDP readiness first. If `start` and `tabs` succeed but `open` or `navigate` fails, the browser control plane is healthy and the failure is usually navigation SSRF policy.

View File

@@ -598,7 +598,7 @@ Security guidance:
The agent gets **one tool** for browser automation:
- `browser` — status/start/stop/tabs/open/focus/close/snapshot/screenshot/navigate/act
- `browser`doctor/status/start/stop/tabs/open/focus/close/snapshot/screenshot/navigate/act
How it maps:

View File

@@ -15,6 +15,7 @@ export {
browserOpenTab,
browserCreateProfile,
browserDeleteProfile,
browserDoctor,
browserProfiles,
browserResetProfile,
browserSnapshot,
@@ -28,6 +29,8 @@ export { runBrowserProxyCommand } from "./node-host/invoke-browser.js";
export type {
BrowserCreateProfileResult,
BrowserDeleteProfileResult,
BrowserDoctorCheck,
BrowserDoctorReport,
BrowserResetProfileResult,
BrowserStatus,
BrowserTab,

View File

@@ -23,6 +23,7 @@ export {
} from "./browser/client-actions.js";
export {
browserCloseTab,
browserDoctor,
browserFocusTab,
browserOpenTab,
browserProfiles,

View File

@@ -16,6 +16,7 @@ const BROWSER_ACT_KINDS = [
] as const;
const BROWSER_TOOL_ACTIONS = [
"doctor",
"status",
"start",
"stop",

View File

@@ -2,6 +2,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const browserClientMocks = vi.hoisted(() => ({
browserCloseTab: vi.fn(async (..._args: unknown[]) => ({})),
browserDoctor: vi.fn(async (..._args: unknown[]) => ({
ok: true,
profile: "openclaw",
transport: "cdp",
checks: [],
status: {
enabled: true,
running: true,
pid: 1,
cdpPort: 18792,
cdpUrl: "http://127.0.0.1:18792",
},
})),
browserFocusTab: vi.fn(async (..._args: unknown[]) => ({})),
browserOpenTab: vi.fn(async (..._args: unknown[]) => ({})),
browserProfiles: vi.fn(
@@ -242,6 +255,7 @@ function resetBrowserToolMocks() {
browserArmDialog: browserActionsMocks.browserArmDialog as never,
browserArmFileChooser: browserActionsMocks.browserArmFileChooser as never,
browserCloseTab: browserClientMocks.browserCloseTab as never,
browserDoctor: browserClientMocks.browserDoctor as never,
browserFocusTab: browserClientMocks.browserFocusTab as never,
browserNavigate: browserActionsMocks.browserNavigate as never,
browserOpenTab: browserClientMocks.browserOpenTab as never,
@@ -510,6 +524,36 @@ describe("browser tool snapshot maxChars", () => {
expect(browserClientMocks.browserStatus).not.toHaveBeenCalled();
});
it("returns a browser doctor report on host", async () => {
const tool = createBrowserTool();
await tool.execute?.("call-1", { action: "doctor" });
expect(browserClientMocks.browserDoctor).toHaveBeenCalledWith(undefined, {
profile: undefined,
});
});
it("routes browser doctor through the node proxy", async () => {
mockSingleBrowserProxyNode();
const tool = createBrowserTool();
await tool.execute?.("call-1", { action: "doctor", target: "node" });
expect(gatewayMocks.callGatewayTool).toHaveBeenCalledWith(
"node.invoke",
{ timeoutMs: 25000 },
expect.objectContaining({
nodeId: "node-1",
command: "browser.proxy",
params: expect.objectContaining({
method: "GET",
path: "/doctor",
timeoutMs: 20000,
}),
}),
);
expect(browserClientMocks.browserDoctor).not.toHaveBeenCalled();
});
it("falls back to role refs when a node snapshot cannot provide aria refs", async () => {
mockSingleBrowserProxyNode();
gatewayMocks.callGatewayTool

View File

@@ -15,6 +15,7 @@ import {
browserArmDialog,
browserArmFileChooser,
browserCloseTab,
browserDoctor,
browserFocusTab,
browserNavigate,
browserOpenTab,
@@ -48,6 +49,7 @@ const browserToolDeps = {
browserArmDialog,
browserArmFileChooser,
browserCloseTab,
browserDoctor,
browserFocusTab,
browserNavigate,
browserOpenTab,
@@ -72,6 +74,7 @@ export const __testing = {
browserArmDialog: typeof browserArmDialog;
browserArmFileChooser: typeof browserArmFileChooser;
browserCloseTab: typeof browserCloseTab;
browserDoctor: typeof browserDoctor;
browserFocusTab: typeof browserFocusTab;
browserNavigate: typeof browserNavigate;
browserOpenTab: typeof browserOpenTab;
@@ -94,6 +97,7 @@ export const __testing = {
browserToolDeps.browserArmFileChooser =
overrides?.browserArmFileChooser ?? browserArmFileChooser;
browserToolDeps.browserCloseTab = overrides?.browserCloseTab ?? browserCloseTab;
browserToolDeps.browserDoctor = overrides?.browserDoctor ?? browserDoctor;
browserToolDeps.browserFocusTab = overrides?.browserFocusTab ?? browserFocusTab;
browserToolDeps.browserNavigate = overrides?.browserNavigate ?? browserNavigate;
browserToolDeps.browserOpenTab = overrides?.browserOpenTab ?? browserOpenTab;
@@ -466,6 +470,17 @@ export function createBrowserTool(opts?: {
: null;
switch (action) {
case "doctor":
if (proxyRequest) {
return jsonResult(
await proxyRequest({
method: "GET",
path: "/doctor",
profile,
}),
);
}
return jsonResult(await browserToolDeps.browserDoctor(baseUrl, { profile }));
case "status":
if (proxyRequest) {
return jsonResult(

View File

@@ -8,7 +8,13 @@ import {
browserPdfSave,
browserScreenshotAction,
} from "./client-actions.js";
import { browserOpenTab, browserSnapshot, browserStatus, browserTabs } from "./client.js";
import {
browserDoctor,
browserOpenTab,
browserSnapshot,
browserStatus,
browserTabs,
} from "./client.js";
describe("browser client", () => {
function stubSnapshotFetch(calls: string[]) {
@@ -220,6 +226,22 @@ describe("browser client", () => {
}),
} as unknown as Response;
}
if (url.endsWith("/doctor")) {
return {
ok: true,
json: async () => ({
ok: true,
profile: "openclaw",
transport: "cdp",
checks: [],
status: {
enabled: true,
running: true,
cdpPort: 18792,
},
}),
} as unknown as Response;
}
return {
ok: true,
json: async () => ({
@@ -244,6 +266,10 @@ describe("browser client", () => {
running: true,
cdpPort: 18792,
});
await expect(browserDoctor("http://127.0.0.1:18791")).resolves.toMatchObject({
ok: true,
profile: "openclaw",
});
await expect(browserTabs("http://127.0.0.1:18791")).resolves.toHaveLength(1);
await expect(
@@ -280,6 +306,7 @@ describe("browser client", () => {
).resolves.toMatchObject({ ok: true, path: "/tmp/a.png" });
expect(calls.some((c) => c.url.endsWith("/tabs"))).toBe(true);
expect(calls.some((c) => c.url.endsWith("/doctor"))).toBe(true);
const open = calls.find((c) => c.url.endsWith("/tabs/open"));
expect(open?.init?.method).toBe("POST");

View File

@@ -1,8 +1,10 @@
import { buildProfileQuery, withBaseUrl } from "./client-actions-url.js";
import { fetchBrowserJson } from "./client-fetch.js";
import type { BrowserTab, BrowserTransport, SnapshotAriaNode } from "./client.types.js";
import type { BrowserDoctorReport } from "./doctor.js";
export type { BrowserTab, BrowserTransport, SnapshotAriaNode } from "./client.types.js";
export type { BrowserDoctorCheck, BrowserDoctorReport } from "./doctor.js";
export type BrowserStatus = {
enabled: boolean;
@@ -88,6 +90,16 @@ export async function browserStatus(
});
}
export async function browserDoctor(
baseUrl?: string,
opts?: { profile?: string },
): Promise<BrowserDoctorReport> {
const q = buildProfileQuery(opts?.profile);
return await fetchBrowserJson<BrowserDoctorReport>(withBaseUrl(baseUrl, `/doctor${q}`), {
timeoutMs: 3000,
});
}
export async function browserProfiles(baseUrl?: string): Promise<ProfileStatus[]> {
const res = await fetchBrowserJson<{ profiles: ProfileStatus[] }>(
withBaseUrl(baseUrl, `/profiles`),

View File

@@ -0,0 +1,105 @@
import { describe, expect, it } from "vitest";
import { buildBrowserDoctorReport } from "./doctor.js";
describe("buildBrowserDoctorReport", () => {
it("reports stopped managed browsers as launchable diagnostics", () => {
const report = buildBrowserDoctorReport({
platform: "linux",
env: { DISPLAY: ":99" },
uid: 1000,
status: {
enabled: true,
profile: "openclaw",
driver: "openclaw",
transport: "cdp",
running: false,
cdpReady: false,
cdpHttp: false,
pid: null,
cdpPort: 18800,
cdpUrl: "http://127.0.0.1:18800",
chosenBrowser: null,
detectedBrowser: "chromium",
detectedExecutablePath: "/usr/bin/chromium",
detectError: null,
userDataDir: "/tmp/openclaw",
color: "#FF4500",
headless: false,
noSandbox: false,
executablePath: null,
attachOnly: false,
},
});
expect(report.ok).toBe(true);
expect(report.checks.find((check) => check.id === "cdp-websocket")).toMatchObject({
status: "info",
summary: "Browser is launchable but not running",
});
});
it("fails when Chrome MCP attach is not ready", () => {
const report = buildBrowserDoctorReport({
status: {
enabled: true,
profile: "user",
driver: "existing-session",
transport: "chrome-mcp",
running: false,
cdpReady: false,
cdpHttp: false,
pid: null,
cdpPort: null,
cdpUrl: null,
chosenBrowser: null,
detectedBrowser: null,
detectedExecutablePath: null,
detectError: null,
userDataDir: null,
color: "#00AA00",
headless: false,
noSandbox: false,
executablePath: null,
attachOnly: true,
},
});
expect(report.ok).toBe(false);
expect(report.checks.find((check) => check.id === "attach-target")).toMatchObject({
status: "fail",
});
});
it("keeps managed launch warnings non-fatal", () => {
const report = buildBrowserDoctorReport({
platform: "linux",
env: {},
uid: 0,
status: {
enabled: true,
profile: "openclaw",
driver: "openclaw",
transport: "cdp",
running: false,
cdpReady: false,
cdpHttp: false,
pid: null,
cdpPort: 18800,
cdpUrl: "http://127.0.0.1:18800",
chosenBrowser: null,
detectedBrowser: null,
detectedExecutablePath: null,
detectError: null,
userDataDir: "/tmp/openclaw",
color: "#FF4500",
headless: false,
noSandbox: false,
executablePath: null,
attachOnly: false,
},
});
expect(report.ok).toBe(true);
expect(report.checks.some((check) => check.status === "warn")).toBe(true);
});
});

View File

@@ -0,0 +1,138 @@
import type { BrowserStatus, BrowserTransport } from "./client.js";
export type BrowserDoctorCheckStatus = "pass" | "warn" | "fail" | "info";
export type BrowserDoctorCheck = {
id: string;
label: string;
status: BrowserDoctorCheckStatus;
summary: string;
fixHint?: string;
};
export type BrowserDoctorReport = {
ok: boolean;
profile: string;
transport: BrowserTransport;
checks: BrowserDoctorCheck[];
status: BrowserStatus;
};
export function buildBrowserDoctorReport(params: {
status: BrowserStatus;
platform?: NodeJS.Platform;
env?: NodeJS.ProcessEnv;
uid?: number;
}): BrowserDoctorReport {
const status = params.status;
const checks: BrowserDoctorCheck[] = [];
const transport: BrowserTransport = status.transport === "chrome-mcp" ? "chrome-mcp" : "cdp";
checks.push({
id: "plugin-enabled",
label: "Browser plugin",
status: status.enabled ? "pass" : "fail",
summary: status.enabled ? "enabled" : "disabled",
...(status.enabled ? {} : { fixHint: "Enable the browser plugin and restart the Gateway." }),
});
checks.push({
id: "profile",
label: "Profile",
status: "pass",
summary: `${status.profile ?? "openclaw"} via ${transport}`,
});
if (transport === "chrome-mcp") {
checks.push({
id: "attach-target",
label: "Existing browser attach",
status: status.running ? "pass" : "fail",
summary: status.running
? "Chrome MCP target is reachable"
: "Chrome MCP target is not reachable",
...(status.running
? {}
: {
fixHint:
"Keep the matching Chromium browser running, enable remote debugging in chrome://inspect, and accept the attach prompt.",
}),
});
} else {
checks.push({
id: "managed-executable",
label: "Chromium executable",
status: status.detectError ? "fail" : status.detectedExecutablePath ? "pass" : "warn",
summary: status.detectError
? status.detectError
: status.detectedExecutablePath
? `${status.detectedBrowser ?? "chromium"} at ${status.detectedExecutablePath}`
: "No Chromium executable detected",
...(status.detectedExecutablePath || status.detectError
? {}
: { fixHint: "Install Chrome/Chromium/Brave/Edge or set browser.executablePath." }),
});
const platform = params.platform ?? process.platform;
const env = params.env ?? process.env;
const uid = params.uid ?? process.getuid?.();
const missingDisplay =
platform === "linux" && !status.headless && !env.DISPLAY && !env.WAYLAND_DISPLAY;
if (missingDisplay) {
checks.push({
id: "display",
label: "Display",
status: "warn",
summary: "No DISPLAY or WAYLAND_DISPLAY is set while browser.headless is false",
fixHint: "Use a desktop session, Xvfb, or set browser.headless: true.",
});
}
if (platform === "linux" && uid === 0 && !status.noSandbox) {
checks.push({
id: "linux-sandbox",
label: "Linux sandbox",
status: "warn",
summary: "Gateway is running as root while browser.noSandbox is false",
fixHint: "Set browser.noSandbox: true for container/root Chromium runtimes.",
});
}
checks.push({
id: "cdp-http",
label: "CDP HTTP",
status: status.cdpHttp ? "pass" : status.running ? "fail" : "info",
summary: status.cdpHttp
? "CDP HTTP endpoint is reachable"
: status.running
? "CDP HTTP endpoint is not reachable"
: "Browser is not currently running",
...(status.cdpHttp || !status.running
? {}
: {
fixHint: "Run openclaw browser start or inspect browser.cdpUrl/CDP port reachability.",
}),
});
checks.push({
id: "cdp-websocket",
label: "CDP WebSocket",
status: status.cdpReady ? "pass" : status.running ? "fail" : "info",
summary: status.cdpReady
? "CDP WebSocket is reachable"
: status.running
? "CDP WebSocket is not reachable"
: "Browser is launchable but not running",
...(status.cdpReady || !status.running
? {}
: { fixHint: "Check Chrome launch logs, stale locks, proxy env, and port conflicts." }),
});
}
return {
ok: checks.every((check) => check.status !== "fail"),
profile: status.profile ?? "openclaw",
transport,
checks,
status,
};
}

View File

@@ -1,6 +1,7 @@
import { getChromeMcpPid } from "../chrome-mcp.js";
import { resolveBrowserExecutableForPlatform } from "../chrome.executables.js";
import { toBrowserErrorResponse } from "../errors.js";
import { buildBrowserDoctorReport } from "../doctor.js";
import { BrowserError, toBrowserErrorResponse } from "../errors.js";
import { getBrowserProfileCapabilities } from "../profile-capabilities.js";
import { createBrowserProfilesService } from "../profiles-service.js";
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
@@ -47,6 +48,66 @@ async function withProfilesServiceMutation(params: {
}
}
async function buildBrowserStatus(req: BrowserRequest, ctx: BrowserRouteContext) {
let current: ReturnType<typeof ctx.state>;
try {
current = ctx.state();
} catch {
throw new BrowserError("browser server not started", 503);
}
const profileCtx = getProfileContext(req, ctx);
if ("error" in profileCtx) {
throw new BrowserError(profileCtx.error, profileCtx.status);
}
const [cdpHttp, cdpReady] = await Promise.all([
profileCtx.isHttpReachable(300),
profileCtx.isReachable(600),
]);
const profileState = current.profiles.get(profileCtx.profile.name);
const capabilities = getBrowserProfileCapabilities(profileCtx.profile);
let detectedBrowser: string | null = null;
let detectedExecutablePath: string | null = null;
let detectError: string | null = null;
try {
const detected = resolveBrowserExecutableForPlatform(current.resolved, process.platform);
if (detected) {
detectedBrowser = detected.kind;
detectedExecutablePath = detected.path;
}
} catch (err) {
detectError = String(err);
}
return {
enabled: current.resolved.enabled,
profile: profileCtx.profile.name,
driver: profileCtx.profile.driver,
transport: capabilities.usesChromeMcp ? ("chrome-mcp" as const) : ("cdp" as const),
running: cdpReady,
cdpReady,
cdpHttp,
pid: capabilities.usesChromeMcp
? getChromeMcpPid(profileCtx.profile.name)
: (profileState?.running?.pid ?? null),
cdpPort: capabilities.usesChromeMcp ? null : profileCtx.profile.cdpPort,
cdpUrl: capabilities.usesChromeMcp ? null : profileCtx.profile.cdpUrl,
chosenBrowser: profileState?.running?.exe.kind ?? null,
detectedBrowser,
detectedExecutablePath,
detectError,
userDataDir: profileState?.running?.userDataDir ?? profileCtx.profile.userDataDir ?? null,
color: profileCtx.profile.color,
headless: current.resolved.headless,
noSandbox: current.resolved.noSandbox,
executablePath: current.resolved.executablePath ?? null,
attachOnly: profileCtx.profile.attachOnly,
};
}
export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: BrowserRouteContext) {
// List all profiles with their status
app.get(
@@ -66,64 +127,24 @@ export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: Brow
app.get(
"/",
asyncBrowserRoute(async (req, res) => {
let current: ReturnType<typeof ctx.state>;
try {
current = ctx.state();
} catch {
return jsonError(res, 503, "browser server not started");
}
const profileCtx = getProfileContext(req, ctx);
if ("error" in profileCtx) {
return jsonError(res, profileCtx.status, profileCtx.error);
}
try {
const [cdpHttp, cdpReady] = await Promise.all([
profileCtx.isHttpReachable(300),
profileCtx.isReachable(600),
]);
const profileState = current.profiles.get(profileCtx.profile.name);
const capabilities = getBrowserProfileCapabilities(profileCtx.profile);
let detectedBrowser: string | null = null;
let detectedExecutablePath: string | null = null;
let detectError: string | null = null;
try {
const detected = resolveBrowserExecutableForPlatform(current.resolved, process.platform);
if (detected) {
detectedBrowser = detected.kind;
detectedExecutablePath = detected.path;
}
} catch (err) {
detectError = String(err);
res.json(await buildBrowserStatus(req, ctx));
} catch (err) {
const mapped = toBrowserErrorResponse(err);
if (mapped) {
return jsonError(res, mapped.status, mapped.message);
}
jsonError(res, 500, String(err));
}
}),
);
res.json({
enabled: current.resolved.enabled,
profile: profileCtx.profile.name,
driver: profileCtx.profile.driver,
transport: capabilities.usesChromeMcp ? "chrome-mcp" : "cdp",
running: cdpReady,
cdpReady,
cdpHttp,
pid: capabilities.usesChromeMcp
? getChromeMcpPid(profileCtx.profile.name)
: (profileState?.running?.pid ?? null),
cdpPort: capabilities.usesChromeMcp ? null : profileCtx.profile.cdpPort,
cdpUrl: capabilities.usesChromeMcp ? null : profileCtx.profile.cdpUrl,
chosenBrowser: profileState?.running?.exe.kind ?? null,
detectedBrowser,
detectedExecutablePath,
detectError,
userDataDir: profileState?.running?.userDataDir ?? profileCtx.profile.userDataDir ?? null,
color: profileCtx.profile.color,
headless: current.resolved.headless,
noSandbox: current.resolved.noSandbox,
executablePath: current.resolved.executablePath ?? null,
attachOnly: profileCtx.profile.attachOnly,
});
app.get(
"/doctor",
asyncBrowserRoute(async (req, res) => {
try {
const status = await buildBrowserStatus(req, ctx);
res.json(buildBrowserDoctorReport({ status }));
} catch (err) {
const mapped = toBrowserErrorResponse(err);
if (mapped) {

View File

@@ -9,6 +9,7 @@ export {
browserCreateProfile,
browserConsoleMessages,
browserDeleteProfile,
browserDoctor,
browserFocusTab,
browserNavigate,
browserOpenTab,
@@ -52,6 +53,8 @@ export {
export type {
BrowserCreateProfileResult,
BrowserDeleteProfileResult,
BrowserDoctorCheck,
BrowserDoctorReport,
BrowserFormField,
BrowserResetProfileResult,
BrowserRouteRegistrar,

View File

@@ -14,11 +14,66 @@ describe("browser doctor readiness", () => {
},
{
noteFn,
resolveManagedExecutable: () => ({ kind: "chrome", path: "/usr/bin/google-chrome" }),
},
);
expect(noteFn).not.toHaveBeenCalled();
});
it("warns when managed browser profiles have no local executable", async () => {
const noteFn = vi.fn();
await noteChromeMcpBrowserReadiness(
{
browser: {
profiles: {
openclaw: { color: "#FF4500" },
},
},
},
{
noteFn,
platform: "linux",
resolveManagedExecutable: () => null,
},
);
expect(noteFn).toHaveBeenCalledWith(
expect.stringContaining("No Chromium-based browser executable was found on this host"),
"Browser",
);
});
it("warns when managed browser launch needs display and no-sandbox adjustments", async () => {
const noteFn = vi.fn();
await noteChromeMcpBrowserReadiness(
{
browser: {
headless: false,
noSandbox: false,
profiles: {
openclaw: { color: "#FF4500" },
},
},
},
{
noteFn,
platform: "linux",
env: {},
getUid: () => 0,
resolveManagedExecutable: () => ({ kind: "chromium", path: "/usr/bin/chromium" }),
},
);
expect(noteFn).toHaveBeenCalledWith(
expect.stringContaining("No DISPLAY or WAYLAND_DISPLAY is set"),
"Browser",
);
expect(noteFn).toHaveBeenCalledWith(
expect.stringContaining("browser.noSandbox: true"),
"Browser",
);
});
it("warns when Chrome MCP is configured but Chrome is missing", async () => {
const noteFn = vi.fn();
await noteChromeMcpBrowserReadiness(

View File

@@ -3,8 +3,10 @@ import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import {
parseBrowserMajorVersion,
readBrowserVersion,
resolveBrowserExecutableForPlatform,
resolveGoogleChromeExecutableForPlatform,
} from "./browser/chrome.executables.js";
import { resolveBrowserConfig } from "./browser/config.js";
import type { OpenClawConfig } from "./config/config.js";
import { asRecord } from "./record-shared.js";
@@ -20,6 +22,10 @@ type ExistingSessionProfile = {
userDataDir?: string;
};
type ManagedProfile = {
name: string;
};
function collectChromeMcpProfiles(cfg: OpenClawConfig): ExistingSessionProfile[] {
const browser = asRecord(cfg.browser);
if (!browser) {
@@ -51,25 +57,100 @@ function collectChromeMcpProfiles(cfg: OpenClawConfig): ExistingSessionProfile[]
return [...profiles.values()].toSorted((a, b) => a.name.localeCompare(b.name));
}
function collectManagedProfiles(cfg: OpenClawConfig): ManagedProfile[] {
const browser = asRecord(cfg.browser);
if (!browser) {
return [];
}
const profiles = new Map<string, ManagedProfile>();
const defaultProfile = normalizeOptionalString(browser.defaultProfile) ?? "";
if (defaultProfile && defaultProfile !== "user") {
profiles.set(defaultProfile, { name: defaultProfile });
}
const configuredProfiles = asRecord(browser.profiles);
if (!configuredProfiles) {
return [...profiles.values()].toSorted((a, b) => a.name.localeCompare(b.name));
}
for (const [profileName, rawProfile] of Object.entries(configuredProfiles)) {
const profile = asRecord(rawProfile);
const driver = normalizeOptionalString(profile?.driver) ?? "openclaw";
if (driver !== "existing-session") {
profiles.set(profileName, { name: profileName });
}
}
return [...profiles.values()].toSorted((a, b) => a.name.localeCompare(b.name));
}
export async function noteChromeMcpBrowserReadiness(
cfg: OpenClawConfig,
deps?: {
platform?: NodeJS.Platform;
noteFn?: typeof note;
env?: NodeJS.ProcessEnv;
getUid?: () => number;
resolveManagedExecutable?: typeof resolveBrowserExecutableForPlatform;
resolveChromeExecutable?: (platform: NodeJS.Platform) => { path: string } | null;
readVersion?: (executablePath: string) => string | null;
},
) {
const noteFn = deps?.noteFn ?? note;
const platform = deps?.platform ?? process.platform;
const env = deps?.env ?? process.env;
const getUid = deps?.getUid ?? (() => process.getuid?.() ?? -1);
const resolveManagedExecutable =
deps?.resolveManagedExecutable ?? resolveBrowserExecutableForPlatform;
const resolveChromeExecutable =
deps?.resolveChromeExecutable ?? resolveGoogleChromeExecutableForPlatform;
const readVersion = deps?.readVersion ?? readBrowserVersion;
const managedProfiles = collectManagedProfiles(cfg);
const managedProfileLabel = managedProfiles.map((profile) => profile.name).join(", ");
const resolved = resolveBrowserConfig(cfg.browser, cfg);
const browserExecutable =
managedProfiles.length > 0 ? resolveManagedExecutable(resolved, platform) : null;
const missingDisplay =
platform === "linux" &&
managedProfiles.length > 0 &&
!resolved.headless &&
!normalizeOptionalString(env.DISPLAY) &&
!normalizeOptionalString(env.WAYLAND_DISPLAY);
const shouldWarnRootNoSandbox =
platform === "linux" && managedProfiles.length > 0 && !resolved.noSandbox && getUid() === 0;
if (!browserExecutable && managedProfiles.length > 0) {
noteFn(
[
`- OpenClaw-managed browser profile(s) are configured: ${managedProfileLabel}.`,
"- No Chromium-based browser executable was found on this host for OpenClaw-managed launch.",
"- Install Chrome, Chromium, Brave, Edge, or set browser.executablePath explicitly.",
].join("\n"),
"Browser",
);
}
if (missingDisplay || shouldWarnRootNoSandbox) {
const lines = [`- OpenClaw-managed browser profile(s) are configured: ${managedProfileLabel}.`];
if (missingDisplay) {
lines.push(
"- No DISPLAY or WAYLAND_DISPLAY is set, and browser.headless is false. Managed browser launch needs a desktop session, Xvfb, or browser.headless: true.",
);
}
if (shouldWarnRootNoSandbox) {
lines.push(
"- The Gateway is running as root and browser.noSandbox is false. Chromium commonly requires browser.noSandbox: true in container/root runtimes.",
);
}
noteFn(lines.join("\n"), "Browser");
}
const profiles = collectChromeMcpProfiles(cfg);
if (profiles.length === 0) {
return;
}
const noteFn = deps?.noteFn ?? note;
const platform = deps?.platform ?? process.platform;
const resolveChromeExecutable =
deps?.resolveChromeExecutable ?? resolveGoogleChromeExecutableForPlatform;
const readVersion = deps?.readVersion ?? readBrowserVersion;
const explicitProfiles = profiles.filter((profile) => profile.userDataDir);
const autoConnectProfiles = profiles.filter((profile) => !profile.userDataDir);
const profileLabel = profiles.map((profile) => profile.name).join(", ");

View File

@@ -5,6 +5,12 @@ import { note } from "../terminal/note.js";
type BrowserDoctorDeps = {
platform?: NodeJS.Platform;
noteFn?: typeof note;
env?: NodeJS.ProcessEnv;
getUid?: () => number;
resolveManagedExecutable?: (
resolved: unknown,
platform: NodeJS.Platform,
) => { path: string } | null;
resolveChromeExecutable?: (platform: NodeJS.Platform) => { path: string } | null;
readVersion?: (executablePath: string) => string | null;
};