Skip to content

Commit 6c20d19

Browse files
committed
Auto-merge upstream openclaw/openclaw
2 parents a88f3f2 + 8de63ca commit 6c20d19

33 files changed

Lines changed: 3092 additions & 2165 deletions

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
1010
- macOS/Talk: add an experimental local MLX speech provider for Talk Mode, with explicit provider selection, local utterance playback, interruption handling, and system-voice fallback. (#63539) Thanks @ImLukeF.
1111
- Docs i18n: chunk raw doc translation, reject truncated tagged outputs, avoid ambiguous body-only wrapper unwrapping, and recover from terminated Pi translation sessions without changing the default `openai/gpt-5.4` path. (#62969, #63808) Thanks @hxy91819.
1212
- QA/testing: add a `--runner multipass` lane for `openclaw qa suite` so repo-backed QA scenarios can run inside a disposable Linux VM and write back the usual report, summary, and VM logs. (#63426) Thanks @shakkernerd.
13+
- Gateway: split startup and runtime seams so gateway lifecycle sequencing, reload state, and shutdown behavior stay easier to maintain without changing observed behavior. (#63975) Thanks @gumadeiras.
1314

1415
### Fixes
1516

@@ -60,6 +61,8 @@ Docs: https://docs.openclaw.ai
6061
- Gateway/thread routing: preserve Slack, Telegram, and Mattermost thread-child delivery targets so bound subagent completion messages land in the originating thread instead of top-level channels. (#54840) Thanks @yzzymt.
6162
- ACP/stream relay: pass parent delivery context to ACP stream relay system events so `streamTo="parent"` updates route to the correct thread or topic instead of falling back to the main DM. (#57056) Thanks @pingren.
6263
- Agents/sessions: preserve announce `threadId` when `sessions.list` fallback rehydrates agent-to-agent announce targets so final announce messages stay in the originating thread/topic. (#63506) Thanks @SnowSky1.
64+
- Browser/plugin SDK: route browser auth, profile, host-inspection, and doctor readiness helpers through browser plugin public facades so core compatibility helpers stop carrying duplicate runtime implementations. (#63957) Thanks @joshavant.
65+
6366
## 2026.4.9
6467

6568
### Changes

extensions/browser/browser-config.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,11 @@ export {
55
DEFAULT_OPENCLAW_BROWSER_COLOR,
66
DEFAULT_OPENCLAW_BROWSER_ENABLED,
77
DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME,
8-
parseBrowserHttpUrl,
9-
redactCdpUrl,
8+
DEFAULT_UPLOAD_DIR,
109
resolveBrowserConfig,
11-
resolveBrowserControlAuth,
1210
resolveProfile,
13-
type BrowserControlAuth,
1411
type ResolvedBrowserConfig,
1512
type ResolvedBrowserProfile,
16-
} from "./src/browser/config.js";
17-
export { DEFAULT_UPLOAD_DIR } from "./src/browser/paths.js";
13+
} from "./browser-profiles.js";
14+
export { resolveBrowserControlAuth, type BrowserControlAuth } from "./browser-control-auth.js";
15+
export { parseBrowserHttpUrl, redactCdpUrl } from "./src/browser/config.js";
Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,6 @@
11
export type { BrowserControlAuth } from "./src/browser/control-auth.js";
2-
export { ensureBrowserControlAuth, resolveBrowserControlAuth } from "./src/browser/control-auth.js";
2+
export {
3+
ensureBrowserControlAuth,
4+
resolveBrowserControlAuth,
5+
shouldAutoGenerateBrowserAuth,
6+
} from "./src/browser/control-auth.js";
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import fs from "node:fs";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
import {
4+
parseBrowserMajorVersion,
5+
resolveGoogleChromeExecutableForPlatform,
6+
} from "./chrome.executables.js";
7+
8+
describe("chrome executables", () => {
9+
beforeEach(() => {
10+
vi.restoreAllMocks();
11+
});
12+
13+
it("parses odd dotted browser version tokens using the last match", () => {
14+
expect(parseBrowserMajorVersion("Chromium 3.0/1.2.3")).toBe(1);
15+
});
16+
17+
it("returns null when no dotted version token exists", () => {
18+
expect(parseBrowserMajorVersion("no version here")).toBeNull();
19+
});
20+
21+
it("classifies beta Linux Google Chrome builds as canary", () => {
22+
vi.spyOn(fs, "existsSync").mockImplementation((candidate) => {
23+
return String(candidate) === "/usr/bin/google-chrome-beta";
24+
});
25+
26+
expect(resolveGoogleChromeExecutableForPlatform("linux")).toEqual({
27+
kind: "canary",
28+
path: "/usr/bin/google-chrome-beta",
29+
});
30+
});
31+
32+
it("classifies unstable Linux Google Chrome builds as canary", () => {
33+
vi.spyOn(fs, "existsSync").mockImplementation((candidate) => {
34+
return String(candidate) === "/usr/bin/google-chrome-unstable";
35+
});
36+
37+
expect(resolveGoogleChromeExecutableForPlatform("linux")).toEqual({
38+
kind: "canary",
39+
path: "/usr/bin/google-chrome-unstable",
40+
});
41+
});
42+
});
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import type { OpenClawConfig } from "../config/config.js";
3+
import { noteChromeMcpBrowserReadiness } from "./doctor-browser.js";
4+
5+
const loadBundledPluginPublicSurfaceModuleSync = vi.hoisted(() => vi.fn());
6+
7+
vi.mock("../plugin-sdk/facade-loader.js", () => ({
8+
loadBundledPluginPublicSurfaceModuleSync,
9+
}));
10+
11+
describe("doctor browser facade", () => {
12+
beforeEach(() => {
13+
loadBundledPluginPublicSurfaceModuleSync.mockReset();
14+
});
15+
16+
it("delegates browser readiness checks to the browser facade surface", async () => {
17+
const delegate = vi.fn().mockResolvedValue(undefined);
18+
loadBundledPluginPublicSurfaceModuleSync.mockReturnValue({
19+
noteChromeMcpBrowserReadiness: delegate,
20+
});
21+
22+
const cfg: OpenClawConfig = {
23+
browser: {
24+
defaultProfile: "user",
25+
},
26+
};
27+
const noteFn = vi.fn();
28+
29+
await noteChromeMcpBrowserReadiness(cfg, { noteFn });
30+
31+
expect(loadBundledPluginPublicSurfaceModuleSync).toHaveBeenCalledWith({
32+
dirName: "browser",
33+
artifactBasename: "browser-doctor.js",
34+
});
35+
expect(delegate).toHaveBeenCalledWith(cfg, { noteFn });
36+
expect(noteFn).not.toHaveBeenCalled();
37+
});
38+
39+
it("warns and no-ops when the browser doctor surface is unavailable", async () => {
40+
loadBundledPluginPublicSurfaceModuleSync.mockImplementation(() => {
41+
throw new Error("missing browser doctor facade");
42+
});
43+
44+
const noteFn = vi.fn();
45+
46+
await expect(noteChromeMcpBrowserReadiness({}, { noteFn })).resolves.toBeUndefined();
47+
expect(noteFn).toHaveBeenCalledTimes(1);
48+
expect(String(noteFn.mock.calls[0]?.[0])).toContain("Browser health check is unavailable");
49+
expect(String(noteFn.mock.calls[0]?.[0])).toContain("missing browser doctor facade");
50+
expect(noteFn.mock.calls[0]?.[1]).toBe("Browser");
51+
});
52+
});

src/commands/doctor-browser.ts

Lines changed: 21 additions & 136 deletions
Original file line numberDiff line numberDiff line change
@@ -1,146 +1,31 @@
11
import type { OpenClawConfig } from "../config/config.js";
2-
import {
3-
parseBrowserMajorVersion,
4-
readBrowserVersion,
5-
resolveGoogleChromeExecutableForPlatform,
6-
} from "../plugin-sdk/browser-host-inspection.js";
7-
import { asNullableRecord } from "../shared/record-coerce.js";
8-
import { normalizeOptionalString } from "../shared/string-coerce.js";
2+
import { loadBundledPluginPublicSurfaceModuleSync } from "../plugin-sdk/facade-loader.js";
93
import { note } from "../terminal/note.js";
104

11-
const CHROME_MCP_MIN_MAJOR = 144;
12-
const REMOTE_DEBUGGING_PAGES = [
13-
"chrome://inspect/#remote-debugging",
14-
"brave://inspect/#remote-debugging",
15-
"edge://inspect/#remote-debugging",
16-
].join(", ");
17-
18-
type ExistingSessionProfile = {
19-
name: string;
20-
userDataDir?: string;
5+
type BrowserDoctorDeps = {
6+
platform?: NodeJS.Platform;
7+
noteFn?: typeof note;
8+
resolveChromeExecutable?: (platform: NodeJS.Platform) => { path: string } | null;
9+
readVersion?: (executablePath: string) => string | null;
2110
};
2211

23-
function collectChromeMcpProfiles(cfg: OpenClawConfig): ExistingSessionProfile[] {
24-
const browser = asNullableRecord(cfg.browser);
25-
if (!browser) {
26-
return [];
27-
}
28-
29-
const profiles = new Map<string, ExistingSessionProfile>();
30-
const defaultProfile = normalizeOptionalString(browser.defaultProfile) ?? "";
31-
if (defaultProfile === "user") {
32-
profiles.set("user", { name: "user" });
33-
}
34-
35-
const configuredProfiles = asNullableRecord(browser.profiles);
36-
if (!configuredProfiles) {
37-
return [...profiles.values()].toSorted((a, b) => a.name.localeCompare(b.name));
38-
}
39-
40-
for (const [profileName, rawProfile] of Object.entries(configuredProfiles)) {
41-
const profile = asNullableRecord(rawProfile);
42-
const driver = normalizeOptionalString(profile?.driver) ?? "";
43-
if (driver === "existing-session") {
44-
profiles.set(profileName, {
45-
name: profileName,
46-
userDataDir: normalizeOptionalString(profile?.userDataDir),
47-
});
48-
}
49-
}
12+
type BrowserDoctorSurface = {
13+
noteChromeMcpBrowserReadiness: (cfg: OpenClawConfig, deps?: BrowserDoctorDeps) => Promise<void>;
14+
};
5015

51-
return [...profiles.values()].toSorted((a, b) => a.name.localeCompare(b.name));
16+
function loadBrowserDoctorSurface(): BrowserDoctorSurface {
17+
return loadBundledPluginPublicSurfaceModuleSync<BrowserDoctorSurface>({
18+
dirName: "browser",
19+
artifactBasename: "browser-doctor.js",
20+
});
5221
}
5322

54-
export async function noteChromeMcpBrowserReadiness(
55-
cfg: OpenClawConfig,
56-
deps?: {
57-
platform?: NodeJS.Platform;
58-
noteFn?: typeof note;
59-
resolveChromeExecutable?: (platform: NodeJS.Platform) => { path: string } | null;
60-
readVersion?: (executablePath: string) => string | null;
61-
},
62-
) {
63-
const profiles = collectChromeMcpProfiles(cfg);
64-
if (profiles.length === 0) {
65-
return;
23+
export async function noteChromeMcpBrowserReadiness(cfg: OpenClawConfig, deps?: BrowserDoctorDeps) {
24+
try {
25+
await loadBrowserDoctorSurface().noteChromeMcpBrowserReadiness(cfg, deps);
26+
} catch (error) {
27+
const noteFn = deps?.noteFn ?? note;
28+
const message = error instanceof Error ? error.message : String(error);
29+
noteFn(`- Browser health check is unavailable: ${message}`, "Browser");
6630
}
67-
68-
const noteFn = deps?.noteFn ?? note;
69-
const platform = deps?.platform ?? process.platform;
70-
const resolveChromeExecutable =
71-
deps?.resolveChromeExecutable ?? resolveGoogleChromeExecutableForPlatform;
72-
const readVersion = deps?.readVersion ?? readBrowserVersion;
73-
const explicitProfiles = profiles.filter((profile) => profile.userDataDir);
74-
const autoConnectProfiles = profiles.filter((profile) => !profile.userDataDir);
75-
const profileLabel = profiles.map((profile) => profile.name).join(", ");
76-
77-
if (autoConnectProfiles.length === 0) {
78-
noteFn(
79-
[
80-
`- Chrome MCP existing-session is configured for profile(s): ${profileLabel}.`,
81-
"- These profiles use an explicit Chromium user data directory instead of Chrome's default auto-connect path.",
82-
`- Verify the matching Chromium-based browser is version ${CHROME_MCP_MIN_MAJOR}+ on the same host as the Gateway or node.`,
83-
`- Enable remote debugging in that browser's inspect page (${REMOTE_DEBUGGING_PAGES}).`,
84-
"- Keep the browser running and accept the attach consent prompt the first time OpenClaw connects.",
85-
].join("\n"),
86-
"Browser",
87-
);
88-
return;
89-
}
90-
91-
const chrome = resolveChromeExecutable(platform);
92-
const autoProfileLabel = autoConnectProfiles.map((profile) => profile.name).join(", ");
93-
94-
if (!chrome) {
95-
const lines = [
96-
`- Chrome MCP existing-session is configured for profile(s): ${profileLabel}.`,
97-
`- Google Chrome was not found on this host for auto-connect profile(s): ${autoProfileLabel}. OpenClaw does not bundle Chrome.`,
98-
`- Install Google Chrome ${CHROME_MCP_MIN_MAJOR}+ on the same host as the Gateway or node, or set browser.profiles.<name>.userDataDir for a different Chromium-based browser.`,
99-
`- Enable remote debugging in the browser inspect page (${REMOTE_DEBUGGING_PAGES}).`,
100-
"- Keep the browser running and accept the attach consent prompt the first time OpenClaw connects.",
101-
"- Docker, headless, and sandbox browser flows stay on raw CDP; this check only applies to host-local Chrome MCP attach.",
102-
];
103-
if (explicitProfiles.length > 0) {
104-
lines.push(
105-
`- Profiles with explicit userDataDir skip Chrome auto-detection: ${explicitProfiles
106-
.map((profile) => profile.name)
107-
.join(", ")}.`,
108-
);
109-
}
110-
noteFn(lines.join("\n"), "Browser");
111-
return;
112-
}
113-
114-
const versionRaw = readVersion(chrome.path);
115-
const major = parseBrowserMajorVersion(versionRaw);
116-
const lines = [
117-
`- Chrome MCP existing-session is configured for profile(s): ${profileLabel}.`,
118-
`- Chrome path: ${chrome.path}`,
119-
];
120-
121-
if (!versionRaw || major === null) {
122-
lines.push(
123-
`- Could not determine the installed Chrome version. Chrome MCP requires Google Chrome ${CHROME_MCP_MIN_MAJOR}+ on this host.`,
124-
);
125-
} else if (major < CHROME_MCP_MIN_MAJOR) {
126-
lines.push(
127-
`- Detected Chrome ${versionRaw}, which is too old for Chrome MCP existing-session attach. Upgrade to Chrome ${CHROME_MCP_MIN_MAJOR}+.`,
128-
);
129-
} else {
130-
lines.push(`- Detected Chrome ${versionRaw}.`);
131-
}
132-
133-
lines.push(`- Enable remote debugging in the browser inspect page (${REMOTE_DEBUGGING_PAGES}).`);
134-
lines.push(
135-
"- Keep the browser running and accept the attach consent prompt the first time OpenClaw connects.",
136-
);
137-
if (explicitProfiles.length > 0) {
138-
lines.push(
139-
`- Profiles with explicit userDataDir still need manual validation of the matching Chromium-based browser: ${explicitProfiles
140-
.map((profile) => profile.name)
141-
.join(", ")}.`,
142-
);
143-
}
144-
145-
noteFn(lines.join("\n"), "Browser");
14631
}

src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import fs from "node:fs";
22
import os from "node:os";
33
import path from "node:path";
4-
import { beforeEach, describe, expect, it } from "vitest";
4+
import { beforeEach, describe, expect, it, vi } from "vitest";
55
import {
66
createDoctorRuntime,
77
ensureAuthProfileStore,
@@ -106,6 +106,43 @@ describe("doctor command", () => {
106106
expect(String(stateNote?.[0])).toContain("CRITICAL");
107107
});
108108

109+
it("routes browser readiness through health contributions and degrades gracefully when browser facade is unavailable", async () => {
110+
const loadBundledPluginPublicSurfaceModuleSync = vi.fn(() => {
111+
throw new Error("missing browser doctor facade");
112+
});
113+
vi.doMock("../plugin-sdk/facade-loader.js", () => ({
114+
loadBundledPluginPublicSurfaceModuleSync,
115+
}));
116+
doctorCommand = await loadDoctorCommandForTest({
117+
unmockModules: [
118+
"../flows/doctor-health-contributions.js",
119+
"./doctor-browser.js",
120+
"./doctor-state-integrity.js",
121+
],
122+
});
123+
124+
mockDoctorConfigSnapshot({
125+
config: {
126+
browser: {
127+
defaultProfile: "user",
128+
},
129+
},
130+
});
131+
132+
await runDoctorNonInteractive();
133+
134+
expect(loadBundledPluginPublicSurfaceModuleSync).toHaveBeenCalledWith({
135+
dirName: "browser",
136+
artifactBasename: "browser-doctor.js",
137+
});
138+
const browserFallbackNote = terminalNoteMock.mock.calls.find(
139+
([message, title]) =>
140+
title === "Browser" && String(message).includes("Browser health check is unavailable"),
141+
);
142+
expect(browserFallbackNote).toBeTruthy();
143+
expect(String(browserFallbackNote?.[0])).toContain("missing browser doctor facade");
144+
});
145+
109146
it("warns about opencode provider overrides", async () => {
110147
mockDoctorConfigSnapshot({
111148
config: {

0 commit comments

Comments
 (0)