fix(browser): auto-start configured browser plugin

This commit is contained in:
Peter Steinberger
2026-04-27 09:36:58 +01:00
parent e792f96a84
commit f97cc58760
15 changed files with 259 additions and 23 deletions

View File

@@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
- Gateway/models: move local-provider pricing opt-outs, OpenRouter/LiteLLM aliases, and proxy passthrough pricing lookup into plugin manifest metadata so core no longer carries extension-specific pricing tables. Thanks @codex.
- Agents/sessions: acquire the session write lock only after cold bootstrap, plugin, and tool setup so fallback runs are not blocked by stalled pre-model startup work. Thanks @codex.
- Browser/plugins: auto-start the bundled browser plugin when root `browser` config is present, including restrictive plugin allowlists, and ignore stale persisted plugin registries whose package paths no longer exist. Thanks @codex.
- Gateway/models: skip external OpenRouter and LiteLLM pricing refreshes for local/self-hosted model endpoints so startup does not wait on remote pricing catalogs for local-only Ollama, vLLM, and compatible providers. Thanks @codex.
- CLI/plugins: stop security-blocked plugin installs from retrying as hook packs, so normal plugin packages report the scanner failure without a misleading "not a valid hook pack" follow-up. Fixes #61175; supersedes #64102. Thanks @KonsultDigital and @ziyincody.
- Agents/Anthropic: strip stale trailing assistant prefill turns from outbound replay so context-engine short circuits cannot send unsupported assistant-prefill payloads to provider APIs. Fixes #72556. Thanks @Veda-openclaw.

View File

@@ -85,8 +85,8 @@ Notes:
If `openclaw browser` is an unknown command, check `plugins.allow` in
`~/.openclaw/openclaw.json`.
When `plugins.allow` is present, the bundled browser plugin must be listed
explicitly:
When `plugins.allow` is present, list the bundled browser plugin explicitly
unless the config already has a root `browser` block:
```json5
{
@@ -96,8 +96,9 @@ explicitly:
}
```
`browser.enabled=true` does not restore the CLI subcommand when the plugin
allowlist excludes `browser`.
An explicit root `browser` block, for example `browser.enabled=true` or
`browser.profiles.<name>`, also activates the bundled browser plugin under a
restrictive plugin allowlist.
Related: [Browser tool](/tools/browser#missing-browser-command-or-tool)

View File

@@ -249,6 +249,7 @@ change correctness while legacy manifest ownership fallbacks still exist.
"onCommands": ["models"],
"onChannels": ["web"],
"onRoutes": ["gateway-webhook"],
"onConfigPaths": ["browser"],
"onCapabilities": ["provider", "tool"]
}
}
@@ -261,6 +262,7 @@ change correctness while legacy manifest ownership fallbacks still exist.
| `onCommands` | No | `string[]` | Command ids that should include this plugin in activation/load plans. |
| `onChannels` | No | `string[]` | Channel ids that should include this plugin in activation/load plans. |
| `onRoutes` | No | `string[]` | Route kinds that should include this plugin in activation/load plans. |
| `onConfigPaths` | No | `string[]` | Root-relative config paths that should include this plugin in startup/load plans when the path is present and not explicitly disabled. |
| `onCapabilities` | No | `Array<"provider" \| "channel" \| "tool" \| "hook">` | Broad capability hints used by control-plane activation planning. Prefer narrower fields when possible. |
Current live consumers:
@@ -271,6 +273,8 @@ Current live consumers:
embedded harnesses and top-level `cliBackends[]` for CLI runtime aliases
- channel-triggered setup/channel planning falls back to legacy `channels[]`
ownership when explicit channel activation metadata is missing
- startup plugin planning uses `activation.onConfigPaths` for non-channel root
config surfaces such as the bundled browser plugin's `browser` block
- provider-triggered setup/runtime planning falls back to legacy
`providers[]` and top-level `cliBackends[]` ownership when explicit provider
activation metadata is missing

View File

@@ -104,7 +104,7 @@ turns do not pay the full token cost.
## Missing browser command or tool
If `openclaw browser` is unknown after an upgrade, `browser.request` is missing, or the agent reports the browser tool as unavailable, the usual cause is a `plugins.allow` list that omits `browser`. Add it:
If `openclaw browser` is unknown after an upgrade, `browser.request` is missing, or the agent reports the browser tool as unavailable, the usual cause is a `plugins.allow` list that omits `browser` and no root `browser` config block exists. Add it:
```json5
{
@@ -114,7 +114,7 @@ If `openclaw browser` is unknown after an upgrade, `browser.request` is missing,
}
```
`browser.enabled=true`, `plugins.entries.browser.enabled=true`, and `tools.alsoAllow: ["browser"]` do not substitute for allowlist membership the allowlist gates plugin loading, and tool policy only runs after load. Removing `plugins.allow` entirely also restores the default.
An explicit root `browser` block, for example `browser.enabled=true` or `browser.profiles.<name>`, activates the bundled browser plugin even under a restrictive `plugins.allow`, matching channel config behavior. `plugins.entries.browser.enabled=true` and `tools.alsoAllow: ["browser"]` do not substitute for allowlist membership by themselves. Removing `plugins.allow` entirely also restores the default.
## Profiles: `openclaw` vs `user`

View File

@@ -1,6 +1,9 @@
{
"id": "browser",
"enabledByDefault": true,
"activation": {
"onConfigPaths": ["browser"]
},
"commandAliases": [{ "name": "browser" }],
"skills": ["./skills"],
"configSchema": {

View File

@@ -3,8 +3,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const applyPluginAutoEnable = vi.hoisted(() =>
vi.fn((params: { config: unknown }) => ({
config: params.config,
changes: [],
autoEnabledReasons: {},
changes: [] as string[],
autoEnabledReasons: {} as Record<string, string[]>,
})),
);
const initSubagentRegistry = vi.hoisted(() => vi.fn());

View File

@@ -128,21 +128,21 @@ export async function prepareGatewayPluginBootstrap(params: {
initSubagentRegistry();
const gatewayPluginConfigAtStart = params.minimalTestGateway
const gatewayPluginConfig = params.minimalTestGateway
? params.cfgAtStart
: applyPluginAutoEnable({
config: params.cfgAtStart,
env: process.env,
}).config;
const defaultAgentId = resolveDefaultAgentId(gatewayPluginConfigAtStart);
const defaultWorkspaceDir = resolveAgentWorkspaceDir(gatewayPluginConfigAtStart, defaultAgentId);
const defaultAgentId = resolveDefaultAgentId(gatewayPluginConfig);
const defaultWorkspaceDir = resolveAgentWorkspaceDir(gatewayPluginConfig, defaultAgentId);
const pluginLookUpTable = params.minimalTestGateway
? undefined
: loadPluginLookUpTable({
config: gatewayPluginConfigAtStart,
activationSourceConfig: params.cfgAtStart,
config: gatewayPluginConfig,
workspaceDir: defaultWorkspaceDir,
env: process.env,
activationSourceConfig: params.cfgAtStart,
});
const deferredConfiguredChannelPluginIds = [
...(pluginLookUpTable?.startup.configuredDeferredChannelPluginIds ?? []),
@@ -156,12 +156,12 @@ export async function prepareGatewayPluginBootstrap(params: {
if (!params.minimalTestGateway) {
await prestageGatewayBundledRuntimeDeps({
cfg: gatewayPluginConfigAtStart,
cfg: gatewayPluginConfig,
pluginIds: startupPluginIds,
log: params.log,
});
({ pluginRegistry, gatewayMethods: baseGatewayMethods } = loadGatewayStartupPlugins({
cfg: gatewayPluginConfigAtStart,
cfg: gatewayPluginConfig,
activationSourceConfig: params.cfgAtStart,
workspaceDir: defaultWorkspaceDir,
log: params.log,
@@ -178,7 +178,7 @@ export async function prepareGatewayPluginBootstrap(params: {
}
return {
gatewayPluginConfigAtStart,
gatewayPluginConfigAtStart: gatewayPluginConfig,
defaultWorkspaceDir,
deferredConfiguredChannelPluginIds,
startupPluginIds,

View File

@@ -83,6 +83,9 @@ function createManifestRegistryFixture() {
{
id: "browser",
channels: [],
activation: {
onConfigPaths: ["browser"],
},
origin: "bundled",
enabledByDefault: true,
providers: [],
@@ -485,6 +488,62 @@ describe("resolveGatewayStartupPluginIds", () => {
});
});
it("starts bundled sidecars selected by root config activation paths", () => {
const rawConfig = {
browser: {
enabled: true,
defaultProfile: "docker-cdp",
},
channels: {},
} satisfies OpenClawConfig;
const effectiveConfig = {
...rawConfig,
plugins: {
entries: {
browser: {
enabled: true,
},
},
},
} satisfies OpenClawConfig;
expectStartupPluginIdsCase({
config: effectiveConfig,
activationSourceConfig: rawConfig,
expected: ["browser", "memory-core"],
});
});
it("lets bundled root config activation paths bypass restrictive allowlists", () => {
expectStartupPluginIdsCase({
config: {
browser: {
enabled: true,
},
channels: {},
plugins: {
allow: ["telegram"],
},
},
expected: ["browser"],
});
});
it("does not bypass restrictive allowlists for disabled root config activation paths", () => {
expectStartupPluginIdsCase({
config: {
browser: {
enabled: false,
},
channels: {},
plugins: {
allow: ["telegram"],
},
},
expected: [],
});
});
it("does not let weak channel presence start untrusted workspace channel owners", () => {
loadPluginManifestRegistry
.mockReset()

View File

@@ -171,6 +171,17 @@ export const PLUGIN_COMPAT_RECORDS = [
diagnostics: ["activation plan compat reason"],
tests: ["src/plugins/activation-planner.test.ts"],
},
{
code: "activation-config-path-hint",
status: "active",
owner: "plugin-execution",
introduced: "2026-04-27",
replacement: "manifest contribution ownership for root config surfaces",
docsPath: "/plugins/manifest",
surfaces: ["activation.onConfigPaths", "startup plugin selection"],
diagnostics: ["activation plan compat reason"],
tests: ["src/plugins/channel-plugin-ids.test.ts"],
},
{
code: "activation-capability-hint",
status: "active",

View File

@@ -9,10 +9,11 @@ import {
} from "../memory-host-sdk/dreaming.js";
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
import { hasExplicitChannelConfig } from "./channel-presence-policy.js";
import { collectPluginConfigContractMatches } from "./config-contracts.js";
import { resolveEffectivePluginActivationState } from "./config-state.js";
import type { InstalledPluginIndexRecord } from "./installed-plugin-index.js";
import { loadPluginManifestRegistryForInstalledIndex } from "./manifest-registry-installed.js";
import type { PluginManifestRegistry } from "./manifest-registry.js";
import type { PluginManifestRecord, PluginManifestRegistry } from "./manifest-registry.js";
import {
createPluginRegistryIdNormalizer,
normalizePluginsConfigWithRegistry,
@@ -39,6 +40,20 @@ function listDisabledChannelIds(config: OpenClawConfig): Set<string> {
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function isConfigActivationValueEnabled(value: unknown): boolean {
if (value === false) {
return false;
}
if (isRecord(value) && value.enabled === false) {
return false;
}
return true;
}
function listPotentialEnabledChannelIds(config: OpenClawConfig, env: NodeJS.ProcessEnv): string[] {
const disabled = listDisabledChannelIds(config);
return listPotentialConfiguredChannelIds(config, env, { includePersistedAuthState: false })
@@ -125,6 +140,60 @@ function listManifestChannelIds(
return manifestRegistry.plugins.find((plugin) => plugin.id === pluginId)?.channels ?? [];
}
function findManifestPlugin(
manifestRegistry: PluginManifestRegistry,
pluginId: string,
): PluginManifestRecord | undefined {
return manifestRegistry.plugins.find((plugin) => plugin.id === pluginId);
}
function hasConfiguredActivationPath(params: {
manifest: PluginManifestRecord | undefined;
config: OpenClawConfig;
}): boolean {
const paths = params.manifest?.activation?.onConfigPaths;
if (!paths?.length) {
return false;
}
return paths.some((pathPattern) =>
collectPluginConfigContractMatches({
root: params.config,
pathPattern,
}).some((match) => isConfigActivationValueEnabled(match.value)),
);
}
function canStartConfiguredRootPlugin(params: {
plugin: InstalledPluginIndexRecord;
manifest: PluginManifestRecord | undefined;
config: OpenClawConfig;
pluginsConfig: ReturnType<typeof normalizePluginsConfigWithRegistry>;
activationSourcePlugins: ReturnType<typeof normalizePluginsConfigWithRegistry>;
}): boolean {
if (params.plugin.origin !== "bundled") {
return false;
}
if (!hasConfiguredActivationPath({ manifest: params.manifest, config: params.config })) {
return false;
}
if (!params.pluginsConfig.enabled || !params.activationSourcePlugins.enabled) {
return false;
}
if (
params.pluginsConfig.deny.includes(params.plugin.pluginId) ||
params.activationSourcePlugins.deny.includes(params.plugin.pluginId)
) {
return false;
}
if (
params.pluginsConfig.entries[params.plugin.pluginId]?.enabled === false ||
params.activationSourcePlugins.entries[params.plugin.pluginId]?.enabled === false
) {
return false;
}
return true;
}
function canStartConfiguredChannelPlugin(params: {
plugin: InstalledPluginIndexRecord;
config: OpenClawConfig;
@@ -301,6 +370,7 @@ export function resolveGatewayStartupPluginIdsFromRegistry(params: {
});
return params.index.plugins
.filter((plugin) => {
const manifest = findManifestPlugin(params.manifestRegistry, plugin.pluginId);
if (
hasConfiguredStartupChannel({
plugin,
@@ -338,6 +408,17 @@ export function resolveGatewayStartupPluginIdsFromRegistry(params: {
) {
return false;
}
if (
canStartConfiguredRootPlugin({
plugin,
manifest,
config: activationSourceConfig,
pluginsConfig,
activationSourcePlugins,
})
) {
return true;
}
const activationState = resolveEffectivePluginActivationState({
id: plugin.pluginId,
origin: plugin.origin,

View File

@@ -86,6 +86,9 @@ function collectCompatCodes(record: PluginManifestRecord): readonly PluginCompat
if (record.activation?.onRoutes?.length) {
codes.push("activation-route-hint");
}
if (record.activation?.onConfigPaths?.length) {
codes.push("activation-config-path-hint");
}
if (record.activation?.onCapabilities?.length) {
codes.push("activation-capability-hint");
}

View File

@@ -111,6 +111,7 @@ describe("loadPluginManifest JSON5 tolerance", () => {
onCommands: ["models", ""],
onChannels: ["web", ""],
onRoutes: ["gateway-webhook", ""],
onConfigPaths: ["browser", ""],
onCapabilities: ["provider", "tool", "wat"]
},
setup: {
@@ -133,6 +134,7 @@ describe("loadPluginManifest JSON5 tolerance", () => {
onCommands: ["models"],
onChannels: ["web"],
onRoutes: ["gateway-webhook"],
onConfigPaths: ["browser"],
onCapabilities: ["provider", "tool"],
});
expect(result.manifest.setup).toEqual({

View File

@@ -119,6 +119,8 @@ export type PluginManifestActivation = {
onChannels?: string[];
/** Route kinds that should include this plugin in activation/load plans. */
onRoutes?: string[];
/** Root-relative config paths that should include this plugin in startup/load plans. */
onConfigPaths?: string[];
/** Broad capability hints for activation/load plans. Prefer narrower ownership metadata. */
onCapabilities?: PluginManifestActivationCapability[];
};
@@ -740,6 +742,7 @@ function normalizeManifestActivation(value: unknown): PluginManifestActivation |
const onCommands = normalizeTrimmedStringList(value.onCommands);
const onChannels = normalizeTrimmedStringList(value.onChannels);
const onRoutes = normalizeTrimmedStringList(value.onRoutes);
const onConfigPaths = normalizeTrimmedStringList(value.onConfigPaths);
const onCapabilities = normalizeTrimmedStringList(value.onCapabilities).filter(
(capability): capability is PluginManifestActivationCapability =>
capability === "provider" ||
@@ -754,6 +757,7 @@ function normalizeManifestActivation(value: unknown): PluginManifestActivation |
...(onCommands.length > 0 ? { onCommands } : {}),
...(onChannels.length > 0 ? { onChannels } : {}),
...(onRoutes.length > 0 ? { onRoutes } : {}),
...(onConfigPaths.length > 0 ? { onConfigPaths } : {}),
...(onCapabilities.length > 0 ? { onCapabilities } : {}),
} satisfies PluginManifestActivation;

View File

@@ -1,3 +1,4 @@
import fs from "node:fs";
import {
inspectPersistedInstalledPluginIndex,
readPersistedInstalledPluginIndexSync,
@@ -25,7 +26,8 @@ export type PluginRegistrySnapshotSource = "provided" | "persisted" | "derived";
export type PluginRegistrySnapshotDiagnosticCode =
| "persisted-registry-disabled"
| "persisted-registry-missing"
| "persisted-registry-stale-policy";
| "persisted-registry-stale-policy"
| "persisted-registry-stale-source";
export type PluginRegistrySnapshotDiagnostic = {
level: "info" | "warn";
@@ -60,6 +62,20 @@ function hasEnvFlag(env: NodeJS.ProcessEnv, name: string): boolean {
return Boolean(value && value !== "0" && value !== "false" && value !== "no");
}
function hasMissingPersistedPluginSource(index: InstalledPluginIndex): boolean {
return index.plugins.some((plugin) => {
if (!plugin.enabled) {
return false;
}
return (
!fs.existsSync(plugin.rootDir) ||
!fs.existsSync(plugin.manifestPath) ||
(plugin.source ? !fs.existsSync(plugin.source) : false) ||
(plugin.setupSource ? !fs.existsSync(plugin.setupSource) : false)
);
});
}
export function loadPluginRegistrySnapshotWithMetadata(
params: LoadPluginRegistryParams = {},
): PluginRegistrySnapshotResult {
@@ -91,6 +107,13 @@ export function loadPluginRegistrySnapshotWithMetadata(
message:
"Persisted plugin registry policy does not match current config; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
});
} else if (hasMissingPersistedPluginSource(persistedIndex)) {
diagnostics.push({
level: "warn",
code: "persisted-registry-stale-source",
message:
"Persisted plugin registry points at missing plugin files; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
});
} else {
return {
snapshot: persistedIndex,

View File

@@ -109,6 +109,7 @@ function createIndex(
pluginId = "demo",
overrides: Partial<InstalledPluginIndex> = {},
): InstalledPluginIndex {
const pluginRoot = overrides.plugins?.[0]?.rootDir ?? `/plugins/${pluginId}`;
return {
version: 1,
hostContractVersion: "2026.4.25",
@@ -120,9 +121,9 @@ function createIndex(
plugins: [
{
pluginId,
manifestPath: `/plugins/${pluginId}/openclaw.plugin.json`,
manifestPath: path.join(pluginRoot, "openclaw.plugin.json"),
manifestHash: "manifest-hash",
rootDir: `/plugins/${pluginId}`,
rootDir: pluginRoot,
origin: "global",
enabled: true,
startup: {
@@ -359,6 +360,47 @@ describe("plugin registry facade", () => {
});
it("reads the persisted registry before deriving from discovered candidates", async () => {
const stateDir = makeTempDir();
const rootDir = makeTempDir();
const persistedRootDir = makeTempDir();
const candidate = createCandidate(rootDir);
const config = {} as const;
fs.writeFileSync(path.join(persistedRootDir, "index.ts"), "", "utf8");
fs.writeFileSync(
path.join(persistedRootDir, "openclaw.plugin.json"),
JSON.stringify({ id: "persisted", configSchema: { type: "object" } }),
"utf8",
);
await writePersistedInstalledPluginIndex(
createIndex("persisted", {
policyHash: resolveInstalledPluginIndexPolicyHash(config),
plugins: [
{
...createIndex("persisted").plugins[0],
manifestPath: path.join(persistedRootDir, "openclaw.plugin.json"),
source: path.join(persistedRootDir, "index.ts"),
rootDir: persistedRootDir,
},
],
}),
{ stateDir },
);
const result = loadPluginRegistrySnapshotWithMetadata({
stateDir,
candidates: [candidate],
config,
env: hermeticEnv(),
});
expect(result.source).toBe("persisted");
expect(result.diagnostics).toEqual([]);
expect(listPluginRecords({ index: result.snapshot }).map((plugin) => plugin.pluginId)).toEqual([
"persisted",
]);
});
it("falls back to the derived registry when persisted source paths are missing", async () => {
const stateDir = makeTempDir();
const rootDir = makeTempDir();
const candidate = createCandidate(rootDir);
@@ -377,10 +419,12 @@ describe("plugin registry facade", () => {
env: hermeticEnv(),
});
expect(result.source).toBe("persisted");
expect(result.diagnostics).toEqual([]);
expect(result.source).toBe("derived");
expect(result.diagnostics).toEqual([
expect.objectContaining({ code: "persisted-registry-stale-source" }),
]);
expect(listPluginRecords({ index: result.snapshot }).map((plugin) => plugin.pluginId)).toEqual([
"persisted",
"demo",
]);
});