From caba05b94a2604715501c2fcaf8c7cb6a4db5f05 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 27 Apr 2026 01:57:40 -0700 Subject: [PATCH] fix(plugins): harden bundled install/uninstall sweep Fix bundled plugin install/uninstall sweep coverage and avoid persisting invalid placeholder config for config-gated bundled plugins. --- CHANGELOG.md | 1 + package.json | 1 + ...bundled-plugin-install-uninstall-docker.sh | 228 ++++++++++++++++++ scripts/lib/docker-e2e-scenarios.mjs | 16 ++ src/cli/plugins-cli.install.test.ts | 36 +++ src/cli/plugins-install-command.ts | 70 +++++- src/cli/plugins-install-persist.test.ts | 65 +++++ src/cli/plugins-install-persist.ts | 24 +- src/plugins/bundled-sources.test.ts | 49 +++- src/plugins/bundled-sources.ts | 18 ++ src/plugins/enable.test.ts | 10 + src/plugins/enable.ts | 11 +- src/plugins/toggle-config.ts | 3 +- test/scripts/docker-build-helper.test.ts | 16 ++ test/scripts/docker-e2e-plan.test.ts | 17 ++ 15 files changed, 550 insertions(+), 15 deletions(-) create mode 100755 scripts/e2e/bundled-plugin-install-uninstall-docker.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 897e2f65d3a..a16c0d4144c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,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/Claude CLI: force live-session launches to include `--output-format stream-json` whenever OpenClaw adds `--input-format stream-json`, so new Claude CLI sessions no longer fail immediately while reusable sessions keep working. Fixes #72206. Thanks @kwangwonkoh and @Xivi08. - CLI/plugins: accept ClawHub plugin API wildcard ranges such as `*` without rejecting compatible plugin installs, while still requiring a valid runtime API version. Fixes #56446; supersedes #56466. Thanks @darconada and @claygeo. +- CLI/plugins: let config-gated bundled plugins install without persisting invalid placeholder config entries, so install/uninstall sweeps can cover plugins such as memory-lancedb before the user configures credentials. Thanks @vincentkoc. - 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. diff --git a/package.json b/package.json index fd73a7e1fb7..ab76a5772d9 100644 --- a/package.json +++ b/package.json @@ -1498,6 +1498,7 @@ "test:docker:browser-cdp-snapshot": "bash scripts/e2e/browser-cdp-snapshot-docker.sh", "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_DISABLED_CONFIG_SCENARIO=1 OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO=1 bash scripts/e2e/bundled-channel-runtime-deps-docker.sh", + "test:docker:bundled-plugin-install-uninstall": "bash scripts/e2e/bundled-plugin-install-uninstall-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:crestodian-first-run": "bash scripts/e2e/crestodian-first-run-docker.sh", diff --git a/scripts/e2e/bundled-plugin-install-uninstall-docker.sh b/scripts/e2e/bundled-plugin-install-uninstall-docker.sh new file mode 100755 index 00000000000..b15a4297179 --- /dev/null +++ b/scripts/e2e/bundled-plugin-install-uninstall-docker.sh @@ -0,0 +1,228 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh" +IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-bundled-plugin-install-uninstall-e2e" OPENCLAW_BUNDLED_PLUGIN_INSTALL_UNINSTALL_E2E_IMAGE)" + +docker_e2e_build_or_reuse "$IMAGE_NAME" bundled-plugin-install-uninstall + +DOCKER_ENV_ARGS=(-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0) +for env_name in \ + OPENCLAW_BUNDLED_PLUGIN_SWEEP_TOTAL \ + OPENCLAW_BUNDLED_PLUGIN_SWEEP_INDEX \ + OPENCLAW_BUNDLED_PLUGIN_SWEEP_IDS; do + env_value="${!env_name:-}" + if [[ -n "$env_value" && "$env_value" != "undefined" && "$env_value" != "null" ]]; then + DOCKER_ENV_ARGS+=(-e "$env_name") + fi +done + +echo "Running bundled plugin install/uninstall Docker E2E..." +RUN_LOG="$(mktemp "${TMPDIR:-/tmp}/openclaw-bundled-plugin-install-uninstall.XXXXXX")" +if ! docker run --rm "${DOCKER_ENV_ARGS[@]}" -i "$IMAGE_NAME" bash -s >"$RUN_LOG" 2>&1 <<'EOF' +set -euo pipefail + +if [ -f dist/index.mjs ]; then + OPENCLAW_ENTRY="dist/index.mjs" +elif [ -f dist/index.js ]; then + OPENCLAW_ENTRY="dist/index.js" +else + echo "Missing dist/index.(m)js (build output):" + ls -la dist || true + exit 1 +fi +export OPENCLAW_ENTRY + +home_dir=$(mktemp -d "/tmp/openclaw-bundled-plugin-sweep.XXXXXX") +export HOME="$home_dir" + +node - <<'NODE' > /tmp/bundled-plugin-sweep-ids +const fs = require("node:fs"); +const path = require("node:path"); + +const explicit = (process.env.OPENCLAW_BUNDLED_PLUGIN_SWEEP_IDS || "") + .split(/[,\s]+/u) + .map((entry) => entry.trim()) + .filter(Boolean); +const extensionRoot = path.join(process.cwd(), "dist", "extensions"); +const manifestEntries = fs + .readdirSync(extensionRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => { + const manifestPath = path.join(extensionRoot, entry.name, "openclaw.plugin.json"); + if (!fs.existsSync(manifestPath)) { + return null; + } + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + const id = typeof manifest.id === "string" ? manifest.id.trim() : ""; + if (!id) { + throw new Error(`Bundled plugin manifest is missing id: ${manifestPath}`); + } + const required = manifest.configSchema?.required; + return { + id, + dir: entry.name, + requiresConfig: + Array.isArray(required) && required.some((value) => typeof value === "string"), + }; + }) + .filter(Boolean) + .sort((a, b) => a.id.localeCompare(b.id)); +const allEntries = + explicit.length > 0 + ? explicit.map( + (lookup) => + manifestEntries.find((entry) => entry.id === lookup || entry.dir === lookup) || { + id: lookup, + dir: lookup, + requiresConfig: false, + }, + ) + : manifestEntries; + +const total = Number.parseInt(process.env.OPENCLAW_BUNDLED_PLUGIN_SWEEP_TOTAL || "1", 10); +const index = Number.parseInt(process.env.OPENCLAW_BUNDLED_PLUGIN_SWEEP_INDEX || "0", 10); +if (!Number.isInteger(total) || total < 1) { + throw new Error(`OPENCLAW_BUNDLED_PLUGIN_SWEEP_TOTAL must be >= 1, got ${process.env.OPENCLAW_BUNDLED_PLUGIN_SWEEP_TOTAL}`); +} +if (!Number.isInteger(index) || index < 0 || index >= total) { + throw new Error(`OPENCLAW_BUNDLED_PLUGIN_SWEEP_INDEX must be in [0, ${total - 1}], got ${process.env.OPENCLAW_BUNDLED_PLUGIN_SWEEP_INDEX}`); +} + +const selected = allEntries.filter((_, candidateIndex) => candidateIndex % total === index); +if (selected.length === 0) { + throw new Error(`No bundled plugin ids selected for shard ${index}/${total}`); +} + +for (const entry of selected) { + console.log(`${entry.id}\t${entry.dir}\t${entry.requiresConfig ? "1" : "0"}`); +} +NODE + +mapfile -t plugin_entries < /tmp/bundled-plugin-sweep-ids +selected_labels=() +for plugin_entry in "${plugin_entries[@]}"; do + IFS=$'\t' read -r plugin_id plugin_dir _requires_config <<<"$plugin_entry" + selected_labels+=("${plugin_id}@${plugin_dir}") +done +echo "Selected ${#plugin_entries[@]} bundled plugins for shard ${OPENCLAW_BUNDLED_PLUGIN_SWEEP_INDEX:-0}/${OPENCLAW_BUNDLED_PLUGIN_SWEEP_TOTAL:-1}: ${selected_labels[*]}" + +assert_installed() { + local plugin_id="$1" + local plugin_dir="$2" + local requires_config="$3" + node - <<'NODE' "$plugin_id" "$plugin_dir" "$requires_config" +const fs = require("node:fs"); +const path = require("node:path"); + +const pluginId = process.argv[2]; +const pluginDir = process.argv[3]; +const requiresConfig = process.argv[4] === "1"; +const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); +const indexPath = path.join(process.env.HOME, ".openclaw", "plugins", "installs.json"); +const config = JSON.parse(fs.readFileSync(configPath, "utf8")); +const index = JSON.parse(fs.readFileSync(indexPath, "utf8")); +const records = index.installRecords ?? index.records ?? {}; +const record = records[pluginId]; +if (!record) { + throw new Error(`missing install record for ${pluginId}`); +} +if (record.source !== "path") { + throw new Error(`expected bundled install record source=path for ${pluginId}, got ${record.source}`); +} +if (typeof record.sourcePath !== "string" || !record.sourcePath.includes(`/dist/extensions/${pluginDir}`)) { + throw new Error(`unexpected bundled source path for ${pluginId}: ${record.sourcePath}`); +} +if (record.installPath !== record.sourcePath) { + throw new Error(`bundled install path should equal source path for ${pluginId}`); +} +const paths = config.plugins?.load?.paths || []; +if (!paths.includes(record.sourcePath)) { + throw new Error(`config load paths do not include bundled install path for ${pluginId}`); +} +if (requiresConfig && config.plugins?.entries?.[pluginId]?.enabled === true) { + throw new Error(`plugin requiring config should not be enabled immediately after install for ${pluginId}`); +} +if (!requiresConfig && config.plugins?.entries?.[pluginId]?.enabled !== true) { + throw new Error(`config entry is not enabled after install for ${pluginId}`); +} +const allow = config.plugins?.allow || []; +if (Array.isArray(allow) && allow.length > 0 && !allow.includes(pluginId)) { + throw new Error(`existing allowlist does not include ${pluginId} after install`); +} +if ((config.plugins?.deny || []).includes(pluginId)) { + throw new Error(`denylist contains ${pluginId} after install`); +} +NODE +} + +assert_uninstalled() { + local plugin_id="$1" + local plugin_dir="$2" + node - <<'NODE' "$plugin_id" "$plugin_dir" +const fs = require("node:fs"); +const path = require("node:path"); + +const pluginId = process.argv[2]; +const pluginDir = process.argv[3]; +const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); +const indexPath = path.join(process.env.HOME, ".openclaw", "plugins", "installs.json"); +const config = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, "utf8")) : {}; +const index = fs.existsSync(indexPath) ? JSON.parse(fs.readFileSync(indexPath, "utf8")) : {}; +const records = index.installRecords ?? index.records ?? {}; +if (records[pluginId]) { + throw new Error(`install record still present after uninstall for ${pluginId}`); +} +const paths = config.plugins?.load?.paths || []; +if (paths.some((entry) => String(entry).includes(`/dist/extensions/${pluginDir}`))) { + throw new Error(`load path still present after uninstall for ${pluginId}`); +} +if (config.plugins?.entries?.[pluginId]) { + throw new Error(`config entry still present after uninstall for ${pluginId}`); +} +if ((config.plugins?.allow || []).includes(pluginId)) { + throw new Error(`allowlist still contains ${pluginId} after uninstall`); +} +if ((config.plugins?.deny || []).includes(pluginId)) { + throw new Error(`denylist still contains ${pluginId} after uninstall`); +} +const managedPath = path.join(process.env.HOME, ".openclaw", "extensions", pluginId); +if (fs.existsSync(managedPath)) { + throw new Error(`managed install directory unexpectedly exists for bundled plugin ${pluginId}: ${managedPath}`); +} +NODE +} + +plugin_index=0 +for plugin_entry in "${plugin_entries[@]}"; do + IFS=$'\t' read -r plugin_id plugin_dir requires_config <<<"$plugin_entry" + install_log="/tmp/openclaw-install-${plugin_index}.log" + uninstall_log="/tmp/openclaw-uninstall-${plugin_index}.log" + echo "Installing bundled plugin: $plugin_id ($plugin_dir)" + node "$OPENCLAW_ENTRY" plugins install "$plugin_id" >"$install_log" 2>&1 || { + cat "$install_log" + exit 1 + } + assert_installed "$plugin_id" "$plugin_dir" "$requires_config" + + echo "Uninstalling bundled plugin: $plugin_id ($plugin_dir)" + node "$OPENCLAW_ENTRY" plugins uninstall "$plugin_id" --force >"$uninstall_log" 2>&1 || { + cat "$uninstall_log" + exit 1 + } + assert_uninstalled "$plugin_id" "$plugin_dir" + plugin_index=$((plugin_index + 1)) +done + +echo "bundled plugin install/uninstall sweep passed (${#plugin_entries[@]} plugin(s))" +EOF +then + cat "$RUN_LOG" + rm -f "$RUN_LOG" + exit 1 +fi +cat "$RUN_LOG" +rm -f "$RUN_LOG" + +echo "OK" diff --git a/scripts/lib/docker-e2e-scenarios.mjs b/scripts/lib/docker-e2e-scenarios.mjs index 73e81c1f793..8487cbb4f1b 100644 --- a/scripts/lib/docker-e2e-scenarios.mjs +++ b/scripts/lib/docker-e2e-scenarios.mjs @@ -215,6 +215,14 @@ export const mainLanes = [ resources: ["npm", "service"], weight: 6, }), + lane( + "bundled-plugin-install-uninstall", + "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:bundled-plugin-install-uninstall", + { + resources: ["npm"], + weight: 4, + }, + ), lane( "plugins-offline", "OPENCLAW_PLUGINS_E2E_CLAWHUB=0 OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:plugins", @@ -387,6 +395,14 @@ const releasePathChunks = { resources: ["npm", "service"], weight: 6, }), + lane( + "bundled-plugin-install-uninstall", + "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:bundled-plugin-install-uninstall", + { + resources: ["npm"], + weight: 4, + }, + ), npmLane("plugin-update", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:plugin-update"), ...bundledScenarioLanes, serviceLane( diff --git a/src/cli/plugins-cli.install.test.ts b/src/cli/plugins-cli.install.test.ts index afee5e71a06..5b9a917f77f 100644 --- a/src/cli/plugins-cli.install.test.ts +++ b/src/cli/plugins-cli.install.test.ts @@ -420,6 +420,42 @@ describe("plugins cli install", () => { expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); }); + it("does not persist incomplete config entries for config-gated bundled installs", async () => { + const cfg = { + plugins: { + entries: { + "memory-lancedb": { + config: { + embedding: {}, + }, + }, + }, + load: { + paths: ["/existing/plugin"], + }, + }, + } as OpenClawConfig; + loadConfig.mockReturnValue(cfg); + + await runPluginsCommand(["plugins", "install", "memory-lancedb"]); + + const writtenConfig = writeConfigFile.mock.calls.at(-1)?.[0] as OpenClawConfig; + expect(writtenConfig.plugins?.entries?.["memory-lancedb"]).toBeUndefined(); + expect(writtenConfig.plugins?.load?.paths).toEqual( + expect.arrayContaining(["/existing/plugin", expect.stringContaining("memory-lancedb")]), + ); + expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({ + "memory-lancedb": expect.objectContaining({ + source: "path", + sourcePath: expect.stringContaining("memory-lancedb"), + installPath: expect.stringContaining("memory-lancedb"), + }), + }); + expect(enablePluginInConfig).not.toHaveBeenCalled(); + expect(applyExclusiveSlotSelection).not.toHaveBeenCalled(); + expect(runtimeLogs.some((line) => line.includes("requires configuration first"))).toBe(true); + }); + it("passes force through as overwrite mode for ClawHub installs", async () => { const cfg = { plugins: { diff --git a/src/cli/plugins-install-command.ts b/src/cli/plugins-install-command.ts index 64ae250a654..6f748c090df 100644 --- a/src/cli/plugins-install-command.ts +++ b/src/cli/plugins-install-command.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import { collectChannelDoctorStaleConfigMutations } from "../commands/doctor/shared/channel-doctor.js"; import { readConfigFileSnapshot } from "../config/config.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; import { installHooksFromNpmSpec, installHooksFromPath } from "../hooks/install.js"; import { resolveArchiveKind } from "../infra/archive.js"; import { parseClawHubPluginSpec } from "../infra/clawhub.js"; @@ -18,6 +19,7 @@ import { installPluginFromMarketplace, resolveMarketplaceInstallShortcut, } from "../plugins/marketplace.js"; +import { validateJsonSchemaValue } from "../plugins/schema-validator.js"; import { defaultRuntime } from "../runtime.js"; import { theme } from "../terminal/theme.js"; import { shortenHomePath } from "../utils.js"; @@ -52,6 +54,54 @@ function resolveInstallSafetyOverrides(overrides: InstallSafetyOverrides): Insta }; } +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function isEmptyRecord(value: Record): boolean { + return Object.keys(value).length === 0; +} + +function hasValidBundledPluginConfig(params: { + bundledSource: BundledPluginSource; + existingEntry: unknown; +}): boolean { + if (!params.bundledSource.requiresConfig) { + return true; + } + if (!isRecord(params.existingEntry)) { + return false; + } + const config = params.existingEntry.config; + if (!isRecord(config)) { + return false; + } + if (!params.bundledSource.configSchema) { + return !isEmptyRecord(config); + } + return validateJsonSchemaValue({ + schema: params.bundledSource.configSchema, + cacheKey: `bundled-install:${params.bundledSource.pluginId}`, + value: config, + applyDefaults: true, + }).ok; +} + +function prepareConfigForDisabledBundledInstall( + config: OpenClawConfig, + pluginId: string, +): OpenClawConfig { + const entries = config.plugins?.entries ?? {}; + const { [pluginId]: _removedEntry, ...nextEntries } = entries; + return { + ...config, + plugins: { + ...config.plugins, + entries: nextEntries, + }, + }; +} + async function installBundledPluginSource(params: { snapshot: ConfigSnapshotForInstallPersist; rawSpec: string; @@ -60,14 +110,25 @@ async function installBundledPluginSource(params: { }) { const existing = params.snapshot.config.plugins?.load?.paths ?? []; const mergedPaths = Array.from(new Set([...existing, params.bundledSource.localPath])); + const existingEntry = params.snapshot.config.plugins?.entries?.[params.bundledSource.pluginId]; + const shouldEnable = hasValidBundledPluginConfig({ + bundledSource: params.bundledSource, + existingEntry, + }); + const configBase = shouldEnable + ? params.snapshot.config + : prepareConfigForDisabledBundledInstall(params.snapshot.config, params.bundledSource.pluginId); + const configWarning = shouldEnable + ? "" + : `Installed bundled plugin "${params.bundledSource.pluginId}" without enabling it because it requires configuration first. Configure it, then run \`openclaw plugins enable ${params.bundledSource.pluginId}\`.`; await persistPluginInstall({ snapshot: { config: { - ...params.snapshot.config, + ...configBase, plugins: { - ...params.snapshot.config.plugins, + ...configBase.plugins, load: { - ...params.snapshot.config.plugins?.load, + ...configBase.plugins?.load, paths: mergedPaths, }, }, @@ -81,7 +142,8 @@ async function installBundledPluginSource(params: { sourcePath: params.bundledSource.localPath, installPath: params.bundledSource.localPath, }, - warningMessage: params.warning, + enable: shouldEnable, + warningMessage: [params.warning, configWarning].filter(Boolean).join("\n"), }); } diff --git a/src/cli/plugins-install-persist.test.ts b/src/cli/plugins-install-persist.test.ts index b4e26d69b0e..6aec2ce7cd9 100644 --- a/src/cli/plugins-install-persist.test.ts +++ b/src/cli/plugins-install-persist.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { + applyExclusiveSlotSelection, enablePluginInConfig, refreshPluginRegistry, resetPluginsCliTestState, @@ -107,4 +108,68 @@ describe("persistPluginInstall", () => { expect(next).toEqual(enabledConfig); }); + + it("can persist an install record without enabling a plugin that needs config first", async () => { + const { persistPluginInstall } = await import("./plugins-install-persist.js"); + const baseConfig = { + plugins: { + entries: {}, + }, + } as OpenClawConfig; + + const next = await persistPluginInstall({ + snapshot: { + config: baseConfig, + baseHash: "config-1", + }, + pluginId: "memory-lancedb", + enable: false, + install: { + source: "path", + spec: "memory-lancedb", + sourcePath: "/app/dist/extensions/memory-lancedb", + installPath: "/app/dist/extensions/memory-lancedb", + }, + }); + + expect(next).toEqual(baseConfig); + expect(enablePluginInConfig).not.toHaveBeenCalled(); + expect(applyExclusiveSlotSelection).not.toHaveBeenCalled(); + expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({ + "memory-lancedb": expect.objectContaining({ + source: "path", + sourcePath: "/app/dist/extensions/memory-lancedb", + }), + }); + expect(writeConfigFile).toHaveBeenCalledWith(baseConfig); + }); + + it("does not add disabled installs to restrictive allowlists", async () => { + const { persistPluginInstall } = await import("./plugins-install-persist.js"); + const baseConfig = { + plugins: { + allow: ["memory-core"], + deny: ["memory-lancedb"], + }, + } as OpenClawConfig; + + const next = await persistPluginInstall({ + snapshot: { + config: baseConfig, + baseHash: "config-1", + }, + pluginId: "memory-lancedb", + enable: false, + install: { + source: "path", + spec: "memory-lancedb", + sourcePath: "/app/dist/extensions/memory-lancedb", + installPath: "/app/dist/extensions/memory-lancedb", + }, + }); + + expect(next.plugins?.allow).toEqual(["memory-core"]); + expect(next.plugins?.deny).toEqual(["memory-lancedb"]); + expect(next.plugins?.entries?.["memory-lancedb"]).toBeUndefined(); + }); }); diff --git a/src/cli/plugins-install-persist.ts b/src/cli/plugins-install-persist.ts index 9597e2d94af..34813aa74e9 100644 --- a/src/cli/plugins-install-persist.ts +++ b/src/cli/plugins-install-persist.ts @@ -61,20 +61,32 @@ export async function persistPluginInstall(params: { snapshot: ConfigSnapshotForInstallPersist; pluginId: string; install: Omit; + enable?: boolean; successMessage?: string; warningMessage?: string; }): Promise { - const installConfig = removeInstalledPluginFromDenylist( - addInstalledPluginToAllowlist(params.snapshot.config, params.pluginId), - params.pluginId, - ); - let next = enablePluginInConfig(installConfig, params.pluginId).config; + const installConfig = + params.enable === false + ? params.snapshot.config + : removeInstalledPluginFromDenylist( + addInstalledPluginToAllowlist(params.snapshot.config, params.pluginId), + params.pluginId, + ); + let next = + params.enable === false + ? installConfig + : enablePluginInConfig(installConfig, params.pluginId, { + updateChannelConfig: false, + }).config; const installRecords = await loadInstalledPluginIndexInstallRecords(); const nextInstallRecords = recordPluginInstallInRecords(installRecords, { pluginId: params.pluginId, ...params.install, }); - const slotResult = applySlotSelectionForPlugin(next, params.pluginId); + const slotResult = + params.enable === false + ? { config: next, warnings: [] } + : applySlotSelectionForPlugin(next, params.pluginId); next = withoutPluginInstallRecords(slotResult.config); await commitPluginInstallRecordsWithConfig({ previousInstallRecords: installRecords, diff --git a/src/plugins/bundled-sources.test.ts b/src/plugins/bundled-sources.test.ts index f0ab77a7019..c415f10b66d 100644 --- a/src/plugins/bundled-sources.test.ts +++ b/src/plugins/bundled-sources.test.ts @@ -48,10 +48,24 @@ function setBundledDiscoveryCandidates(candidates: unknown[]) { }); } -function setBundledManifestIdsByRoot(manifestIds: Record) { +function setBundledManifestIdsByRoot( + manifestIds: Record, +) { loadPluginManifestMock.mockImplementation((rootDir: string) => rootDir in manifestIds - ? { ok: true, manifest: { id: manifestIds[rootDir] } } + ? { + ok: true, + manifest: + typeof manifestIds[rootDir] === "string" + ? { id: manifestIds[rootDir] } + : { + id: manifestIds[rootDir].id, + configSchema: { + type: "object", + required: manifestIds[rootDir].required, + }, + }, + } : { ok: false, error: "invalid manifest", @@ -81,11 +95,15 @@ function createResolvedBundledSource(params: { pluginId: string; localPath: string; npmSpec?: string; + configSchema?: Record; + requiresConfig?: boolean; }) { return { pluginId: params.pluginId, localPath: params.localPath, npmSpec: params.npmSpec ?? `@openclaw/${params.pluginId}`, + ...(params.configSchema ? { configSchema: params.configSchema } : {}), + requiresConfig: params.requiresConfig ?? false, }; } @@ -212,6 +230,33 @@ describe("bundled plugin sources", () => { }); }); + it("marks bundled sources that require plugin config before activation", () => { + setBundledDiscoveryCandidates([ + createBundledCandidate({ + rootDir: appBundledPluginRoot("memory-lancedb"), + packageName: "@openclaw/memory-lancedb", + }), + ]); + setBundledManifestIdsByRoot({ + [appBundledPluginRoot("memory-lancedb")]: { + id: "memory-lancedb", + required: ["embedding"], + }, + }); + + expect(resolveBundledPluginSources({}).get("memory-lancedb")).toEqual( + createResolvedBundledSource({ + pluginId: "memory-lancedb", + localPath: appBundledPluginRoot("memory-lancedb"), + configSchema: { + type: "object", + required: ["embedding"], + }, + requiresConfig: true, + }), + ); + }); + it("reuses a pre-resolved bundled map for repeated lookups", () => { const bundled = new Map([ [ diff --git a/src/plugins/bundled-sources.ts b/src/plugins/bundled-sources.ts index 7d07460b8ce..94a21e611ce 100644 --- a/src/plugins/bundled-sources.ts +++ b/src/plugins/bundled-sources.ts @@ -6,6 +6,8 @@ export type BundledPluginSource = { pluginId: string; localPath: string; npmSpec?: string; + configSchema?: Record; + requiresConfig?: boolean; }; export type BundledPluginLookup = @@ -64,12 +66,28 @@ export function resolveBundledPluginSources(params: { pluginId, localPath: candidate.rootDir, npmSpec, + ...(isRecord(manifest.manifest.configSchema) + ? { configSchema: manifest.manifest.configSchema } + : {}), + requiresConfig: pluginConfigSchemaHasRequiredFields(manifest.manifest.configSchema), }); } return bundled; } +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function pluginConfigSchemaHasRequiredFields(schema: unknown): boolean { + if (!isRecord(schema)) { + return false; + } + const required = schema.required; + return Array.isArray(required) && required.some((entry) => typeof entry === "string"); +} + export function findBundledPluginSource(params: { lookup: BundledPluginLookup; workspaceDir?: string; diff --git a/src/plugins/enable.test.ts b/src/plugins/enable.test.ts index 5c6849c8857..68e5bcd268a 100644 --- a/src/plugins/enable.test.ts +++ b/src/plugins/enable.test.ts @@ -150,4 +150,14 @@ describe("enablePluginInConfig", () => { assert, }); }); + + it("can enable a built-in channel plugin entry without mutating channel config", () => { + const result = enablePluginInConfig({} as OpenClawConfig, "twitch", { + updateChannelConfig: false, + }); + + expect(result.enabled).toBe(true); + expect(result.config.plugins?.entries?.twitch?.enabled).toBe(true); + expect(result.config.channels?.twitch).toBeUndefined(); + }); }); diff --git a/src/plugins/enable.ts b/src/plugins/enable.ts index 6de8ce0c5b0..ba4b4dac07b 100644 --- a/src/plugins/enable.ts +++ b/src/plugins/enable.ts @@ -8,7 +8,11 @@ export type PluginEnableResult = { reason?: string; }; -export function enablePluginInConfig(cfg: OpenClawConfig, pluginId: string): PluginEnableResult { +export function enablePluginInConfig( + cfg: OpenClawConfig, + pluginId: string, + options: { updateChannelConfig?: boolean } = {}, +): PluginEnableResult { const builtInChannelId = normalizeChatChannelId(pluginId); const resolvedId = builtInChannelId ?? pluginId; if (cfg.plugins?.enabled === false) { @@ -26,5 +30,8 @@ export function enablePluginInConfig(cfg: OpenClawConfig, pluginId: string): Plu ) { return { config: cfg, enabled: false, reason: "blocked by allowlist" }; } - return { config: setPluginEnabledInConfig(cfg, resolvedId, true), enabled: true }; + return { + config: setPluginEnabledInConfig(cfg, resolvedId, true, options), + enabled: true, + }; } diff --git a/src/plugins/toggle-config.ts b/src/plugins/toggle-config.ts index f2f3945b4ef..f8e8a6137e6 100644 --- a/src/plugins/toggle-config.ts +++ b/src/plugins/toggle-config.ts @@ -5,6 +5,7 @@ export function setPluginEnabledInConfig( config: OpenClawConfig, pluginId: string, enabled: boolean, + options: { updateChannelConfig?: boolean } = {}, ): OpenClawConfig { const builtInChannelId = normalizeChatChannelId(pluginId); const resolvedId = builtInChannelId ?? pluginId; @@ -23,7 +24,7 @@ export function setPluginEnabledInConfig( }, }; - if (!builtInChannelId) { + if (!builtInChannelId || options.updateChannelConfig === false) { return next; } diff --git a/test/scripts/docker-build-helper.test.ts b/test/scripts/docker-build-helper.test.ts index 9af422f243f..712f7e51c9b 100644 --- a/test/scripts/docker-build-helper.test.ts +++ b/test/scripts/docker-build-helper.test.ts @@ -9,6 +9,8 @@ const INSTALL_E2E_RUNNER_PATH = "scripts/docker/install-sh-e2e/run.sh"; const LIVE_CLI_BACKEND_DOCKER_PATH = "scripts/test-live-cli-backend-docker.sh"; const LIVE_BUILD_DOCKER_PATH = "scripts/test-live-build-docker.sh"; const OPENAI_WEB_SEARCH_MINIMAL_E2E_PATH = "scripts/e2e/openai-web-search-minimal-docker.sh"; +const BUNDLED_PLUGIN_INSTALL_UNINSTALL_E2E_PATH = + "scripts/e2e/bundled-plugin-install-uninstall-docker.sh"; const PLUGINS_DOCKER_E2E_PATH = "scripts/e2e/plugins-docker.sh"; const PLUGIN_UPDATE_DOCKER_E2E_PATH = "scripts/e2e/plugin-update-unchanged-docker.sh"; const DOCTOR_SWITCH_DOCKER_E2E_PATH = "scripts/e2e/doctor-install-switch-docker.sh"; @@ -90,6 +92,7 @@ describe("docker build helper", () => { const scenarios = readFileSync(DOCKER_E2E_SCENARIOS_PATH, "utf8"); expect(scenarios).toContain('"plugins-offline"'); + expect(scenarios).toContain('"bundled-plugin-install-uninstall"'); expect(scenarios).toContain("OPENCLAW_PLUGINS_E2E_CLAWHUB=0"); expect(scenarios).toContain('"bundled-channel-deps-compat"'); expect(scenarios).toContain("test:docker:bundled-channel-deps:fast"); @@ -125,6 +128,19 @@ describe("docker build helper", () => { ); }); + it("keeps bundled plugin install/uninstall sweep chunkable", () => { + const runner = readFileSync(BUNDLED_PLUGIN_INSTALL_UNINSTALL_E2E_PATH, "utf8"); + + expect(runner).toContain("OPENCLAW_BUNDLED_PLUGIN_SWEEP_TOTAL"); + expect(runner).toContain("OPENCLAW_BUNDLED_PLUGIN_SWEEP_INDEX"); + expect(runner).toContain('"openclaw.plugin.json"'); + expect(runner).toContain("read -r plugin_id plugin_dir requires_config"); + expect(runner).toContain('node "$OPENCLAW_ENTRY" plugins install "$plugin_id"'); + expect(runner).toContain('node "$OPENCLAW_ENTRY" plugins uninstall "$plugin_id" --force'); + expect(runner).toContain("assert_installed"); + expect(runner).toContain("assert_uninstalled"); + }); + it("passes installer tag env to bash, not curl", () => { const runner = readFileSync(INSTALL_E2E_RUNNER_PATH, "utf8"); diff --git a/test/scripts/docker-e2e-plan.test.ts b/test/scripts/docker-e2e-plan.test.ts index 50538b02fe2..cb4c36ecb5c 100644 --- a/test/scripts/docker-e2e-plan.test.ts +++ b/test/scripts/docker-e2e-plan.test.ts @@ -111,6 +111,23 @@ describe("scripts/lib/docker-e2e-plan", () => { ]); }); + it("plans bundled plugin install/uninstall as package-backed plugin coverage", () => { + const plan = planFor({ selectedLaneNames: ["bundled-plugin-install-uninstall"] }); + + expect(plan.lanes).toEqual([ + expect.objectContaining({ + imageKind: "functional", + live: false, + name: "bundled-plugin-install-uninstall", + resources: expect.arrayContaining(["docker", "npm"]), + }), + ]); + expect(plan.needs).toMatchObject({ + functionalImage: true, + package: true, + }); + }); + it("rejects unknown selected lanes with the available lane names", () => { expect(() => planFor({ selectedLaneNames: ["missing-lane"] })).toThrow( /OPENCLAW_DOCKER_ALL_LANES unknown lane\(s\): missing-lane/u,