diff --git a/CHANGELOG.md b/CHANGELOG.md index a4a55a74156..a95c7b05e13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -250,6 +250,7 @@ Docs: https://docs.openclaw.ai - Channels/Telegram: require an observed Telegram send, edit, or fallback before treating a forum-topic final as delivered, so final replies generated in transcript no longer disappear from Telegram topics. Fixes #76554. (#76764) Thanks @bubucilo and @obviyus. - Plugins/update: keep externalized bundled npm bridge updates on the normal plugin security scanner path instead of granting source-linked official trust without artifact provenance. (#76765) Thanks @Lucenx9. - Agents/reply context: label replied-to messages as the current user message target in model-visible metadata, so short replies are grounded to their explicit reply target instead of nearby chat history. (#76817) Thanks @obviyus. +- Doctor/plugins: install configured missing official plugins such as Discord and Brave during doctor/update repair, auto-enable repaired provider plugins, preserve config when a download fails, and stop auto-enable from inventing plugin entries when no manifest declares a configured channel. Fixes #76872. Thanks @jack-stormentswe. ## 2026.5.2 diff --git a/docs/cli/doctor.md b/docs/cli/doctor.md index fd63975e3e4..e22c755cbf6 100644 --- a/docs/cli/doctor.md +++ b/docs/cli/doctor.md @@ -45,7 +45,7 @@ Notes: - State integrity checks now detect orphan transcript files in the sessions directory. Archiving them as `.deleted.` requires an interactive confirmation; `--fix`, `--yes`, and headless runs leave them in place. - Doctor also scans `~/.openclaw/cron/jobs.json` (or `cron.store`) for legacy cron job shapes and can rewrite them in place before the scheduler has to auto-normalize them at runtime. - On Linux, doctor warns when the user's crontab still runs legacy `~/.openclaw/bin/ensure-whatsapp.sh`; that script is no longer maintained and can log false WhatsApp gateway outages when cron lacks the systemd user-bus environment. -- Doctor cleans legacy plugin dependency staging state created by older OpenClaw versions. It also repairs missing configured downloadable plugins when the registry can resolve them, and the 2026.5.2 doctor pass automatically installs downloadable plugins that an older config already uses before marking the config touched for that release. +- Doctor cleans legacy plugin dependency staging state created by older OpenClaw versions. It also repairs missing configured downloadable plugins when the registry can resolve them, and the 2026.5.2 doctor pass automatically installs downloadable plugins that an older config already uses before marking the config touched for that release. If the download fails, doctor reports the install error and preserves the configured plugin entry for the next repair attempt. - Doctor repairs stale plugin config by removing missing plugin ids from `plugins.allow`/`plugins.entries`, plus matching dangling channel config, heartbeat targets, and channel model overrides when plugin discovery is healthy. - Doctor quarantines invalid plugin config by disabling the affected `plugins.entries.` entry and removing its invalid `config` payload. Gateway startup already skips only that bad plugin so other plugins and channels can keep running. - Set `OPENCLAW_SERVICE_REPAIR_POLICY=external` when another supervisor owns the gateway lifecycle. Doctor still reports gateway/service health and applies non-service repairs, but skips service install/start/restart/bootstrap and legacy service cleanup. diff --git a/scripts/e2e/lib/upgrade-survivor/assertions.mjs b/scripts/e2e/lib/upgrade-survivor/assertions.mjs index 7555be70f7a..f241e024304 100644 --- a/scripts/e2e/lib/upgrade-survivor/assertions.mjs +++ b/scripts/e2e/lib/upgrade-survivor/assertions.mjs @@ -416,6 +416,16 @@ function assertExternalPluginInstall(records, pluginId, packageName) { } } +function assertConfiguredPluginAvailable(index, pluginId, packageName) { + const records = index.installRecords ?? {}; + const bundled = (index.plugins ?? []).find((plugin) => plugin?.pluginId === pluginId); + if (bundled) { + assert(bundled.enabled !== false, `configured bundled ${pluginId} plugin is disabled`); + return; + } + assertExternalPluginInstall(records, pluginId, packageName); +} + function assertConfiguredPluginInstalls() { const coverage = getCoverage(); const stage = process.env.OPENCLAW_UPGRADE_SURVIVOR_ASSERT_STAGE || "survival"; @@ -432,7 +442,7 @@ function assertConfiguredPluginInstalls() { assert(!matrix, "internal matrix plugin should not be installed externally"); assert(bundledMatrix, "configured bundled matrix plugin is missing from the plugin index"); assert(bundledMatrix.enabled !== false, "configured bundled matrix plugin is disabled"); - assertExternalPluginInstall(records, "discord", "@openclaw/discord"); + assertConfiguredPluginAvailable(index, "brave", "@openclaw/brave-plugin"); assert(!records.telegram, "internal telegram plugin should not be installed externally"); } diff --git a/scripts/e2e/lib/upgrade-survivor/config-recipe/plugins-configured-installs.json b/scripts/e2e/lib/upgrade-survivor/config-recipe/plugins-configured-installs.json index 13868c3de31..db9d980b89f 100644 --- a/scripts/e2e/lib/upgrade-survivor/config-recipe/plugins-configured-installs.json +++ b/scripts/e2e/lib/upgrade-survivor/config-recipe/plugins-configured-installs.json @@ -1,7 +1,19 @@ { "enabled": true, - "allow": ["discord", "telegram", "matrix"], + "allow": ["brave", "discord", "telegram", "matrix"], "entries": { + "brave": { + "enabled": true, + "config": { + "webSearch": { + "apiKey": { + "source": "env", + "provider": "default", + "id": "BRAVE_API_KEY" + } + } + } + }, "discord": { "enabled": true }, diff --git a/scripts/e2e/lib/upgrade-survivor/run.sh b/scripts/e2e/lib/upgrade-survivor/run.sh index 83cc4d5881f..61a1734f0fc 100644 --- a/scripts/e2e/lib/upgrade-survivor/run.sh +++ b/scripts/e2e/lib/upgrade-survivor/run.sh @@ -18,6 +18,7 @@ export DISCORD_BOT_TOKEN="upgrade-survivor-discord-token" export TELEGRAM_BOT_TOKEN="123456:upgrade-survivor-telegram-token" export FEISHU_APP_SECRET="upgrade-survivor-feishu-secret" export MATRIX_ACCESS_TOKEN="upgrade-survivor-matrix-token" +export BRAVE_API_KEY="BSA_upgrade_survivor_brave_key" ARTIFACT_ROOT="$(dirname "${OPENCLAW_UPGRADE_SURVIVOR_SUMMARY_JSON:-/tmp/openclaw-upgrade-survivor-artifacts/summary.json}")" mkdir -p "$ARTIFACT_ROOT" @@ -40,6 +41,7 @@ CURRENT_PHASE="setup" FAILURE_PHASE="" FAILURE_MESSAGE="" gateway_pid="" +plugin_registry_pid="" baseline_spec="" baseline_version="" baseline_version_expected="0" @@ -191,6 +193,9 @@ NODE } cleanup() { + if [ -n "${plugin_registry_pid:-}" ]; then + kill "$plugin_registry_pid" >/dev/null 2>&1 || true + fi openclaw_e2e_terminate_gateways "${gateway_pid:-}" } @@ -281,6 +286,92 @@ configured_plugin_installs_enabled() { [ "$SCENARIO" = "configured-plugin-installs" ] } +configure_configured_plugin_install_fixture_registry() { + configured_plugin_installs_enabled || return 0 + + local fixture_root="$ARTIFACT_ROOT/configured-plugin-installs-npm-fixture" + local package_dir="$fixture_root/package" + local tarball="$fixture_root/openclaw-brave-plugin-2026.5.2.tgz" + local port_file="$fixture_root/npm-registry-port" + local log_file="$fixture_root/npm-registry.log" + mkdir -p "$package_dir" + FIXTURE_PACKAGE_DIR="$package_dir" node <<'NODE' +const fs = require("node:fs"); +const path = require("node:path"); +const root = process.env.FIXTURE_PACKAGE_DIR; +fs.mkdirSync(root, { recursive: true }); +fs.writeFileSync( + path.join(root, "package.json"), + `${JSON.stringify( + { + name: "@openclaw/brave-plugin", + version: "2026.5.2", + openclaw: { extensions: ["./index.js"] }, + }, + null, + 2, + )}\n`, +); +fs.writeFileSync( + path.join(root, "openclaw.plugin.json"), + `${JSON.stringify( + { + id: "brave", + activation: { onStartup: false }, + providerAuthEnvVars: { brave: ["BRAVE_API_KEY"] }, + contracts: { webSearchProviders: ["brave"] }, + configSchema: { + type: "object", + additionalProperties: false, + properties: { + webSearch: { + type: "object", + additionalProperties: false, + properties: { + apiKey: { type: ["string", "object"] }, + mode: { type: "string", enum: ["web", "llm-context"] }, + baseUrl: { type: ["string", "object"] }, + }, + }, + }, + }, + }, + null, + 2, + )}\n`, +); +fs.writeFileSync( + path.join(root, "index.js"), + `module.exports = { id: "brave", name: "Brave Fixture", register() {} };\n`, +); +NODE + tar -czf "$tarball" -C "$fixture_root" package + node scripts/e2e/lib/plugins/npm-registry-server.mjs \ + "$port_file" \ + "@openclaw/brave-plugin" \ + "2026.5.2" \ + "$tarball" \ + >"$log_file" 2>&1 & + plugin_registry_pid="$!" + + for _ in $(seq 1 100); do + if [ -s "$port_file" ]; then + export NPM_CONFIG_REGISTRY="http://127.0.0.1:$(cat "$port_file")" + export npm_config_registry="$NPM_CONFIG_REGISTRY" + return 0 + fi + if ! kill -0 "$plugin_registry_pid" 2>/dev/null; then + cat "$log_file" >&2 || true + return 1 + fi + sleep 0.1 + done + + cat "$log_file" >&2 || true + echo "Timed out waiting for configured plugin install npm fixture registry." >&2 + return 1 +} + legacy_plugin_dependency_probe_paths() { local plugin="$1" local plugin_dir @@ -699,6 +790,7 @@ phase seed-legacy-runtime-deps-symlink seed_legacy_runtime_deps_symlink phase resolve-candidate resolve_candidate_version phase update-candidate update_candidate phase assert-legacy-plugin-dependency-debris-before-doctor assert_legacy_plugin_dependency_debris_before_doctor +phase configure-configured-plugin-install-fixture-registry configure_configured_plugin_install_fixture_registry phase doctor run_doctor phase assert-legacy-plugin-dependency-debris-cleaned assert_legacy_plugin_dependency_debris_cleaned phase assert-legacy-runtime-deps-symlink-repaired assert_legacy_runtime_deps_symlink_repaired diff --git a/scripts/e2e/upgrade-survivor-docker.sh b/scripts/e2e/upgrade-survivor-docker.sh index dc44e5c4280..23de3a1a1c5 100755 --- a/scripts/e2e/upgrade-survivor-docker.sh +++ b/scripts/e2e/upgrade-survivor-docker.sh @@ -143,13 +143,104 @@ export OPENAI_API_KEY="sk-openclaw-upgrade-survivor" export DISCORD_BOT_TOKEN="upgrade-survivor-discord-token" export TELEGRAM_BOT_TOKEN="123456:upgrade-survivor-telegram-token" export FEISHU_APP_SECRET="upgrade-survivor-feishu-secret" +export BRAVE_API_KEY="BSA_upgrade_survivor_brave_key" gateway_pid="" +plugin_registry_pid="" cleanup() { + if [ -n "${plugin_registry_pid:-}" ]; then + kill "$plugin_registry_pid" >/dev/null 2>&1 || true + fi openclaw_e2e_terminate_gateways "${gateway_pid:-}" } trap cleanup EXIT +configure_configured_plugin_install_fixture_registry() { + [ "${OPENCLAW_UPGRADE_SURVIVOR_SCENARIO:-base}" = "configured-plugin-installs" ] || return 0 + + local fixture_root="$OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_ROOT/configured-plugin-installs-npm-fixture" + local package_dir="$fixture_root/package" + local tarball="$fixture_root/openclaw-brave-plugin-2026.5.2.tgz" + local port_file="$fixture_root/npm-registry-port" + local log_file="$fixture_root/npm-registry.log" + mkdir -p "$package_dir" + FIXTURE_PACKAGE_DIR="$package_dir" node <<'"'"'NODE'"'"' +const fs = require("node:fs"); +const path = require("node:path"); +const root = process.env.FIXTURE_PACKAGE_DIR; +fs.mkdirSync(root, { recursive: true }); +fs.writeFileSync( + path.join(root, "package.json"), + `${JSON.stringify( + { + name: "@openclaw/brave-plugin", + version: "2026.5.2", + openclaw: { extensions: ["./index.js"] }, + }, + null, + 2, + )}\n`, +); +fs.writeFileSync( + path.join(root, "openclaw.plugin.json"), + `${JSON.stringify( + { + id: "brave", + activation: { onStartup: false }, + providerAuthEnvVars: { brave: ["BRAVE_API_KEY"] }, + contracts: { webSearchProviders: ["brave"] }, + configSchema: { + type: "object", + additionalProperties: false, + properties: { + webSearch: { + type: "object", + additionalProperties: false, + properties: { + apiKey: { type: ["string", "object"] }, + mode: { type: "string", enum: ["web", "llm-context"] }, + baseUrl: { type: ["string", "object"] }, + }, + }, + }, + }, + }, + null, + 2, + )}\n`, +); +fs.writeFileSync( + path.join(root, "index.js"), + `module.exports = { id: "brave", name: "Brave Fixture", register() {} };\n`, +); +NODE + tar -czf "$tarball" -C "$fixture_root" package + node scripts/e2e/lib/plugins/npm-registry-server.mjs \ + "$port_file" \ + "@openclaw/brave-plugin" \ + "2026.5.2" \ + "$tarball" \ + >"$log_file" 2>&1 & + plugin_registry_pid="$!" + + for _ in $(seq 1 100); do + if [ -s "$port_file" ]; then + export NPM_CONFIG_REGISTRY="http://127.0.0.1:$(cat "$port_file")" + export npm_config_registry="$NPM_CONFIG_REGISTRY" + return 0 + fi + if ! kill -0 "$plugin_registry_pid" 2>/dev/null; then + cat "$log_file" >&2 || true + return 1 + fi + sleep 0.1 + done + + cat "$log_file" >&2 || true + echo "Timed out waiting for configured plugin install npm fixture registry." >&2 + return 1 +} + openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}" node scripts/e2e/lib/upgrade-survivor/assertions.mjs seed @@ -178,6 +269,7 @@ if [ "$update_status" -ne 0 ]; then fi echo "Running non-interactive doctor repair..." +configure_configured_plugin_install_fixture_registry if ! openclaw doctor --fix --non-interactive >/tmp/openclaw-upgrade-survivor-doctor.log 2>&1; then echo "openclaw doctor failed" >&2 cat /tmp/openclaw-upgrade-survivor-doctor.log >&2 || true @@ -220,7 +312,7 @@ node scripts/e2e/lib/upgrade-survivor/probe-gateway.mjs \ --base-url "http://127.0.0.1:$PORT" \ --path /readyz \ --expect ready \ - --allow-failing discord,telegram,whatsapp,feishu \ + --allow-failing discord,telegram,whatsapp,feishu,matrix \ --out /tmp/openclaw-upgrade-survivor-readyz.json echo "Checking gateway RPC status..." diff --git a/src/commands/doctor/repair-sequencing.test.ts b/src/commands/doctor/repair-sequencing.test.ts index 21f5c09b9bc..d07bdfd0326 100644 --- a/src/commands/doctor/repair-sequencing.test.ts +++ b/src/commands/doctor/repair-sequencing.test.ts @@ -1,7 +1,21 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import { runDoctorRepairSequence } from "./repair-sequencing.js"; +const mocks = vi.hoisted(() => ({ + applyPluginAutoEnable: vi.fn(), + maybeRepairStalePluginConfig: vi.fn(), + repairMissingConfiguredPluginInstalls: vi.fn(), +})); + +vi.mock("../../config/plugin-auto-enable.js", () => ({ + applyPluginAutoEnable: mocks.applyPluginAutoEnable, +})); + +vi.mock("./shared/missing-configured-plugin-install.js", () => ({ + repairMissingConfiguredPluginInstalls: mocks.repairMissingConfiguredPluginInstalls, +})); + vi.mock("./shared/channel-doctor.js", () => ({ collectChannelDoctorRepairMutations: ({ cfg }: { cfg: OpenClawConfig }) => { const allowFrom = cfg.channels?.discord?.allowFrom as unknown[] | undefined; @@ -70,10 +84,7 @@ vi.mock("./shared/open-policy-allowfrom.js", () => ({ })); vi.mock("./shared/stale-plugin-config.js", () => ({ - maybeRepairStalePluginConfig: (cfg: OpenClawConfig) => ({ - config: cfg, - changes: [], - }), + maybeRepairStalePluginConfig: mocks.maybeRepairStalePluginConfig, })); vi.mock("./shared/invalid-plugin-config.js", () => ({ @@ -128,6 +139,22 @@ vi.mock("./shared/exec-safe-bins.js", () => ({ })); describe("doctor repair sequencing", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.applyPluginAutoEnable.mockImplementation((params: { config: OpenClawConfig }) => ({ + config: params.config, + changes: [], + })); + mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({ + changes: [], + warnings: [], + }); + mocks.maybeRepairStalePluginConfig.mockImplementation((cfg: OpenClawConfig) => ({ + config: cfg, + changes: [], + })); + }); + it("applies ordered repairs and sanitizes empty-allowlist warnings", async () => { const result = await runDoctorRepairSequence({ state: { @@ -231,4 +258,201 @@ describe("doctor repair sequencing", () => { expect(result.state.pendingChanges).toBe(false); expect(result.state.candidate.channels?.discord?.allowFrom).toEqual([106232522769186816]); }); + + it("auto-enables newly installed configured plugins after doctor repair", async () => { + mocks.repairMissingConfiguredPluginInstalls.mockResolvedValueOnce({ + changes: ['Installed missing configured plugin "brave" from @openclaw/brave-plugin.'], + warnings: [], + }); + mocks.applyPluginAutoEnable.mockImplementationOnce((params: { config: OpenClawConfig }) => ({ + config: { + ...params.config, + plugins: { + ...params.config.plugins, + allow: ["telegram", "brave"], + entries: { + ...params.config.plugins?.entries, + brave: { enabled: true }, + }, + }, + }, + changes: ["brave web search provider selected, enabled automatically."], + })); + + const result = await runDoctorRepairSequence({ + state: { + cfg: { + tools: { web: { search: { provider: "brave" } } }, + plugins: { allow: ["telegram"] }, + } as OpenClawConfig, + candidate: { + tools: { web: { search: { provider: "brave" } } }, + plugins: { allow: ["telegram"] }, + } as OpenClawConfig, + pendingChanges: false, + fixHints: [], + }, + doctorFixCommand: "openclaw doctor --fix", + }); + + expect(result.state.pendingChanges).toBe(true); + expect(result.state.candidate.plugins?.allow).toEqual(["telegram", "brave"]); + expect(result.state.candidate.plugins?.entries?.brave?.enabled).toBe(true); + expect(result.changeNotes).toEqual( + expect.arrayContaining([ + 'Installed missing configured plugin "brave" from @openclaw/brave-plugin.', + "brave web search provider selected, enabled automatically.", + ]), + ); + }); + + it("does not remove deferred configured plugins during the package update doctor pass", async () => { + mocks.repairMissingConfiguredPluginInstalls.mockResolvedValueOnce({ + changes: [ + 'Deferred missing configured plugin "brave" install repair until post-update doctor.', + ], + warnings: [], + }); + mocks.maybeRepairStalePluginConfig.mockImplementationOnce((cfg: OpenClawConfig) => ({ + config: { + ...cfg, + plugins: { + ...cfg.plugins, + allow: [], + entries: {}, + }, + }, + changes: ["- plugins.entries: removed 1 stale plugin entry (brave)"], + })); + + const result = await runDoctorRepairSequence({ + state: { + cfg: { + plugins: { + allow: ["brave"], + entries: { + brave: { + enabled: true, + config: { + webSearch: { + apiKey: { + source: "env", + provider: "default", + id: "BRAVE_API_KEY", + }, + }, + }, + }, + }, + }, + } as OpenClawConfig, + candidate: { + plugins: { + allow: ["brave"], + entries: { + brave: { + enabled: true, + config: { + webSearch: { + apiKey: { + source: "env", + provider: "default", + id: "BRAVE_API_KEY", + }, + }, + }, + }, + }, + }, + } as OpenClawConfig, + pendingChanges: false, + fixHints: [], + }, + doctorFixCommand: "openclaw doctor --fix", + env: { + OPENCLAW_UPDATE_IN_PROGRESS: "1", + }, + }); + + expect(mocks.maybeRepairStalePluginConfig).not.toHaveBeenCalled(); + expect(result.state.candidate.plugins?.allow).toEqual(["brave"]); + expect(result.state.candidate.plugins?.entries?.brave?.enabled).toBe(true); + expect(result.changeNotes).toContain( + 'Deferred missing configured plugin "brave" install repair until post-update doctor.', + ); + }); + + it("preserves configured plugins when their install repair fails", async () => { + mocks.repairMissingConfiguredPluginInstalls.mockResolvedValueOnce({ + changes: [], + warnings: [ + 'Failed to install missing configured plugin "brave" from @openclaw/brave-plugin: package install failed', + ], + }); + mocks.maybeRepairStalePluginConfig.mockImplementationOnce((cfg: OpenClawConfig) => ({ + config: { + ...cfg, + plugins: { + ...cfg.plugins, + allow: [], + entries: {}, + }, + }, + changes: ["plugins.entries: removed 1 stale plugin entry (brave)"], + })); + + const result = await runDoctorRepairSequence({ + state: { + cfg: { + plugins: { + allow: ["brave"], + entries: { + brave: { + enabled: true, + config: { + webSearch: { + apiKey: { + source: "env", + provider: "default", + id: "BRAVE_API_KEY", + }, + }, + }, + }, + }, + }, + } as OpenClawConfig, + candidate: { + plugins: { + allow: ["brave"], + entries: { + brave: { + enabled: true, + config: { + webSearch: { + apiKey: { + source: "env", + provider: "default", + id: "BRAVE_API_KEY", + }, + }, + }, + }, + }, + }, + } as OpenClawConfig, + pendingChanges: false, + fixHints: [], + }, + doctorFixCommand: "openclaw doctor --fix", + }); + + expect(mocks.maybeRepairStalePluginConfig).not.toHaveBeenCalled(); + expect(result.state.candidate.plugins?.allow).toEqual(["brave"]); + expect(result.state.candidate.plugins?.entries?.brave?.enabled).toBe(true); + expect(result.state.pendingChanges).toBe(false); + expect(result.warningNotes).toContain( + 'Failed to install missing configured plugin "brave" from @openclaw/brave-plugin: package install failed', + ); + }); }); diff --git a/src/commands/doctor/repair-sequencing.ts b/src/commands/doctor/repair-sequencing.ts index d239a7965cf..a1748f091b9 100644 --- a/src/commands/doctor/repair-sequencing.ts +++ b/src/commands/doctor/repair-sequencing.ts @@ -1,3 +1,4 @@ +import { applyPluginAutoEnable } from "../../config/plugin-auto-enable.js"; import { sanitizeForLog } from "../../terminal/ansi.js"; import { maybeRepairAllowlistPolicyAllowFrom } from "./shared/allowlist-policy-repair.js"; import { maybeRepairBundledPluginLoadPaths } from "./shared/bundled-plugin-load-paths.js"; @@ -18,6 +19,12 @@ import { maybeRepairOpenPolicyAllowFrom } from "./shared/open-policy-allowfrom.j import { cleanupLegacyPluginDependencyState } from "./shared/plugin-dependency-cleanup.js"; import { maybeRepairStalePluginConfig } from "./shared/stale-plugin-config.js"; +const UPDATE_IN_PROGRESS_ENV = "OPENCLAW_UPDATE_IN_PROGRESS"; + +function isUpdatePackageDoctorPass(env: NodeJS.ProcessEnv): boolean { + return env[UPDATE_IN_PROGRESS_ENV] === "1"; +} + export async function runDoctorRepairSequence(params: { state: DoctorConfigMutationState; doctorFixCommand: string; @@ -66,11 +73,16 @@ export async function runDoctorRepairSequence(params: { }); if (missingConfiguredPluginInstallRepair.changes.length > 0) { changeNotes.push(sanitizeLines(missingConfiguredPluginInstallRepair.changes)); + applyMutation(applyPluginAutoEnable({ config: state.candidate, env })); } if (missingConfiguredPluginInstallRepair.warnings.length > 0) { warningNotes.push(sanitizeLines(missingConfiguredPluginInstallRepair.warnings)); } - applyMutation(maybeRepairStalePluginConfig(state.candidate, env)); + const missingConfiguredPluginInstallFailed = + missingConfiguredPluginInstallRepair.warnings.length > 0; + if (!isUpdatePackageDoctorPass(env) && !missingConfiguredPluginInstallFailed) { + applyMutation(maybeRepairStalePluginConfig(state.candidate, env)); + } applyMutation(maybeRepairInvalidPluginConfig(state.candidate)); applyMutation(await maybeRepairAllowlistPolicyAllowFrom(state.candidate)); diff --git a/src/config/config.plugin-validation.test.ts b/src/config/config.plugin-validation.test.ts index e8b8329ebc0..0201f010ca1 100644 --- a/src/config/config.plugin-validation.test.ts +++ b/src/config/config.plugin-validation.test.ts @@ -467,6 +467,36 @@ describe("config plugin validation", () => { expect(res.warnings).not.toContainEqual(expect.objectContaining({ path: "channels.telegarm" })); }); + it("warns when plugins.allow contains a channel id without a plugin manifest (#76872)", async () => { + const res = validateConfigObjectWithPlugins( + { + agents: { list: [{ id: "pi" }] }, + channels: { + discord: { token: "xxx" }, + }, + plugins: { + allow: ["discord"], + }, + }, + { + env: suiteEnv(), + pluginMetadataSnapshot: { + manifestRegistry: { + plugins: [], + diagnostics: [], + }, + }, + }, + ); + + expect(res.ok).toBe(true); + expect(res.warnings ?? []).toContainEqual({ + path: "plugins.allow", + message: + "plugin not found: discord (stale config entry ignored; remove it from plugins config)", + }); + }); + it("uses persisted installed-plugin records as stale channel evidence", async () => { const installedPluginIndexPath = path.join(suiteHome, ".openclaw", "plugins", "installs.json"); await mkdirSafe(path.dirname(installedPluginIndexPath)); diff --git a/src/config/plugin-auto-enable.channels.test.ts b/src/config/plugin-auto-enable.channels.test.ts index 18abbc5db78..52e38e10645 100644 --- a/src/config/plugin-auto-enable.channels.test.ts +++ b/src/config/plugin-auto-enable.channels.test.ts @@ -319,7 +319,7 @@ describe("applyPluginAutoEnable channels", () => { expect(result.config.plugins?.entries?.qqbot?.enabled).toBe(true); }); - it("falls back to channel key as plugin id when no installed manifest declares the channel", () => { + it("does not synthesize plugin entries when no installed manifest declares the channel", () => { const result = applyPluginAutoEnable({ config: { channels: { "unknown-chan": { someKey: "value" } }, @@ -328,7 +328,9 @@ describe("applyPluginAutoEnable channels", () => { manifestRegistry: makeRegistry([]), }); - expect(result.config.plugins?.entries?.["unknown-chan"]?.enabled).toBe(true); + expect(result.config.plugins?.entries?.["unknown-chan"]).toBeUndefined(); + expect(result.config.plugins?.allow).toBeUndefined(); + expect(result.changes).toEqual([]); }); }); diff --git a/src/config/plugin-auto-enable.providers.test.ts b/src/config/plugin-auto-enable.providers.test.ts index 2ba9749931c..dbe59a2351a 100644 --- a/src/config/plugin-auto-enable.providers.test.ts +++ b/src/config/plugin-auto-enable.providers.test.ts @@ -73,6 +73,83 @@ describe("applyPluginAutoEnable providers", () => { expect(result.changes).toContain("xai web search configured, enabled automatically."); }); + it("auto-enables selected web search provider plugins under restrictive allowlists", () => { + const result = applyPluginAutoEnable({ + config: { + tools: { + web: { + search: { + provider: "brave", + }, + }, + }, + plugins: { + allow: ["telegram"], + }, + }, + env, + manifestRegistry: makeRegistry([ + { + id: "brave", + channels: [], + contracts: { + webSearchProviders: ["brave"], + }, + }, + ]), + }); + + expect(result.config.plugins?.entries?.brave?.enabled).toBe(true); + expect(result.config.plugins?.allow).toEqual(["telegram", "brave"]); + expect(result.changes).toContain("brave web search provider selected, enabled automatically."); + }); + + it("does not auto-enable selected web search provider plugins when web search is disabled", () => { + const result = applyPluginAutoEnable({ + config: { + tools: { + web: { + search: { + enabled: false, + provider: "brave", + }, + }, + }, + plugins: { + allow: ["telegram"], + }, + agents: { + defaults: { + model: "codex/gpt-5.4", + }, + }, + }, + env, + manifestRegistry: makeRegistry([ + { + id: "brave", + channels: [], + contracts: { + webSearchProviders: ["brave"], + }, + }, + { + id: "codex", + channels: [], + providers: ["codex"], + }, + ]), + }); + + expect(result.config.plugins?.entries?.codex?.enabled).toBe(true); + expect(result.config.plugins?.entries?.brave).toBeUndefined(); + expect(result.config.plugins?.allow).toEqual(["telegram", "codex"]); + expect(result.changes).toContain("codex/gpt-5.4 model configured, enabled automatically."); + expect(result.changes).not.toContain( + "brave web search provider selected, enabled automatically.", + ); + }); + it("materializes xai setup auto-enable when the plugin-owned x_search tool is configured", () => { const result = materializePluginAutoEnableCandidates({ config: { diff --git a/src/config/plugin-auto-enable.shared.ts b/src/config/plugin-auto-enable.shared.ts index 7c786a9fdc8..bb6c125e9bf 100644 --- a/src/config/plugin-auto-enable.shared.ts +++ b/src/config/plugin-auto-enable.shared.ts @@ -235,6 +235,21 @@ function resolvePluginIdForConfiguredWebFetchProvider( )?.id; } +function resolvePluginIdForConfiguredWebSearchProvider( + providerId: string | undefined, + registry: PluginManifestRegistry, +): string | undefined { + const normalizedProviderId = normalizeOptionalLowercaseString(providerId); + if (!normalizedProviderId) { + return undefined; + } + return registry.plugins.find((plugin) => + (plugin.contracts?.webSearchProviders ?? []).some( + (candidate) => normalizeOptionalLowercaseString(candidate) === normalizedProviderId, + ), + )?.id; +} + function normalizeManifestChannelId(channelId: string): string { return normalizeChatChannelId(channelId) ?? channelId; } @@ -265,7 +280,7 @@ function collectPluginIdsForConfiguredChannel( } if (claims.length === 0) { - return [builtInId ?? normalizedChannelId]; + return builtInId ? [builtInId] : []; } const claimIds = new Set(claims.map((claim) => claim.plugin.id)); @@ -310,6 +325,13 @@ function hasConfiguredWebSearchPluginEntry(cfg: OpenClawConfig): boolean { ); } +function hasConfiguredWebSearchProviderSelection(cfg: OpenClawConfig): boolean { + const provider = cfg.tools?.web?.search?.provider; + return ( + cfg.tools?.web?.search?.enabled !== false && typeof provider === "string" && !!provider.trim() + ); +} + function hasConfiguredWebFetchPluginEntry(cfg: OpenClawConfig): boolean { const entries = cfg.plugins?.entries; return ( @@ -494,6 +516,9 @@ function configMayNeedPluginManifestRegistry(cfg: OpenClawConfig, env: NodeJS.Pr if (hasConfiguredProviderModelOrHarness(cfg, env)) { return true; } + if (hasConfiguredWebSearchProviderSelection(cfg)) { + return true; + } const configuredChannels = cfg.channels as Record | undefined; if (!configuredChannels || typeof configuredChannels !== "object") { return false; @@ -526,7 +551,11 @@ export function configMayNeedPluginAutoEnable( if (hasConfiguredProviderModelOrHarness(cfg, env)) { return true; } - if (hasConfiguredWebSearchPluginEntry(cfg) || hasConfiguredWebFetchPluginEntry(cfg)) { + if ( + hasConfiguredWebSearchProviderSelection(cfg) || + hasConfiguredWebSearchPluginEntry(cfg) || + hasConfiguredWebFetchPluginEntry(cfg) + ) { return true; } if (!hasSetupAutoEnableRelevantConfig(cfg)) { @@ -553,6 +582,8 @@ export function resolvePluginAutoEnableCandidateReason( return `${candidate.modelRef} model configured`; case "agent-harness-runtime-configured": return `${candidate.runtime} agent runtime configured`; + case "web-search-provider-selected": + return `${candidate.providerId} web search provider selected`; case "web-fetch-provider-selected": return `${candidate.providerId} web fetch provider selected`; case "plugin-web-search-configured": @@ -614,6 +645,23 @@ export function resolveConfiguredPluginAutoEnableCandidates(params: { } } + const webSearchConfig = params.config.tools?.web?.search; + const webSearchProvider = + webSearchConfig?.enabled !== false && typeof webSearchConfig?.provider === "string" + ? webSearchConfig.provider + : undefined; + const webSearchPluginId = resolvePluginIdForConfiguredWebSearchProvider( + webSearchProvider, + params.registry, + ); + if (webSearchPluginId) { + changes.push({ + pluginId: webSearchPluginId, + kind: "web-search-provider-selected", + providerId: normalizeOptionalLowercaseString(webSearchProvider) ?? "", + }); + } + const webFetchProvider = typeof params.config.tools?.web?.fetch?.provider === "string" ? params.config.tools.web.fetch.provider diff --git a/src/config/plugin-auto-enable.types.ts b/src/config/plugin-auto-enable.types.ts index c501a9ccd1f..3a1146d1020 100644 --- a/src/config/plugin-auto-enable.types.ts +++ b/src/config/plugin-auto-enable.types.ts @@ -21,6 +21,11 @@ export type PluginAutoEnableCandidate = kind: "agent-harness-runtime-configured"; runtime: string; } + | { + pluginId: string; + kind: "web-search-provider-selected"; + providerId: string; + } | { pluginId: string; kind: "web-fetch-provider-selected"; diff --git a/ui/src/i18n/locales/ko.ts b/ui/src/i18n/locales/ko.ts index d49c564ff21..00bf03341fc 100644 --- a/ui/src/i18n/locales/ko.ts +++ b/ui/src/i18n/locales/ko.ts @@ -980,7 +980,7 @@ export const ko: TranslationMap = { }, isolated: { label: "독립 세션", - description: "자체 세션���서 실행", + description: "자체 세션에서 실행", }, }, steps: {