From f6dcf968ca30773d7cfce74d8ec4416c619add87 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 24 Apr 2026 17:46:35 +0100 Subject: [PATCH] fix: honor disabled plugin runtime deps --- CHANGELOG.md | 1 + docs/help/testing.md | 2 +- docs/tools/plugin.md | 3 + package.json | 2 +- .../bundled-channel-runtime-deps-docker.sh | 122 ++++++++++++++++++ ...doctor-bundled-plugin-runtime-deps.test.ts | 83 +++++++++++- src/plugins/bundled-runtime-deps.ts | 16 ++- src/plugins/channel-plugin-ids.test.ts | 39 ++++++ src/plugins/gateway-startup-plugin-ids.ts | 36 +++++- 9 files changed, 292 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f30f9e5922e..df732443f63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Plugins/runtime deps: respect explicit plugin and channel disablement when repairing bundled runtime dependencies, so doctor and health checks no longer install deps for disabled configured channels. - Diagnostics: harden tool and model diagnostic events against hostile errors, blocking listeners, and unsafe stability reason fields. Thanks @vincentkoc. - Plugins/onboarding: record local plugin install source metadata without duplicating raw absolute local paths in persisted `plugins.installs`, while preserving linked load-path cleanup. (#70970) Thanks @vincentkoc. - Browser/tool: tell agents not to pass per-call `timeoutMs` on existing-session type, evaluate, and other Chrome MCP actions that reject timeout overrides. diff --git a/docs/help/testing.md b/docs/help/testing.md index 0ec006fc6f4..ff5c1cb30e7 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -537,7 +537,7 @@ The live-model Docker runners also bind-mount only the needed CLI auth homes (or - Plugins (install smoke + `/plugin` alias + Claude-bundle restart semantics): `pnpm test:docker:plugins` (script: `scripts/e2e/plugins-docker.sh`) - Plugin update unchanged smoke: `pnpm test:docker:plugin-update` (script: `scripts/e2e/plugin-update-unchanged-docker.sh`) - Config reload metadata smoke: `pnpm test:docker:config-reload` (script: `scripts/e2e/config-reload-source-docker.sh`) -- Bundled plugin runtime deps: `pnpm test:docker:bundled-channel-deps` builds a small Docker runner image by default, builds and packs OpenClaw once on the host, then mounts that tarball into each Linux install scenario. Reuse the image with `OPENCLAW_SKIP_DOCKER_BUILD=1`, skip the host rebuild after a fresh local build with `OPENCLAW_BUNDLED_CHANNEL_HOST_BUILD=0`, or point at an existing tarball with `OPENCLAW_BUNDLED_CHANNEL_PACKAGE_TGZ=/path/to/openclaw-*.tgz`. The full Docker aggregate pre-packs this tarball once, then shards bundled channel checks into independent lanes; use `OPENCLAW_BUNDLED_CHANNELS=telegram,slack` to narrow the channel matrix when running the bundled lane directly. +- Bundled plugin runtime deps: `pnpm test:docker:bundled-channel-deps` builds a small Docker runner image by default, builds and packs OpenClaw once on the host, then mounts that tarball into each Linux install scenario. Reuse the image with `OPENCLAW_SKIP_DOCKER_BUILD=1`, skip the host rebuild after a fresh local build with `OPENCLAW_BUNDLED_CHANNEL_HOST_BUILD=0`, or point at an existing tarball with `OPENCLAW_BUNDLED_CHANNEL_PACKAGE_TGZ=/path/to/openclaw-*.tgz`. The full Docker aggregate pre-packs this tarball once, then shards bundled channel checks into independent lanes; use `OPENCLAW_BUNDLED_CHANNELS=telegram,slack` to narrow the channel matrix when running the bundled lane directly. The lane also verifies that `channels..enabled=false` and `plugins.entries..enabled=false` suppress doctor/runtime-dependency repair. - Narrow bundled plugin runtime deps while iterating by disabling unrelated scenarios, for example: `OPENCLAW_BUNDLED_CHANNEL_SCENARIOS=0 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=0 pnpm test:docker:bundled-channel-deps`. diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index fa0e03bb029..7bb19b60a24 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -65,6 +65,9 @@ Packaged OpenClaw installs do not eagerly install every bundled plugin's runtime dependency tree. When a bundled OpenClaw-owned plugin is active from plugin config, legacy channel config, or a default-enabled manifest, startup repairs only that plugin's declared runtime dependencies before importing it. +Explicit disablement still wins: `plugins.entries..enabled: false`, +`plugins.deny`, `plugins.enabled: false`, and `channels..enabled: false` +prevent automatic bundled runtime-dependency repair for that plugin/channel. External plugins and custom load paths must still be installed through `openclaw plugins install`. diff --git a/package.json b/package.json index eadfe71cb8f..56cf2633643 100644 --- a/package.json +++ b/package.json @@ -1457,7 +1457,7 @@ "test:coverage:changed": "node scripts/run-vitest.mjs run --config test/vitest/vitest.unit.config.ts --coverage --changed origin/main", "test:docker:all": "node scripts/test-docker-all.mjs", "test:docker:bundled-channel-deps": "bash scripts/e2e/bundled-channel-runtime-deps-docker.sh", - "test:docker:bundled-channel-deps:fast": "OPENCLAW_BUNDLED_CHANNEL_SCENARIOS=0 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=1 OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO=1 bash scripts/e2e/bundled-channel-runtime-deps-docker.sh", + "test:docker:bundled-channel-deps:fast": "OPENCLAW_BUNDLED_CHANNEL_SCENARIOS=0 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=1 OPENCLAW_BUNDLED_CHANNEL_DISABLED_CONFIG_SCENARIO=1 OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO=1 bash scripts/e2e/bundled-channel-runtime-deps-docker.sh", "test:docker:cleanup": "bash scripts/test-cleanup-docker.sh", "test:docker:config-reload": "bash scripts/e2e/config-reload-source-docker.sh", "test:docker:cron-mcp-cleanup": "bash scripts/e2e/cron-mcp-cleanup-docker.sh", diff --git a/scripts/e2e/bundled-channel-runtime-deps-docker.sh b/scripts/e2e/bundled-channel-runtime-deps-docker.sh index 0ef65f5141e..ab70ce14d4e 100644 --- a/scripts/e2e/bundled-channel-runtime-deps-docker.sh +++ b/scripts/e2e/bundled-channel-runtime-deps-docker.sh @@ -14,6 +14,7 @@ RUN_UPDATE_SCENARIO="${OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO:-1}" RUN_ROOT_OWNED_SCENARIO="${OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO:-1}" RUN_SETUP_ENTRY_SCENARIO="${OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO:-1}" RUN_LOAD_FAILURE_SCENARIO="${OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO:-1}" +RUN_DISABLED_CONFIG_SCENARIO="${OPENCLAW_BUNDLED_CHANNEL_DISABLED_CONFIG_SCENARIO:-1}" docker_e2e_build_or_reuse "$IMAGE_NAME" bundled-channel-deps "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "$DOCKER_TARGET" @@ -863,6 +864,124 @@ EOF rm -f "$run_log" } +run_disabled_config_scenario() { + local run_log + run_log="$(mktemp "${TMPDIR:-/tmp}/openclaw-bundled-channel-disabled-config.XXXXXX")" + + echo "Running bundled channel disabled-config runtime deps Docker E2E..." + if ! docker run --rm \ + -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ + "${PACKAGE_DOCKER_ARGS[@]}" \ + -i "$IMAGE_NAME" bash -s >"$run_log" 2>&1 <<'EOF' +set -euo pipefail + +export HOME="$(mktemp -d "/tmp/openclaw-bundled-channel-disabled-config.XXXXXX")" +export NPM_CONFIG_PREFIX="$HOME/.npm-global" +export PATH="$NPM_CONFIG_PREFIX/bin:$PATH" +export OPENCLAW_NO_ONBOARD=1 +export OPENCLAW_PLUGIN_STAGE_DIR="$HOME/.openclaw/plugin-runtime-deps" +mkdir -p "$OPENCLAW_PLUGIN_STAGE_DIR" + +package_root() { + printf "%s/openclaw" "$(npm root -g)" +} + +assert_dep_absent_everywhere() { + local channel="$1" + local dep_path="$2" + local root="$3" + for candidate in \ + "$root/dist/extensions/$channel/node_modules/$dep_path/package.json" \ + "$root/dist/extensions/node_modules/$dep_path/package.json" \ + "$root/node_modules/$dep_path/package.json"; do + if [ -f "$candidate" ]; then + echo "disabled $channel unexpectedly installed $dep_path at $candidate" >&2 + exit 1 + fi + done + if find "$OPENCLAW_PLUGIN_STAGE_DIR" -maxdepth 12 -path "*/node_modules/$dep_path/package.json" -type f | grep -q .; then + echo "disabled $channel unexpectedly staged $dep_path externally" >&2 + find "$OPENCLAW_PLUGIN_STAGE_DIR" -maxdepth 12 -type f | sort | head -160 >&2 || true + exit 1 + fi +} + +echo "Installing mounted OpenClaw package..." +package_tgz="${OPENCLAW_CURRENT_PACKAGE_TGZ:?missing OPENCLAW_CURRENT_PACKAGE_TGZ}" +npm install -g "$package_tgz" --no-fund --no-audit >/tmp/openclaw-disabled-config-install.log 2>&1 + +root="$(package_root)" +test -d "$root/dist/extensions/telegram" +test -d "$root/dist/extensions/discord" +test -d "$root/dist/extensions/slack" +rm -rf "$root/dist/extensions/telegram/node_modules" +rm -rf "$root/dist/extensions/discord/node_modules" +rm -rf "$root/dist/extensions/slack/node_modules" + +node - <<'NODE' +const fs = require("node:fs"); +const path = require("node:path"); + +const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); +const config = { + plugins: { + enabled: true, + entries: { + discord: { enabled: false }, + }, + }, + channels: { + telegram: { + enabled: false, + botToken: "123456:disabled-config-token", + dmPolicy: "disabled", + groupPolicy: "disabled", + }, + slack: { + enabled: false, + botToken: "xoxb-disabled-config-token", + appToken: "xapp-disabled-config-token", + }, + discord: { + enabled: true, + token: "disabled-plugin-entry-token", + dmPolicy: "disabled", + groupPolicy: "disabled", + }, + }, +}; +fs.mkdirSync(path.dirname(configPath), { recursive: true }); +fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); +NODE + +if ! openclaw doctor --non-interactive >/tmp/openclaw-disabled-config-doctor.log 2>&1; then + echo "doctor failed for disabled-config runtime deps smoke" >&2 + cat /tmp/openclaw-disabled-config-doctor.log >&2 + exit 1 +fi + +assert_dep_absent_everywhere telegram grammy "$root" +assert_dep_absent_everywhere slack @slack/web-api "$root" +assert_dep_absent_everywhere discord discord-api-types "$root" + +if grep -Eq "\\[plugins\\] (telegram|slack|discord) installed bundled runtime deps:" /tmp/openclaw-disabled-config-doctor.log; then + echo "doctor installed runtime deps for an explicitly disabled channel/plugin" >&2 + cat /tmp/openclaw-disabled-config-doctor.log >&2 + exit 1 +fi + +echo "bundled channel disabled-config runtime deps Docker E2E passed" +EOF + then + cat "$run_log" + rm -f "$run_log" + exit 1 + fi + + cat "$run_log" + rm -f "$run_log" +} + run_update_scenario() { local run_log run_log="$(mktemp "${TMPDIR:-/tmp}/openclaw-bundled-channel-update.XXXXXX")" @@ -1392,6 +1511,9 @@ fi if [ "$RUN_SETUP_ENTRY_SCENARIO" != "0" ]; then run_setup_entry_scenario fi +if [ "$RUN_DISABLED_CONFIG_SCENARIO" != "0" ]; then + run_disabled_config_scenario +fi if [ "$RUN_LOAD_FAILURE_SCENARIO" != "0" ]; then run_load_failure_scenario fi diff --git a/src/commands/doctor-bundled-plugin-runtime-deps.test.ts b/src/commands/doctor-bundled-plugin-runtime-deps.test.ts index e3107422105..53f008a3d22 100644 --- a/src/commands/doctor-bundled-plugin-runtime-deps.test.ts +++ b/src/commands/doctor-bundled-plugin-runtime-deps.test.ts @@ -31,6 +31,20 @@ function writeBundledChannelPlugin(root: string, id: string, dependencies: Recor }); } +function writeDefaultEnabledBundledChannelPlugin( + root: string, + id: string, + dependencies: Record, +) { + writeBundledChannelPlugin(root, id, dependencies); + writeJson(path.join(root, "dist", "extensions", id, "openclaw.plugin.json"), { + id, + channels: [id], + enabledByDefault: true, + configSchema: { type: "object" }, + }); +} + function createInstalledRuntimeDeps(): InstalledRuntimeDeps { return []; } @@ -153,7 +167,7 @@ describe("doctor bundled plugin runtime deps", () => { expect(result.conflicts).toEqual([]); }); - it("can include disabled but configured bundled channel deps for doctor recovery", () => { + it("does not include explicitly disabled but configured bundled channel deps", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); writeJson(path.join(root, "package.json"), { name: "openclaw" }); writeBundledChannelPlugin(root, "telegram", { "telegram-only": "1.0.0" }); @@ -169,12 +183,77 @@ describe("doctor bundled plugin runtime deps", () => { }, }); + expect(result.missing).toEqual([]); + expect(result.conflicts).toEqual([]); + }); + + it("includes configured bundled channel deps for doctor recovery when not explicitly disabled", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); + writeJson(path.join(root, "package.json"), { name: "openclaw" }); + writeBundledChannelPlugin(root, "telegram", { "telegram-only": "1.0.0" }); + + const result = scanBundledPluginRuntimeDeps({ + packageRoot: root, + includeConfiguredChannels: true, + config: { + plugins: { enabled: true }, + channels: { + telegram: { botToken: "123:abc" }, + }, + }, + }); + expect(result.missing.map((dep) => `${dep.name}@${dep.version}`)).toEqual([ "telegram-only@1.0.0", ]); expect(result.conflicts).toEqual([]); }); + it("does not include configured bundled channel deps when the plugin entry is disabled", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); + writeJson(path.join(root, "package.json"), { name: "openclaw" }); + writeBundledChannelPlugin(root, "telegram", { "telegram-only": "1.0.0" }); + + const result = scanBundledPluginRuntimeDeps({ + packageRoot: root, + includeConfiguredChannels: true, + config: { + plugins: { + enabled: true, + entries: { + telegram: { enabled: false }, + }, + }, + channels: { + telegram: { botToken: "123:abc" }, + }, + }, + }); + + expect(result.missing).toEqual([]); + expect(result.conflicts).toEqual([]); + }); + + it("lets channel disablement suppress default-enabled bundled channel deps", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); + writeJson(path.join(root, "package.json"), { name: "openclaw" }); + writeDefaultEnabledBundledChannelPlugin(root, "demo", { "demo-only": "1.0.0" }); + + const result = scanBundledPluginRuntimeDeps({ + packageRoot: root, + includeConfiguredChannels: true, + config: { + plugins: { enabled: true }, + channels: { + demo: { enabled: false }, + }, + }, + }); + + expect(result.missing).toEqual([]); + expect(result.conflicts).toEqual([]); + }); + it("reports default-enabled bundled plugin deps", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); writeJson(path.join(root, "package.json"), { name: "openclaw" }); @@ -324,7 +403,7 @@ describe("doctor bundled plugin runtime deps", () => { { installRoot: root, missingSpecs: ["grammy@1.37.0"], - installSpecs: ["@slack/web-api@7.15.1", "grammy@1.37.0"], + installSpecs: ["grammy@1.37.0"], }, ]); }); diff --git a/src/plugins/bundled-runtime-deps.ts b/src/plugins/bundled-runtime-deps.ts index f9d3a45b241..1964640eab6 100644 --- a/src/plugins/bundled-runtime-deps.ts +++ b/src/plugins/bundled-runtime-deps.ts @@ -3,7 +3,6 @@ import { createHash } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { normalizeChatChannelId } from "../channels/ids.js"; import { resolveStateDir } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveHomeRelativePath } from "../infra/home-dir.js"; @@ -571,14 +570,24 @@ function isBundledPluginConfiguredForRuntimeDeps(params: { if (entry?.enabled === true) { return true; } + let hasExplicitChannelDisable = false; for (const channelId of readBundledPluginChannels(params.pluginDir)) { - const normalizedChannelId = normalizeChatChannelId(channelId); + const normalizedChannelId = normalizeOptionalLowercaseString(channelId); if (!normalizedChannelId) { continue; } const channelConfig = (params.config.channels as Record | undefined)?.[ normalizedChannelId ]; + if ( + channelConfig && + typeof channelConfig === "object" && + !Array.isArray(channelConfig) && + (channelConfig as { enabled?: unknown }).enabled === false + ) { + hasExplicitChannelDisable = true; + continue; + } if ( channelConfig && typeof channelConfig === "object" && @@ -589,6 +598,9 @@ function isBundledPluginConfiguredForRuntimeDeps(params: { return true; } } + if (hasExplicitChannelDisable) { + return false; + } return readBundledPluginEnabledByDefault(params.pluginDir); } diff --git a/src/plugins/channel-plugin-ids.test.ts b/src/plugins/channel-plugin-ids.test.ts index 2c8232aaa05..3c66a73fafa 100644 --- a/src/plugins/channel-plugin-ids.test.ts +++ b/src/plugins/channel-plugin-ids.test.ts @@ -510,6 +510,45 @@ describe("resolveGatewayStartupPluginIds", () => { }); }); + it("does not treat explicitly disabled stale channel config as startup intent", () => { + expectStartupPluginIdsCase({ + config: { + channels: { + "demo-channel": { + enabled: false, + token: "stale", + }, + }, + } as OpenClawConfig, + env: {}, + expected: ["browser"], + }); + }); + + it("does not treat explicitly disabled stale channel config as deferred startup intent", () => { + loadPluginManifestRegistry + .mockReset() + .mockReturnValue(createManifestRegistryFixtureWithWorkspaceDemoChannel()); + + expect( + resolveConfiguredDeferredChannelPluginIds({ + config: { + channels: { + "demo-channel": { + enabled: false, + token: "stale", + }, + }, + plugins: { + allow: ["workspace-demo-channel-plugin"], + }, + } as OpenClawConfig, + workspaceDir: "/tmp", + env: {}, + }), + ).toEqual([]); + }); + it("includes the explicitly selected memory slot plugin in startup scope", () => { expectStartupPluginIdsCase({ config: createStartupConfig({ diff --git a/src/plugins/gateway-startup-plugin-ids.ts b/src/plugins/gateway-startup-plugin-ids.ts index 8de6b1add59..0227a5a52d1 100644 --- a/src/plugins/gateway-startup-plugin-ids.ts +++ b/src/plugins/gateway-startup-plugin-ids.ts @@ -7,6 +7,7 @@ import { resolveMemoryDreamingPluginConfig, resolveMemoryDreamingPluginId, } from "../memory-host-sdk/dreaming.js"; +import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import { resolveManifestActivationPluginIds } from "./activation-planner.js"; import { hasExplicitChannelConfig } from "./channel-presence-policy.js"; import { @@ -18,6 +19,33 @@ import { import { loadPluginManifestRegistry, type PluginManifestRecord } from "./manifest-registry.js"; import { hasKind } from "./slots.js"; +function listDisabledChannelIds(config: OpenClawConfig): Set { + const channels = config.channels; + if (!channels || typeof channels !== "object" || Array.isArray(channels)) { + return new Set(); + } + return new Set( + Object.entries(channels) + .filter(([, value]) => { + return ( + value && + typeof value === "object" && + !Array.isArray(value) && + (value as { enabled?: unknown }).enabled === false + ); + }) + .map(([channelId]) => normalizeOptionalLowercaseString(channelId)) + .filter((channelId): channelId is string => Boolean(channelId)), + ); +} + +function listPotentialEnabledChannelIds(config: OpenClawConfig, env: NodeJS.ProcessEnv): string[] { + const disabled = listDisabledChannelIds(config); + return listPotentialConfiguredChannelIds(config, env) + .map((id) => normalizeOptionalLowercaseString(id) ?? "") + .filter((id) => id && !disabled.has(id)); +} + function hasRuntimeContractSurface(plugin: PluginManifestRecord): boolean { return Boolean( plugin.providers.length > 0 || @@ -148,9 +176,7 @@ export function resolveConfiguredDeferredChannelPluginIds(params: { workspaceDir?: string; env: NodeJS.ProcessEnv; }): string[] { - const configuredChannelIds = new Set( - listPotentialConfiguredChannelIds(params.config, params.env).map((id) => id.trim()), - ); + const configuredChannelIds = new Set(listPotentialEnabledChannelIds(params.config, params.env)); if (configuredChannelIds.size === 0) { return []; } @@ -183,9 +209,7 @@ export function resolveGatewayStartupPluginIds(params: { workspaceDir?: string; env: NodeJS.ProcessEnv; }): string[] { - const configuredChannelIds = new Set( - listPotentialConfiguredChannelIds(params.config, params.env).map((id) => id.trim()), - ); + const configuredChannelIds = new Set(listPotentialEnabledChannelIds(params.config, params.env)); const pluginsConfig = normalizePluginsConfig(params.config.plugins); // Startup must classify allowlist exceptions against the raw config snapshot, // not the auto-enabled effective snapshot, or configured-only channels can be