From 30aa1f890a3ccb93f1899456eda7e3c7c2b84eb1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 01:14:45 +0100 Subject: [PATCH] feat(browser): expose doctor diagnostics to agents Co-authored-by: Sean Coley --- CHANGELOG.md | 1 + docs/cli/browser.md | 2 + docs/tools/browser.md | 2 +- extensions/browser/src/browser-runtime.ts | 3 + .../browser/src/browser-tool.runtime.ts | 1 + extensions/browser/src/browser-tool.schema.ts | 1 + extensions/browser/src/browser-tool.test.ts | 44 ++++++ extensions/browser/src/browser-tool.ts | 15 ++ extensions/browser/src/browser/client.test.ts | 29 +++- extensions/browser/src/browser/client.ts | 12 ++ extensions/browser/src/browser/doctor.test.ts | 105 +++++++++++++ extensions/browser/src/browser/doctor.ts | 138 ++++++++++++++++++ .../browser/src/browser/routes/basic.ts | 133 ++++++++++------- extensions/browser/src/core-api.ts | 3 + extensions/browser/src/doctor-browser.test.ts | 55 +++++++ extensions/browser/src/doctor-browser.ts | 91 +++++++++++- src/commands/doctor-browser.ts | 6 + 17 files changed, 578 insertions(+), 63 deletions(-) create mode 100644 extensions/browser/src/browser/doctor.test.ts create mode 100644 extensions/browser/src/browser/doctor.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 00e4c82c8b4..e2861a7eb72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/cli/browser.md b/docs/cli/browser.md index 18933c1bd95..032ed879290 100644 --- a/docs/cli/browser.md +++ b/docs/cli/browser.md @@ -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. diff --git a/docs/tools/browser.md b/docs/tools/browser.md index b6cc1725543..043ac6dbb5f 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -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: diff --git a/extensions/browser/src/browser-runtime.ts b/extensions/browser/src/browser-runtime.ts index b4fb4c54948..5264fca998b 100644 --- a/extensions/browser/src/browser-runtime.ts +++ b/extensions/browser/src/browser-runtime.ts @@ -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, diff --git a/extensions/browser/src/browser-tool.runtime.ts b/extensions/browser/src/browser-tool.runtime.ts index 6a6a8a18cfa..a75a71a48a3 100644 --- a/extensions/browser/src/browser-tool.runtime.ts +++ b/extensions/browser/src/browser-tool.runtime.ts @@ -23,6 +23,7 @@ export { } from "./browser/client-actions.js"; export { browserCloseTab, + browserDoctor, browserFocusTab, browserOpenTab, browserProfiles, diff --git a/extensions/browser/src/browser-tool.schema.ts b/extensions/browser/src/browser-tool.schema.ts index bdc46ba3482..a1c336f04f4 100644 --- a/extensions/browser/src/browser-tool.schema.ts +++ b/extensions/browser/src/browser-tool.schema.ts @@ -16,6 +16,7 @@ const BROWSER_ACT_KINDS = [ ] as const; const BROWSER_TOOL_ACTIONS = [ + "doctor", "status", "start", "stop", diff --git a/extensions/browser/src/browser-tool.test.ts b/extensions/browser/src/browser-tool.test.ts index ce4e8b878f1..b5fbfb24f82 100644 --- a/extensions/browser/src/browser-tool.test.ts +++ b/extensions/browser/src/browser-tool.test.ts @@ -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 diff --git a/extensions/browser/src/browser-tool.ts b/extensions/browser/src/browser-tool.ts index 06e8a745503..e971b6615f6 100644 --- a/extensions/browser/src/browser-tool.ts +++ b/extensions/browser/src/browser-tool.ts @@ -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( diff --git a/extensions/browser/src/browser/client.test.ts b/extensions/browser/src/browser/client.test.ts index 64d37580e35..329cb33b5b1 100644 --- a/extensions/browser/src/browser/client.test.ts +++ b/extensions/browser/src/browser/client.test.ts @@ -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"); diff --git a/extensions/browser/src/browser/client.ts b/extensions/browser/src/browser/client.ts index 9bf1d010fe6..d450d3d44a2 100644 --- a/extensions/browser/src/browser/client.ts +++ b/extensions/browser/src/browser/client.ts @@ -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 { + const q = buildProfileQuery(opts?.profile); + return await fetchBrowserJson(withBaseUrl(baseUrl, `/doctor${q}`), { + timeoutMs: 3000, + }); +} + export async function browserProfiles(baseUrl?: string): Promise { const res = await fetchBrowserJson<{ profiles: ProfileStatus[] }>( withBaseUrl(baseUrl, `/profiles`), diff --git a/extensions/browser/src/browser/doctor.test.ts b/extensions/browser/src/browser/doctor.test.ts new file mode 100644 index 00000000000..ada9fc721bb --- /dev/null +++ b/extensions/browser/src/browser/doctor.test.ts @@ -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); + }); +}); diff --git a/extensions/browser/src/browser/doctor.ts b/extensions/browser/src/browser/doctor.ts new file mode 100644 index 00000000000..c815d45d953 --- /dev/null +++ b/extensions/browser/src/browser/doctor.ts @@ -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, + }; +} diff --git a/extensions/browser/src/browser/routes/basic.ts b/extensions/browser/src/browser/routes/basic.ts index a913d980a43..3e40a73e653 100644 --- a/extensions/browser/src/browser/routes/basic.ts +++ b/extensions/browser/src/browser/routes/basic.ts @@ -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; + 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; 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) { diff --git a/extensions/browser/src/core-api.ts b/extensions/browser/src/core-api.ts index 485465e46c0..0c5a5cf02c4 100644 --- a/extensions/browser/src/core-api.ts +++ b/extensions/browser/src/core-api.ts @@ -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, diff --git a/extensions/browser/src/doctor-browser.test.ts b/extensions/browser/src/doctor-browser.test.ts index c40a26f5c20..40926002185 100644 --- a/extensions/browser/src/doctor-browser.test.ts +++ b/extensions/browser/src/doctor-browser.test.ts @@ -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( diff --git a/extensions/browser/src/doctor-browser.ts b/extensions/browser/src/doctor-browser.ts index ccef7b6021b..681ab773560 100644 --- a/extensions/browser/src/doctor-browser.ts +++ b/extensions/browser/src/doctor-browser.ts @@ -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(); + 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(", "); diff --git a/src/commands/doctor-browser.ts b/src/commands/doctor-browser.ts index f8d7f20a375..ede72a4ddd5 100644 --- a/src/commands/doctor-browser.ts +++ b/src/commands/doctor-browser.ts @@ -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; };