fix: ease bundled browser plugin recovery

This commit is contained in:
Peter Steinberger
2026-03-29 22:39:13 +09:00
parent f1af7d66d2
commit 2dd29db464
6 changed files with 198 additions and 0 deletions

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
rewriteUpdateFlagArgv,
resolveMissingBrowserCommandMessage,
shouldEnsureCliPath,
shouldRegisterPrimarySubcommand,
shouldSkipPluginCommandRegistration,
@@ -136,3 +137,39 @@ describe("shouldUseRootHelpFastPath", () => {
expect(shouldUseRootHelpFastPath(["node", "openclaw", "--help", "status"])).toBe(false);
});
});
describe("resolveMissingBrowserCommandMessage", () => {
it("explains plugins.allow misses for the browser command", () => {
expect(
resolveMissingBrowserCommandMessage({
plugins: {
allow: ["telegram"],
},
}),
).toContain('`plugins.allow` excludes "browser"');
});
it("explains explicit bundled browser disablement", () => {
expect(
resolveMissingBrowserCommandMessage({
plugins: {
entries: {
browser: {
enabled: false,
},
},
},
}),
).toContain("plugins.entries.browser.enabled=false");
});
it("returns null when browser is already allowed", () => {
expect(
resolveMissingBrowserCommandMessage({
plugins: {
allow: ["browser"],
},
}),
).toBeNull();
});
});

View File

@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
import type { OpenClawConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import { normalizeEnv } from "../infra/env.js";
import { formatUncaughtError } from "../infra/errors.js";
@@ -9,6 +10,7 @@ import { isMainModule } from "../infra/is-main.js";
import { ensureOpenClawCliOnPath } from "../infra/path-env.js";
import { assertSupportedRuntime } from "../infra/runtime-guard.js";
import { enableConsoleCapture } from "../logging.js";
import { normalizePluginId } from "../plugins/config-state.js";
import { hasMemoryRuntime } from "../plugins/memory-state.js";
import {
getCommandPathWithRootOptions,
@@ -86,6 +88,28 @@ export function shouldUseRootHelpFastPath(argv: string[]): boolean {
return isRootHelpInvocation(argv);
}
export function resolveMissingBrowserCommandMessage(config?: OpenClawConfig): string | null {
const allow =
Array.isArray(config?.plugins?.allow) && config.plugins.allow.length > 0
? config.plugins.allow
.filter((entry): entry is string => typeof entry === "string")
.map((entry) => normalizePluginId(entry))
: [];
if (allow.length > 0 && !allow.includes("browser")) {
return (
'The `openclaw browser` command is unavailable because `plugins.allow` excludes "browser". ' +
'Add "browser" to `plugins.allow` if you want the bundled browser CLI and tool.'
);
}
if (config?.plugins?.entries?.browser?.enabled === false) {
return (
"The `openclaw browser` command is unavailable because `plugins.entries.browser.enabled=false`. " +
"Re-enable that entry if you want the bundled browser CLI and tool."
);
}
return null;
}
function shouldLoadCliDotEnv(env: NodeJS.ProcessEnv = process.env): boolean {
if (existsSync(path.join(process.cwd(), ".env"))) {
return true;
@@ -190,6 +214,15 @@ export async function runCli(argv: string[] = process.argv) {
const config = await loadValidatedConfigForPluginRegistration();
if (config) {
registerPluginCliCommands(program, config);
if (
primary === "browser" &&
!program.commands.some((command) => command.name() === "browser")
) {
const browserCommandMessage = resolveMissingBrowserCommandMessage(config);
if (browserCommandMessage) {
throw new Error(browserCommandMessage);
}
}
}
}

View File

@@ -286,6 +286,24 @@ describe("doctor config flow", () => {
).toBe("existing-session");
});
it("repairs restrictive plugins.allow when browser is referenced via tools.alsoAllow", async () => {
const result = await runDoctorConfigWithInput({
repair: true,
config: {
tools: {
alsoAllow: ["browser"],
},
plugins: {
allow: ["telegram"],
},
},
run: loadAndMaybeMigrateDoctorConfig,
});
expect(result.cfg.plugins?.allow).toEqual(["telegram", "browser"]);
expect(result.cfg.plugins?.entries?.browser?.enabled).toBe(true);
});
it("previews Matrix legacy sync-store migration in read-only mode", async () => {
const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
try {

View File

@@ -135,6 +135,57 @@ describe("applyPluginAutoEnable", () => {
expect(result.config.plugins?.allow).toBeUndefined();
});
it("auto-enables browser when browser config exists under a restrictive plugins.allow", () => {
const result = applyPluginAutoEnable({
config: {
browser: {
defaultProfile: "openclaw",
},
plugins: {
allow: ["telegram"],
},
},
env: {},
});
expect(result.config.plugins?.allow).toEqual(["telegram", "browser"]);
expect(result.config.plugins?.entries?.browser?.enabled).toBe(true);
expect(result.changes).toContain("browser configured, enabled automatically.");
});
it("auto-enables browser when tools.alsoAllow references browser", () => {
const result = applyPluginAutoEnable({
config: {
tools: {
alsoAllow: ["browser"],
},
plugins: {
allow: ["telegram"],
},
},
env: {},
});
expect(result.config.plugins?.allow).toEqual(["telegram", "browser"]);
expect(result.config.plugins?.entries?.browser?.enabled).toBe(true);
expect(result.changes).toContain("browser tool referenced, enabled automatically.");
});
it("keeps restrictive plugins.allow unchanged when browser is not referenced", () => {
const result = applyPluginAutoEnable({
config: {
plugins: {
allow: ["telegram"],
},
},
env: {},
});
expect(result.config.plugins?.allow).toEqual(["telegram"]);
expect(result.config.plugins?.entries?.browser).toBeUndefined();
expect(result.changes).toEqual([]);
});
it("ignores channels.modelByChannel for plugin auto-enable", () => {
const result = applyPluginAutoEnable({
config: {

View File

@@ -319,6 +319,59 @@ function configMayNeedPluginManifestRegistry(cfg: OpenClawConfig): boolean {
return false;
}
function listContainsBrowser(value: unknown): boolean {
return (
Array.isArray(value) &&
value.some((entry) => typeof entry === "string" && entry.trim().toLowerCase() === "browser")
);
}
function toolPolicyReferencesBrowser(value: unknown): boolean {
if (!isRecord(value)) {
return false;
}
return listContainsBrowser(value.allow) || listContainsBrowser(value.alsoAllow);
}
function hasBrowserToolReference(cfg: OpenClawConfig): boolean {
if (toolPolicyReferencesBrowser(cfg.tools)) {
return true;
}
const agentList = cfg.agents?.list;
if (!Array.isArray(agentList)) {
return false;
}
return agentList.some((entry) => isRecord(entry) && toolPolicyReferencesBrowser(entry.tools));
}
function hasExplicitBrowserPluginEntry(cfg: OpenClawConfig): boolean {
return Boolean(
cfg.plugins?.entries && Object.prototype.hasOwnProperty.call(cfg.plugins.entries, "browser"),
);
}
function resolveBrowserAutoEnableReason(cfg: OpenClawConfig): string | null {
if (cfg.browser?.enabled === false || cfg.plugins?.entries?.browser?.enabled === false) {
return null;
}
if (Object.prototype.hasOwnProperty.call(cfg, "browser")) {
return "browser configured";
}
if (hasExplicitBrowserPluginEntry(cfg)) {
return "browser plugin configured";
}
if (hasBrowserToolReference(cfg)) {
return "browser tool referenced";
}
return null;
}
function resolveConfiguredPlugins(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv,
@@ -334,6 +387,11 @@ function resolveConfiguredPlugins(
}
}
const browserReason = resolveBrowserAutoEnableReason(cfg);
if (browserReason) {
changes.push({ pluginId: "browser", reason: browserReason });
}
for (const [providerId, pluginId] of Object.entries(
resolveAutoEnableProviderPluginIds(registry),
)) {