mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 15:40:44 +00:00
feat(browser): expose doctor diagnostics to agents
Co-authored-by: Sean Coley <github@seancoley.me>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -23,6 +23,7 @@ export {
|
||||
} from "./browser/client-actions.js";
|
||||
export {
|
||||
browserCloseTab,
|
||||
browserDoctor,
|
||||
browserFocusTab,
|
||||
browserOpenTab,
|
||||
browserProfiles,
|
||||
|
||||
@@ -16,6 +16,7 @@ const BROWSER_ACT_KINDS = [
|
||||
] as const;
|
||||
|
||||
const BROWSER_TOOL_ACTIONS = [
|
||||
"doctor",
|
||||
"status",
|
||||
"start",
|
||||
"stop",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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`),
|
||||
|
||||
105
extensions/browser/src/browser/doctor.test.ts
Normal file
105
extensions/browser/src/browser/doctor.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
138
extensions/browser/src/browser/doctor.ts
Normal file
138
extensions/browser/src/browser/doctor.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(", ");
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user