fix(browser): warn when Chrome extension version drifts after upgrades (#119641)

* fix(browser): detect Chrome extension version drift

* fix(browser): diagnose paired Chrome extension version drift

Co-authored-by: shaoohh <150606856+shaoohh@users.noreply.github.com>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
shaoohh
2026-08-27 01:42:57 +08:00
committed by GitHub
parent f498727bbd
commit db4d42ead2
9 changed files with 441 additions and 47 deletions
+4
View File
@@ -387,6 +387,10 @@ openclaw doctor
Migrates config, audits DM policies, and checks gateway health. Details: [Doctor](/gateway/doctor)
If you use the unpacked Chrome extension, also run `openclaw browser doctor --browser-profile chrome`.
For a version-mismatch warning, reload the extension from `chrome://extensions`;
fully restart Chrome if the warning remains.
### Restart the gateway
```bash
+3
View File
@@ -301,6 +301,9 @@ openclaw doctor
development fallback after the command says native bootstrap is ready.
- **Extension was loaded before native setup:** restart Chrome once to clear its
cached native-host miss, then rerun the ordered install flow.
- **Extension version mismatch:** reload the unpacked OpenClaw extension from
`chrome://extensions`, then rerun browser doctor. Fully restart Chrome if the
running and bundled versions still differ.
- **Waiting for local OpenClaw:** run `extension status`; install or repair the
owned native host.
- **Automatic setup disabled:** enable it in Settings or click **Use local
@@ -19,6 +19,7 @@ import { createBrowserRouteDispatcher } from "../src/browser/routes/dispatcher.j
import { createBrowserRouteContext } from "../src/browser/server-context.js";
import { getFreePort } from "../src/browser/test-port.js";
import { getBrowserControlState, stopBrowserControlService } from "../src/control-service.js";
import chromeExtensionManifest from "./manifest.json" with { type: "json" };
import { relayTestKey } from "./relay-key.test-support.js";
declare const chrome: {
@@ -387,6 +388,24 @@ describe.runIf(runE2E)("Chrome native bootstrap Chromium E2E", () => {
refreshConfigFromDisk: false,
});
const dispatcher = createBrowserRouteDispatcher(routeContext);
const matchingDoctor = await dispatcher.dispatch({
method: "GET",
path: "/doctor",
query: { profile: "e2e" },
});
expect(matchingDoctor.status).toBe(200);
expect(matchingDoctor.body).toMatchObject({
checks: expect.arrayContaining([
expect.objectContaining({
id: "extension-version",
status: "pass",
summary: `running ${chromeExtensionManifest.version}; bundled ${chromeExtensionManifest.version} (match)`,
}),
]),
});
process.stderr.write(
`[browser-extension-e2e] doctor version match ${chromeExtensionManifest.version}\n`,
);
const tabsResponse = await dispatcher.dispatch({
method: "GET",
path: "/tabs",
@@ -632,8 +651,45 @@ describe.runIf(runE2E)("Chrome native bootstrap Chromium E2E", () => {
.poll(() => relay.bridge.accessibleTabs().some((tab) => tab.tabId === tabId))
.toBe(false);
const installedManifestPath = path.join(installed, "manifest.json");
const installedManifest = JSON.parse(await fs.readFile(installedManifestPath, "utf8")) as {
version: string;
};
const outdatedVersion = chromeExtensionManifest.version === "2.0.0" ? "1.0.0" : "2.0.0";
await fs.writeFile(
installedManifestPath,
`${JSON.stringify({ ...installedManifest, version: outdatedVersion }, null, 2)}\n`,
);
await context.close();
context = await launchChromium();
await loadUnpackedExtension(context, installed);
expect(await waitForExtensionId(context, installed)).toBe(extensionId);
const outdatedExtensionPage = await context.newPage();
await outdatedExtensionPage.goto(`chrome-extension://${extensionId}/options.html`);
await expect.poll(() => relay.bridge.identity?.extensionVersion).toBe(outdatedVersion);
const outdatedDoctor = await dispatcher.dispatch({
method: "GET",
path: "/doctor",
query: { profile: "e2e" },
});
expect(outdatedDoctor.status).toBe(200);
expect(outdatedDoctor.body).toMatchObject({
ok: true,
checks: expect.arrayContaining([
expect.objectContaining({
id: "extension-version",
status: "warn",
summary: `running ${outdatedVersion}; bundled ${chromeExtensionManifest.version} (mismatch)`,
fixHint: expect.stringMatching(/reload/i),
}),
]),
});
process.stderr.write(
`[browser-extension-e2e] doctor version mismatch running=${outdatedVersion} bundled=${chromeExtensionManifest.version} status=WARN\n`,
);
const extensionContext = routeContext.forProfile("e2e");
await extensionPage.evaluate(
await outdatedExtensionPage.evaluate(
async () => await chrome.runtime.sendMessage({ type: "unpair" }),
);
await expect.poll(() => relay.bridge.extensionConnected).toBe(false);
@@ -1,7 +1,14 @@
// Browser tests cover doctor plugin behavior.
import { describe, expect, it } from "vitest";
import chromeExtensionManifest from "../../chrome-extension/manifest.json" with { type: "json" };
import { buildBrowserDoctorReport } from "./doctor.js";
const outdatedExtensionVersion = chromeExtensionManifest.version === "2.0.0" ? "1.0.0" : "2.0.0";
const equivalentExtensionVersion =
chromeExtensionManifest.version.split(".").length < 4
? `${chromeExtensionManifest.version}.0`
: chromeExtensionManifest.version.replace(/\.0$/, "");
function collectWarningCheckIds(checks: readonly { id: string; status: string }[]): string[] {
const ids: string[] = [];
for (const check of checks) {
@@ -46,6 +53,7 @@ describe("buildBrowserDoctorReport", () => {
const websocketCheck = report.checks.find((check) => check.id === "cdp-websocket");
expect(websocketCheck?.status).toBe("info");
expect(websocketCheck?.summary).toBe("Browser is launchable but not running");
expect(report.checks.find((check) => check.id === "extension-version")).toBeUndefined();
});
it("fails when Chrome MCP attach is not ready", () => {
@@ -77,6 +85,7 @@ describe("buildBrowserDoctorReport", () => {
expect(report.ok).toBe(false);
const attachCheck = report.checks.find((check) => check.id === "attach-target");
expect(attachCheck?.status).toBe("fail");
expect(report.checks.find((check) => check.id === "extension-version")).toBeUndefined();
});
it("keeps managed launch warnings non-fatal", () => {
@@ -242,4 +251,48 @@ describe("buildBrowserDoctorReport", () => {
summary: "unavailable: SystemInfo domain unavailable",
});
});
it.each([
["outdated", outdatedExtensionVersion, "warn"],
["current", chromeExtensionManifest.version, "pass"],
["equivalent missing version component", equivalentExtensionVersion, "pass"],
["maximum valid version", "65535.65535.65535.65535", "warn"],
["unavailable", undefined, "info"],
["terminal-control input", "2.0.0\u001b[31m", "info"],
["oversized version component", "65536.0", "info"],
["nonzero leading zero", "02.0.0", "info"],
["all-zero version", "0.0.0.0", "info"],
["too many version components", "2.0.0.0.0", "info"],
] as const)("classifies %s extension version evidence", (_label, extensionVersion, severity) => {
const report = buildBrowserDoctorReport({
status: {
enabled: true,
profile: "chrome",
driver: "extension",
transport: "extension",
running: true,
pid: null,
cdpPort: 18792,
chosenBrowser: null,
userDataDir: null,
color: "#00AA00",
headless: false,
attachOnly: true,
},
extensionVersion,
});
const versionCheck = report.checks.find((check) => check.id === "extension-version");
expect(versionCheck?.status).toBe(severity);
if (severity === "warn") {
expect(versionCheck?.summary).toContain(
`running ${extensionVersion}; bundled ${chromeExtensionManifest.version}`,
);
expect(versionCheck?.fixHint).toMatch(/reload/i);
} else {
expect(versionCheck?.fixHint).toBeUndefined();
expect(versionCheck?.summary).not.toContain("\u001b");
}
expect(report.ok).toBe(true);
});
});
+44
View File
@@ -4,6 +4,7 @@
* Turns BrowserStatus into profile-aware diagnostic checks and fix hints for
* CLI, tool, and HTTP doctor responses.
*/
import chromeExtensionManifest from "../../chrome-extension/manifest.json" with { type: "json" };
import { formatBrowserGraphicsSummary } from "./chrome.graphics.js";
import type { BrowserStatus, BrowserTransport } from "./client.types.js";
@@ -27,9 +28,24 @@ export type BrowserDoctorReport = {
status: BrowserStatus;
};
function isChromeExtensionVersion(value: unknown): value is string {
if (typeof value !== "string") {
return false;
}
const components = value.split(".");
return (
components.length <= 4 &&
components.every(
(component) => /^(?:0|[1-9]\d{0,4})$/.test(component) && Number(component) <= 65_535,
) &&
components.some((component) => component !== "0")
);
}
/** Build a browser doctor report from a status response and environment facts. */
export function buildBrowserDoctorReport(params: {
status: BrowserStatus;
extensionVersion?: string;
platform?: NodeJS.Platform;
env?: NodeJS.ProcessEnv;
uid?: number;
@@ -88,6 +104,34 @@ export function buildBrowserDoctorReport(params: {
"Install the OpenClaw Chrome extension (openclaw browser extension path), run openclaw browser extension pair, and paste the pairing string into the extension popup.",
}),
});
const runningVersion = isChromeExtensionVersion(params.extensionVersion)
? params.extensionVersion
: undefined;
const bundledVersion = isChromeExtensionVersion(chromeExtensionManifest.version)
? chromeExtensionManifest.version
: undefined;
// Chrome treats absent version components as zero, so trailing zeroes do not indicate drift.
const mismatch = Boolean(
runningVersion &&
bundledVersion &&
runningVersion.replace(/(?:\.0)+$/, "") !== bundledVersion.replace(/(?:\.0)+$/, ""),
);
checks.push({
id: "extension-version",
label: "Chrome extension version",
status: !runningVersion || !bundledVersion ? "info" : mismatch ? "warn" : "pass",
summary:
runningVersion && bundledVersion
? `running ${runningVersion}; bundled ${bundledVersion} (${mismatch ? "mismatch" : "match"})`
: "version data unavailable",
...(mismatch
? {
fixHint:
"Reload the OpenClaw extension from chrome://extensions. If the versions still differ, fully quit and reopen Chrome.",
}
: {}),
});
} else {
checks.push({
id: "managed-executable",
@@ -1,5 +1,6 @@
// Browser tests cover basic.existing session plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
import chromeExtensionManifest from "../../../chrome-extension/manifest.json" with { type: "json" };
import { createBrowserRouteApp, createBrowserRouteResponse } from "./test-helpers.js";
const { inspectChromeGraphicsDiagnosticsMock } = vi.hoisted(() => ({
@@ -214,6 +215,43 @@ describe("basic browser routes", () => {
inspectChromeGraphicsDiagnosticsMock.mockReset();
});
it("reports version drift only from the selected extension profile owner", async () => {
const outdatedVersion = chromeExtensionManifest.version === "2.0.0" ? "1.0.0" : "2.0.0";
const state = {
...createManagedProfileState(
{ name: "chrome", driver: "extension", attachOnly: true },
{
isHttpReachable: async () => true,
isTransportAvailable: async () => true,
},
),
extensionRelays: new Map([
["chrome", { bridge: { identity: { extensionVersion: outdatedVersion } } }],
["other", { bridge: { identity: { extensionVersion: chromeExtensionManifest.version } } }],
]),
};
const response = await callBasicRouteWithState({
route: "/doctor",
query: { profile: "chrome" },
state,
});
const report = responseBodyRecord(response);
expect(response.statusCode).toBe(200);
expect(report.checks).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "extension-version",
status: "warn",
summary: expect.stringContaining(
`running ${outdatedVersion}; bundled ${chromeExtensionManifest.version}`,
),
}),
]),
);
expect(report.status).not.toHaveProperty("chromeExtension");
});
it("releases the doctor transaction, restarts once, and retries the live probe", async () => {
const ensureBrowserAvailable = vi.fn(async () => {});
const ensureTabAvailable = vi
@@ -397,7 +397,14 @@ export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: Brow
signal: req.signal,
run: async (signal) => {
const status = await buildBrowserStatus(ctx, profileCtx, signal);
const doctorReport = buildBrowserDoctorReport({ status });
const doctorReport = buildBrowserDoctorReport({
status,
extensionVersion:
status.transport === "extension"
? ctx.state().extensionRelays?.get(profileCtx.profile.name)?.bridge.identity
?.extensionVersion
: undefined,
});
if (toBoolean(req.query.deep) === true || toBoolean(req.query.live) === true) {
doctorReport.checks.push(await runBrowserLiveProbe(profileCtx, signal));
doctorReport.ok = doctorReport.checks.every((check) => check.status !== "fail");
@@ -490,41 +490,203 @@ describe("browser manage output", () => {
expect(getBrowserManageCallBrowserRequestMock()).not.toHaveBeenCalled();
});
it("prints authenticated extension drift from the canonical browser doctor report", async () => {
getBrowserManageCallBrowserRequestMock().mockImplementation(async (_opts: unknown, req) => {
if (req.path === "/doctor") {
return {
ok: true,
checks: [
{
id: "extension-version",
label: "Chrome extension version",
status: "warn",
summary: "running 2.0.0; bundled 2.2.0 (mismatch)",
fixHint: "Reload the OpenClaw extension.",
},
],
status: {
enabled: true,
profile: "chrome",
driver: "extension",
transport: "extension",
running: true,
cdpReady: true,
},
};
}
if (req.path === "/profiles") {
return { profiles: [{ name: "chrome", running: true }] };
}
if (req.path === "/tabs") {
return { running: true, tabs: [] };
}
throw new Error(`unexpected browser route: ${req.path}`);
});
const program = createBrowserManageProgram();
await program.parseAsync(["browser", "--browser-profile", "chrome", "doctor"], {
from: "user",
});
expect(lastRuntimeLog()).toContain(
"WARN extension-version: running 2.0.0; bundled 2.2.0 (mismatch); Reload the OpenClaw extension.",
);
expect(process.exitCode).toBeUndefined();
expect(getBrowserManageCallBrowserRequestMock().mock.calls[0]?.[1]).toMatchObject({
path: "/doctor",
query: { profile: "chrome" },
});
});
it("keeps unavailable extension version evidence informational and nonfatal", async () => {
getBrowserManageCallBrowserRequestMock().mockImplementation(async (_opts: unknown, req) => {
if (req.path === "/doctor") {
return {
checks: [
{
id: "extension-version",
label: "Chrome extension version",
status: "info",
summary: "version data unavailable",
},
],
status: {
enabled: true,
profile: "chrome",
transport: "extension",
running: true,
},
};
}
return req.path === "/profiles"
? { profiles: [{ name: "chrome", running: true }] }
: { running: true, tabs: [] };
});
const program = createBrowserManageProgram();
await program.parseAsync(["browser", "--browser-profile", "chrome", "doctor"], {
from: "user",
});
expect(lastRuntimeLog()).toContain("INFO extension-version: version data unavailable");
expect(lastRuntimeLog()).not.toContain("WARN extension-version");
expect(process.exitCode).toBeUndefined();
});
it("preserves one nonfatal JSON report for confirmed extension version drift", async () => {
getBrowserManageCallBrowserRequestMock().mockImplementation(async (_opts: unknown, req) => {
if (req.path === "/doctor") {
return {
checks: [
{
id: "extension-version",
label: "Chrome extension version",
status: "warn",
summary: "running 2.0.0; bundled 2.2.0 (mismatch)",
fixHint: "Reload the OpenClaw extension.",
},
],
status: {
enabled: true,
profile: "chrome",
transport: "extension",
running: true,
},
};
}
return req.path === "/profiles"
? { profiles: [{ name: "chrome", running: true }] }
: { running: true, tabs: [] };
});
const program = createBrowserManageProgram();
await program.parseAsync(["browser", "--json", "doctor"], { from: "user" });
expect(parseSingleRuntimeJson()).toMatchObject({
ok: true,
checks: expect.arrayContaining([
expect.objectContaining({ name: "extension-version", ok: true, warning: true }),
]),
});
expect(getBrowserCliRuntime().writeJson).toHaveBeenCalledTimes(1);
expect(process.exitCode).toBeUndefined();
});
it("runs exactly one deep snapshot after consuming the canonical doctor report", async () => {
getBrowserManageCallBrowserRequestMock().mockImplementation(async (_opts: unknown, req) => {
if (req.path === "/doctor") {
return {
checks: [],
status: {
enabled: true,
profile: "chrome",
transport: "extension",
running: true,
},
};
}
if (req.path === "/profiles") {
return { profiles: [{ name: "chrome", running: true }] };
}
if (req.path === "/tabs") {
return { running: true, tabs: [] };
}
if (req.path === "/snapshot") {
return { ok: true, format: "aria", nodes: [{ role: "document" }] };
}
throw new Error(`unexpected browser route: ${req.path}`);
});
const program = createBrowserManageProgram();
await program.parseAsync(["browser", "--browser-profile", "chrome", "doctor", "--deep"], {
from: "user",
});
expect(lastRuntimeLog()).toContain("OK live-snapshot: 1 nodes/lines");
const snapshotCalls = getBrowserManageCallBrowserRequestMock().mock.calls.filter(
([, request]) => request.path === "/snapshot",
);
expect(snapshotCalls).toHaveLength(1);
});
it("prints a readable browser doctor report", async () => {
getBrowserManageCallBrowserRequestMock().mockImplementation(async (_opts: unknown, req) => {
if (req.path === "/") {
if (req.path === "/doctor") {
return {
enabled: true,
profile: "openclaw",
driver: "openclaw",
transport: "cdp",
running: true,
cdpReady: true,
cdpHttp: true,
pid: 4321,
cdpPort: 18792,
cdpUrl: "http://127.0.0.1:18792",
chosenBrowser: "chrome",
userDataDir: null,
color: "#00AA00",
headless: false,
noSandbox: false,
executablePath: null,
attachOnly: false,
graphics: {
status: "available",
observedAt: 123,
acceleration: "software",
renderer: "ANGLE (Google, SwiftShader Device)",
vendor: "Google Inc.",
version: "OpenGL ES 3.0",
backend: "(gl=angle,angle=swiftshader)",
devices: [],
featureStatus: {},
disabledFeatures: [],
driverBugWorkarounds: [],
videoDecoding: [],
videoEncoding: [],
checks: [],
status: {
enabled: true,
profile: "openclaw",
driver: "openclaw",
transport: "cdp",
running: true,
cdpReady: true,
cdpHttp: true,
pid: 4321,
cdpPort: 18792,
cdpUrl: "http://127.0.0.1:18792",
chosenBrowser: "chrome",
userDataDir: null,
color: "#00AA00",
headless: false,
noSandbox: false,
executablePath: null,
attachOnly: false,
graphics: {
status: "available",
observedAt: 123,
acceleration: "software",
renderer: "ANGLE (Google, SwiftShader Device)",
vendor: "Google Inc.",
version: "OpenGL ES 3.0",
backend: "(gl=angle,angle=swiftshader)",
devices: [],
featureStatus: {},
disabledFeatures: [],
driverBugWorkarounds: [],
videoDecoding: [],
videoEncoding: [],
},
},
};
}
@@ -562,12 +724,15 @@ describe("browser manage output", () => {
it("prints one complete JSON browser doctor failure before setting exit status", async () => {
getBrowserManageCallBrowserRequestMock().mockImplementation(async (_opts: unknown, req) => {
if (req.path === "/") {
if (req.path === "/doctor") {
return {
enabled: false,
profile: "openclaw",
transport: "cdp",
running: false,
checks: [],
status: {
enabled: false,
profile: "openclaw",
transport: "cdp",
running: false,
},
};
}
if (req.path === "/profiles") {
@@ -596,12 +761,15 @@ describe("browser manage output", () => {
it("prints one JSON browser doctor report and succeeds when every check passes", async () => {
getBrowserManageCallBrowserRequestMock().mockImplementation(async (_opts: unknown, req) => {
if (req.path === "/") {
if (req.path === "/doctor") {
return {
enabled: true,
profile: "openclaw",
transport: "cdp",
running: true,
checks: [],
status: {
enabled: true,
profile: "openclaw",
transport: "cdp",
running: true,
},
};
}
if (req.path === "/profiles") {
@@ -4,6 +4,7 @@
*/
import type { Command } from "commander";
import { formatBrowserGraphicsSummary } from "../browser/chrome.graphics.js";
import type { BrowserDoctorReport } from "../browser/doctor.js";
import {
BROWSER_TAB_REFERENCE_HELP,
callBrowserRequest,
@@ -37,6 +38,7 @@ type BrowserDoctorCheck = {
ok: boolean;
detail?: string;
warning?: boolean;
info?: boolean;
};
function sanitizeTableCell(value: string): string {
@@ -132,7 +134,7 @@ function logBrowserTabs(tabs: BrowserTab[], json?: boolean) {
}
function formatDoctorLine(check: BrowserDoctorCheck): string {
const prefix = check.warning ? "WARN" : check.ok ? "OK" : "FAIL";
const prefix = check.warning ? "WARN" : check.info ? "INFO" : check.ok ? "OK" : "FAIL";
return `${prefix} ${check.name}${check.detail ? `: ${check.detail}` : ""}`;
}
@@ -156,10 +158,18 @@ function formatBrowserDoctorGatewayError(error: unknown): string {
async function runBrowserDoctor(parent: BrowserParentOpts, profile?: string, deep?: boolean) {
const checks: BrowserDoctorCheck[] = [];
let status: BrowserStatus | null;
let report: BrowserDoctorReport;
try {
status = await fetchBrowserStatus(parent, profile);
report = await callBrowserRequest<BrowserDoctorReport>(
parent,
{
method: "GET",
path: "/doctor",
query: resolveProfileQuery(profile),
},
{ timeoutMs: BROWSER_MANAGE_REQUEST_TIMEOUT_MS },
);
checks.push({
name: "gateway",
ok: true,
@@ -174,6 +184,7 @@ async function runBrowserDoctor(parent: BrowserParentOpts, profile?: string, dee
return { ok: false, checks };
}
const status = report.status;
checks.push({
name: "plugin",
ok: status.enabled,
@@ -191,6 +202,16 @@ async function runBrowserDoctor(parent: BrowserParentOpts, profile?: string, dee
? `running${status.cdpReady === false ? ", CDP not ready" : ""}`
: "not running; run `openclaw browser start`",
});
const extensionVersionCheck = report.checks.find((check) => check.id === "extension-version");
if (extensionVersionCheck) {
checks.push({
name: extensionVersionCheck.id,
ok: extensionVersionCheck.status !== "fail",
warning: extensionVersionCheck.status === "warn",
info: extensionVersionCheck.status === "info",
detail: `${extensionVersionCheck.summary}${extensionVersionCheck.fixHint ? `; ${extensionVersionCheck.fixHint}` : ""}`,
});
}
if (status.graphics) {
checks.push({
name: "graphics",