Skip to content

[Feature]: Honor plugins.allow on runtime provider paths (currently force-loaded) — propose plugins.bundledMode opt-in #75575

@sergeyksv

Description

@sergeyksv

Summary

Make the runtime tool registration paths for web-search, web-fetch, and LLM providers honor plugins.allow and plugins.entries..enabled instead of force-loading every bundled plugin that declares the contract.

Expose this behavior as an opt-in setting, plugins.bundledMode: "respect-allow", so existing setups stay unchanged unless the new mode is explicitly enabled.

Problem to solve

OpenClaw’s plugin system has two contradictory views of “active plugins”:

The control plane honors plugins.allow and plugins.entries..enabled. For example, a user who allow-lists 7 plugins sees exactly 7 plugins in openclaw plugins list --enabled.

The runtime tool registration paths do not honor that same policy. On every chat turn and every subagent spawn, they use compat-mode flags like enablement: "always" and enablement: "allowlist" to force-import every bundled plugin that declares the relevant contract, regardless of the user’s config.

This divergence has three user-facing consequences:

plugins.allow does not mean what users expect. In most tool ecosystems, an allowlist means “only these are active.” In OpenClaw, it is documented as a third-party allowlist that does not affect bundled plugins, but most users will reasonably assume their config is being honored.
There is no inverse-allowlist surface for bundled plugins. As the bundled set grows, each release can silently auto-enable newly added bundled plugins on existing installs. Users who want a minimal setup have to keep disabling new bundled plugin IDs one by one with plugins.entries..enabled: false.
Force-importing many bundled plugin runtimes is expensive. That cost is paid synchronously on the hot path before the LLM call.

src/plugins/CLAUDE.md describes the current broad mutable registry materialization as transitional, not the intended endpoint. Its direction-of-travel guidance points toward a manifest-first control plane, targeted runtime loaders, and no hidden contract bypasses. That guidance contradicts the current runtime behavior.

The compat shim was reasonable when there were only a few bundled plugins. With the bundled set now much larger, it has become a hot-path performance and policy problem.

Proposed solution

Add a single new config field that lets users opt into having the runtime paths respect plugins.allow and plugins.entries.<id>.enabled:

plugins.bundledMode: "compat" | "respect-allow"

Default: compat

With bundledMode: "compat", current behavior is preserved exactly. Bundled provider plugins keep using the existing compat behavior, including enablement: "always" where applicable, and runtime paths continue to force-load the relevant contract set. Existing users see no behavior change.

With bundledMode: "respect-allow", bundled provider plugins are gated by plugins.allow and plugins.entries.<id>.enabled exactly like third-party plugins. This should apply on every runtime code path, including runtime tool registration and subagent spawn.

Example config:

plugins.enabled: true

plugins.allow: ["minimax", "telegram", "brave"]

plugins.bundledMode: "respect-allow"

Implementation surface:

src/plugins/web-provider-resolution-shared.ts:154-165 — web-search providers

src/plugins/web-content-extractors.runtime.ts:74-84 — web-fetch providers

src/plugins/providers.runtime.ts:222-232 — LLM providers

src/plugins/web-provider-runtime-shared.ts:92,144 — change cache: false to cache: true so the loader’s existing pluginLoaderCacheState engages

At each runtime provider site, when cfg.plugins?.bundledMode === "respect-allow", use:

compatMode: { allowlist: false, enablement: "allowlist", vitest: false }

This causes shouldResolveBundledCompatPluginIds to return false, so the compat layer skips its overrides and the user’s plugins.allow flows through unmodified.

A working reference implementation exists in sergeyksv/openclaw:survival/plugin-cache at commit a9f7ecf3. That version is env-var gated instead of config-key gated as a fork-side workaround for strict schema validation. The total reference diff is small: +13/-10 across the four files.

The default remains unchanged. The documentation surface only needs one added line describing plugins.bundledMode in the schema help text.

Alternatives considered

Considered alternatives:

  1. Make plugins.allow authoritative for everything, with no opt-in.

This is the smallest user-facing change and matches allowlist semantics in most other tool ecosystems. However, it would silently break setups that depend on bundled plugins being always available, such as users who reference a bundled provider’s model without explicitly listing that provider, or setup flows that probe for installable providers.

  1. Document that plugins.allow only applies to third-party plugins and tell users to disable bundled plugins individually with plugins.entries.<id>.enabled: false.

This has already been attempted. It does not solve the treadmill problem, because each release can add new bundled plugins that users must individually disable. It also does not address the per-turn and per-spawn runtime import cost.

  1. Add a separate bundled-plugin allowlist, such as plugins.bundledAllow: [...].

This creates a more verbose config surface with two allowlists to maintain. Most users likely want one of two modes: “honor my single allowlist everywhere” or “keep upstream defaults.” A two-list surface does not fit either case cleanly.

  1. Provide only an environment variable, such as OPENCLAW_RESPECT_ALLOW=1.

This works as a survival-fork mechanism and is what the reference fork currently ships. However, environment variables are not a good primary user-facing surface. They are less discoverable, not visible in config tooling, and do not reliably survive daemon restarts unless every launcher is configured correctly.

  1. Replace the compat-flag mechanism with manifest-only enumeration, so contract enumeration never imports plugin runtime.

This aligns with the direction described in src/plugins/CLAUDE.md and is the right long-term architecture. However, it is a larger change: roughly a 200+ line PR across many files touching plugin discovery, runtime resolvers, model selection, and tool factories.

That architectural fix should still happen eventually, but a single config-key opt-in unblocks affected users now without prejudicing the longer-term work.

Impact

Affected users and systems:

  • Users running OpenClaw on slower CPUs, including ARM SBCs, low-power home servers, and Raspberry Pi-class hardware. This has been confirmed on orangepi4pro.
  • Users running on faster systems, including Intel i7 and Apple M2 machines. The same code paths execute there too, producing lower but still user-visible latency, with community reports around 3-8 seconds of prep time instead of 30-60 seconds.
  • All channels, including Telegram, Discord, Slack, and web. The cost is in the runner, not the channel layer.
  • Power users who configure plugins.allow and then observe runtime behavior diverging from plugins list --enabled.

Severity:

Blocker on slow hardware and significant degradation on fast hardware.

On ARM, end-to-end chat turn time is around 75-80 seconds, while the LLM portion accounts for about 3 seconds. In practice, roughly 95% of every turn is plugin overhead.

Subagent spawns can block the event loop for around 40 seconds. During that time, the gateway is unresponsive, so Telegram and Discord users see unexplained silence with no indication of activity.

Frequency:

Every chat turn and every subagent spawn.

This is deterministic, not load-dependent or intermittent. It reproduces on any single message, including the second, third, and later turns within the same gateway process. There is no meaningful warm-up effect.

Consequences:

  • Users on slow hardware effectively cannot use OpenClaw for interactive chat at v2026.4.5+.
  • Workarounds reported in the wild, including rolling back to v2026.4.23, maintaining exhaustive entries.<id>.enabled: false lists, or setting OPENCLAW_DISABLE_BUNDLED_PLUGINS=1, sacrifice features and create daily breakage risk.
  • Users see plugins list --enabled report 7 plugins while runtime loads 12-49, which makes config behavior look broken even when the user’s configuration is correct.
  • Every release that adds bundled plugins becomes a silent performance regression for existing setups, compounding with each release on the daily cadence.

Evidence/examples

Trace evidence from orangepi4pro on ARM, using OpenClaw v2026.4.27:

The embedded run trace shows phase=stream-ready with totalMs=53561. Three prep stages dominate the runtime:

core-plugin-tools: 14976ms

system-prompt: 15783ms

stream-setup: 15844ms

These three independent stages each take roughly 15 seconds and have a similar shape. That is the characteristic signature of three call sites each re-walking the same shared dependency graph.

Per-turn plugin re-import evidence:

Runtime logs show bundled providers being loaded from the extensions directory, including brave, duckduckgo, exa, and others. In that trace, runtime loaded 12 plugins, with 12 attempted, in 189.9ms.

The user config had plugins.allow set to 7 entries:

minimax, telegram, openai, anthropic, zai, browser, brave

plugins list --enabled reported 7 plugins, but runtime imported 12.

Subagent spawn evidence:

A subagent spawn loaded 49 plugins, spanning amazon-bedrock through zai, and blocked for 39356.8ms. During the same window, diagnostics reported a liveness warning caused by event loop delay, with eventLoopDelayP99Ms=1425, eventLoopDelayMaxMs=4127.2, and eventLoopUtilization=0.696.

The node --prof baseline summary shows most CPU cost below the JavaScript layer:

JavaScript: 8.6% total, 21.7% nonlib

C++: 31.2% total, 78.3% nonlib

Shared libraries: 60.1%, mostly libc syscalls

In other words, 91% of CPU time is below the JavaScript layer, consistent with filesystem syscalls and native binding work driven by per-turn plugin re-imports.

Reference implementation:

The reference branch is sergeyksv/openclaw survival/plugin-cache. It uses env-gated patches. With OPENCLAW_RESPECT_ALLOW=1, per-turn loads drop from 12 attempted plugins to 2 attempted plugins. Prep time drops from roughly 14 seconds to roughly 8 seconds, and end-to-end chat turn time drops from roughly 76 seconds to around 10-11 seconds.

Code citations for the current override behavior:

src/plugins/web-provider-resolution-shared.ts:164 uses enablement: "always".

src/plugins/web-content-extractors.runtime.ts:82 uses enablement: "always".

src/plugins/providers.runtime.ts:229 uses enablement: "allowlist" with allowlist: params.bundledProviderAllowlistCompat, which is true from callers.

src/plugins/web-provider-runtime-shared.ts:92,144 uses cache: params.cache ?? false, which defeats pluginLoaderCacheState.

Related upstream issues in the same root-cause family:

#60528: high pre-processing latency of 8-12 seconds on webchat after gateway restart, not present in v2026.2.26.

#62051: v2026.4.5 worker processes load all plugins, causing CPU saturation.

#73176: 2026.4.25 chokidar v5 plus plugin-loader regression breaks gateway.

#71938: openclaw onboard takes too long.

#68825: Active Memory plugin times out at 120 seconds.

Third-party coverage:

PiunikaWeb reported on 2026-04-29 that the OpenClaw 2026.4.26 update was triggering gateway crashes and Discord issues for many users, and recommended rolling back to v2026.4.23 as a workaround.

Additional information

Additional constraints and follow-up notes:

Schema strictness constraint:

OpenClaw’s config loader rejects unknown keys. Today, adding plugins.bundledMode without updating the schema fails with:

Invalid config: plugins: Unrecognized key: "bundledMode"

The reference implementation uses OPENCLAW_RESPECT_ALLOW=1 because that avoids the strict schema check. That is useful as a survival workaround, but the proper user-facing surface is the config key. The same PR needs to add the schema entry for plugins.bundledMode.

Daily-release cadence:

OpenClaw merges 50+ commits per day, with significant churn in plugin and runtime layers. That makes downstream survival-fork rebases expensive and makes user trust fragile when undocumented behavioral changes ship without per-release performance gates.

A bundledMode opt-in stabilizes one moving piece: once users opt in, future bundled plugin additions do not silently auto-enable on their installs.

Test and CI gap:

The current trace warning thresholds are:

EMBEDDED_RUN_STAGE_WARN_TOTAL_MS = 10_000ms

EMBEDDED_RUN_STAGE_WARN_STAGE_MS = 5_000ms

These are defined in src/agents/pi-embedded-runner/run/attempt-stage-timing.ts.

On dev hardware, the current behavior can produce totals around 3-8 seconds, which stays below the warning threshold, so the trace never fires. There is currently no CI assertion that exercises the per-turn cold path on a slow runner.

A performance-gate workflow on a CPU-budget-constrained runner, using a real chat turn, would likely have caught this regression and would help catch the next one.

Architectural follow-on:

The proposed opt-in unblocks the immediate user pain, but it is not the final architecture.

The proper long-term fix, consistent with src/plugins/CLAUDE.md, is contract enumeration that reads manifest metadata only and never imports plugin runtime.

Runtime paths such as loadAgentToolResultMiddlewaresForRuntime, resolvePluginTools, resolveRuntimePluginRegistry, and createOpenClawCodingTools should receive a caller-owned PluginRegistrySnapshot for the session lifetime instead of re-deriving registry data from disk on every turn.

That should be a separate, larger PR. It is worth tracking, but it should not gate this smaller plugins.bundledMode opt-in.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions