diff --git a/CHANGELOG.md b/CHANGELOG.md index 13845eb2413..1d60fe3b11b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Doctor/plugins: run a one-time 2026.5.2 configured-plugin install repair based on `meta.lastTouchedVersion`, installing actively used downloadable OpenClaw plugins from ClawHub with npm fallback before marking the config touched for the release. - Sessions/transcripts: use one `session.writeLock.acquireTimeoutMs` policy for session transcript lock acquisitions and raise the default wait to 60 seconds, avoiding user-visible lock timeouts during legitimate slow prep, cleanup, compaction, and mirror work. Fixes #75894. Thanks @shandutta. - Control UI: contain the standalone iOS PWA viewport with safe-area-aware document locking, so Add-to-Home-Screen launches cannot scroll past the device bounds. Refs #76072. Thanks @kvncrw. - Agents/restart recovery: match cleaned transcript locks by exact transcript lock paths plus the canonical session fallback, so interrupted main sessions using topic-suffixed transcripts resume after gateway restart. Refs #76052. Thanks @anyech. diff --git a/docs/ci.md b/docs/ci.md index 761c4accf3f..99cf7a639eb 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -258,7 +258,7 @@ For the dedicated update and plugin testing policy, including local commands, Docker lanes, Package Acceptance inputs, release defaults, and failure triage, see [Testing updates and plugins](/help/testing-updates-plugins). -Release checks call Package Acceptance with `source=artifact`, the prepared release package artifact, `suite_profile=custom`, `docker_lanes='doctor-switch update-channel-switch upgrade-survivor published-upgrade-survivor plugins-offline plugin-update'`, `published_upgrade_survivor_baselines=release-history`, `published_upgrade_survivor_scenarios=reported-issues`, and `telegram_mode=mock-openai`. This keeps package migration, update, stale-plugin-dependency cleanup, offline plugin, plugin-update, and Telegram proof on the same resolved package tarball. Cross-OS release checks still cover OS-specific onboarding, installer, and platform behavior; package/update product validation should start with Package Acceptance. The `published-upgrade-survivor` Docker lane validates one published package baseline per run. In Package Acceptance, the resolved `package-under-test` tarball is always the candidate and `published_upgrade_survivor_baseline` selects the fallback published baseline, defaulting to `openclaw@latest`; failed-lane rerun commands preserve that baseline. Set `published_upgrade_survivor_baselines=release-history` to expand the lane across a deduped history matrix: the latest six stable releases, `2026.4.23`, and the latest stable release before `2026-03-15`. Set `published_upgrade_survivor_scenarios=reported-issues` to expand the same baselines across issue-shaped fixtures for Feishu config, preserved bootstrap/persona files, tilde log paths, and stale legacy plugin dependency roots. The separate `Update Migration` workflow uses the `update-migration` Docker lane with `all-since-2026.4.23` and `plugin-deps-cleanup` when the question is exhaustive published update cleanup, not normal Full Release CI breadth. Local aggregate runs can pass exact package specs with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS`, keep a single lane with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC` such as `openclaw@2026.4.15`, or set `OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS` for the scenario matrix. The published lane configures the baseline with a baked `openclaw config set` command recipe, records recipe steps in `summary.json`, and probes `/healthz`, `/readyz`, plus RPC status after Gateway start. The Windows packaged and installer fresh lanes also verify that an installed package can import a browser-control override from a raw absolute Windows path. The OpenAI cross-OS agent-turn smoke defaults to `OPENCLAW_CROSS_OS_OPENAI_MODEL` when set, otherwise `openai/gpt-5.4`, so the install and gateway proof stays on a GPT-5 test model while avoiding GPT-4.x defaults. +Release checks call Package Acceptance with `source=artifact`, the prepared release package artifact, `suite_profile=custom`, `docker_lanes='doctor-switch update-channel-switch upgrade-survivor published-upgrade-survivor plugins-offline plugin-update'`, `published_upgrade_survivor_baselines=release-history`, `published_upgrade_survivor_scenarios=reported-issues`, and `telegram_mode=mock-openai`. This keeps package migration, update, stale-plugin-dependency cleanup, configured-plugin install repair, offline plugin, plugin-update, and Telegram proof on the same resolved package tarball. Cross-OS release checks still cover OS-specific onboarding, installer, and platform behavior; package/update product validation should start with Package Acceptance. The `published-upgrade-survivor` Docker lane validates one published package baseline per run. In Package Acceptance, the resolved `package-under-test` tarball is always the candidate and `published_upgrade_survivor_baseline` selects the fallback published baseline, defaulting to `openclaw@latest`; failed-lane rerun commands preserve that baseline. Set `published_upgrade_survivor_baselines=release-history` to expand the lane across a deduped history matrix: the latest six stable releases, `2026.4.23`, and the latest stable release before `2026-03-15`. Set `published_upgrade_survivor_scenarios=reported-issues` to expand the same baselines across issue-shaped fixtures for Feishu config, preserved bootstrap/persona files, configured OpenClaw plugin installs, tilde log paths, and stale legacy plugin dependency roots. The separate `Update Migration` workflow uses the `update-migration` Docker lane with `all-since-2026.4.23` and `plugin-deps-cleanup` when the question is exhaustive published update cleanup, not normal Full Release CI breadth. Local aggregate runs can pass exact package specs with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS`, keep a single lane with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC` such as `openclaw@2026.4.15`, or set `OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS` for the scenario matrix. The published lane configures the baseline with a baked `openclaw config set` command recipe, records recipe steps in `summary.json`, and probes `/healthz`, `/readyz`, plus RPC status after Gateway start. The Windows packaged and installer fresh lanes also verify that an installed package can import a browser-control override from a raw absolute Windows path. The OpenAI cross-OS agent-turn smoke defaults to `OPENCLAW_CROSS_OS_OPENAI_MODEL` when set, otherwise `openai/gpt-5.4`, so the install and gateway proof stays on a GPT-5 test model while avoiding GPT-4.x defaults. ### Legacy compatibility windows diff --git a/docs/cli/doctor.md b/docs/cli/doctor.md index 2c46febd76b..8068961b848 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. +- 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 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/docs/gateway/doctor.md b/docs/gateway/doctor.md index b884aae19fa..d97177f32d3 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -344,7 +344,7 @@ That stages grounded durable candidates into the short-term dreaming store while Doctor removes legacy OpenClaw-generated plugin dependency staging state in `openclaw doctor --fix` / `openclaw doctor --repair` mode. This covers stale generated dependency roots, old install-stage directories, and package-local debris from earlier bundled-plugin dependency repair code. - Doctor can also reinstall configured downloadable plugins when the config references them but the local plugin registry cannot find them. Gateway startup and config reload do not run package managers; plugin installs remain explicit doctor/install/update work. + Doctor can also reinstall configured downloadable plugins when the config references them but the local plugin registry cannot find them. For the 2026.5.2 bundled-plugin externalization, doctor automatically installs downloadable plugins that the existing config already uses and then relies on `meta.lastTouchedVersion` to run that release pass only once. Gateway startup and config reload do not run package managers; plugin installs remain explicit doctor/install/update work. diff --git a/docs/help/testing-updates-plugins.md b/docs/help/testing-updates-plugins.md index f9259c2dc04..e6f0f2b37b7 100644 --- a/docs/help/testing-updates-plugins.md +++ b/docs/help/testing-updates-plugins.md @@ -118,9 +118,10 @@ pnpm test:docker:published-upgrade-survivor ``` Available scenarios are `base`, `feishu-channel`, `bootstrap-persona`, -`plugin-deps-cleanup`, `tilde-log-path`, and `versioned-runtime-deps`. In aggregate runs, +`plugin-deps-cleanup`, `configured-plugin-installs`, `tilde-log-path`, and +`versioned-runtime-deps`. In aggregate runs, `OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS=reported-issues` expands to all reported -issue-shaped scenarios. +issue-shaped scenarios, including the configured-plugin install migration. Full update migration is intentionally separate from Full Release CI. Use the manual `Update Migration` workflow when the release question is "can every diff --git a/docs/help/testing.md b/docs/help/testing.md index 2fe19bf8506..45efdc22e50 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -625,7 +625,7 @@ The live-model Docker runners also bind-mount only the needed CLI auth homes (or - Npm tarball onboarding/channel/agent smoke: `pnpm test:docker:npm-onboard-channel-agent` installs the packed OpenClaw tarball globally in Docker, configures OpenAI via env-ref onboarding plus Telegram by default, runs doctor, and runs one mocked OpenAI agent turn. Reuse a prebuilt tarball with `OPENCLAW_CURRENT_PACKAGE_TGZ=/path/to/openclaw-*.tgz`, skip the host rebuild with `OPENCLAW_NPM_ONBOARD_HOST_BUILD=0`, or switch channel with `OPENCLAW_NPM_ONBOARD_CHANNEL=discord`. - Update channel switch smoke: `pnpm test:docker:update-channel-switch` installs the packed OpenClaw tarball globally in Docker, switches from package `stable` to git `dev`, verifies the persisted channel and plugin post-update work, then switches back to package `stable` and checks update status. - Upgrade survivor smoke: `pnpm test:docker:upgrade-survivor` installs the packed OpenClaw tarball over a dirty old-user fixture with agents, channel config, plugin allowlists, stale plugin dependency state, and existing workspace/session files. It runs package update plus non-interactive doctor without live provider or channel keys, then starts a loopback Gateway and checks config/state preservation plus startup/status budgets. -- Published upgrade survivor smoke: `pnpm test:docker:published-upgrade-survivor` installs `openclaw@latest` by default, seeds realistic existing-user files, configures that baseline with a baked command recipe, validates the resulting config, updates that published install to the candidate tarball, runs non-interactive doctor, writes `.artifacts/upgrade-survivor/summary.json`, then starts a loopback Gateway and checks configured intents, state preservation, startup, `/healthz`, `/readyz`, and RPC status budgets. Override one baseline with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC`, ask the aggregate scheduler to expand exact baselines with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS`, and expand issue-shaped fixtures with `OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS` such as `reported-issues`; Package Acceptance exposes those as `published_upgrade_survivor_baseline`, `published_upgrade_survivor_baselines`, and `published_upgrade_survivor_scenarios`. +- Published upgrade survivor smoke: `pnpm test:docker:published-upgrade-survivor` installs `openclaw@latest` by default, seeds realistic existing-user files, configures that baseline with a baked command recipe, validates the resulting config, updates that published install to the candidate tarball, runs non-interactive doctor, writes `.artifacts/upgrade-survivor/summary.json`, then starts a loopback Gateway and checks configured intents, state preservation, startup, `/healthz`, `/readyz`, and RPC status budgets. Override one baseline with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC`, ask the aggregate scheduler to expand exact baselines with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS`, and expand issue-shaped fixtures with `OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS` such as `reported-issues`; the reported-issues set includes `configured-plugin-installs` for automatic external OpenClaw plugin install repair. Package Acceptance exposes those as `published_upgrade_survivor_baseline`, `published_upgrade_survivor_baselines`, and `published_upgrade_survivor_scenarios`. - Session runtime context smoke: `pnpm test:docker:session-runtime-context` verifies hidden runtime context transcript persistence plus doctor repair of affected duplicated prompt-rewrite branches. - Bun global install smoke: `bash scripts/e2e/bun-global-install-smoke.sh` packs the current tree, installs it with `bun install -g` in an isolated home, and verifies `openclaw infer image providers --json` returns bundled image providers instead of hanging. Reuse a prebuilt tarball with `OPENCLAW_BUN_GLOBAL_SMOKE_PACKAGE_TGZ=/path/to/openclaw-*.tgz`, skip the host build with `OPENCLAW_BUN_GLOBAL_SMOKE_HOST_BUILD=0`, or copy `dist/` from a built Docker image with `OPENCLAW_BUN_GLOBAL_SMOKE_DIST_IMAGE=openclaw-dockerfile-smoke:local`. - Installer Docker smoke: `bash scripts/test-install-sh-docker.sh` shares one npm cache across its root, update, and direct-npm containers. Update smoke defaults to npm `latest` as the stable baseline before upgrading to the candidate tarball. Override with `OPENCLAW_INSTALL_SMOKE_UPDATE_BASELINE=2026.4.22` locally, or with the Install Smoke workflow's `update_baseline_version` input on GitHub. Non-root installer checks keep an isolated npm cache so root-owned cache entries do not mask user-local install behavior. Set `OPENCLAW_INSTALL_SMOKE_NPM_CACHE_DIR=/path/to/cache` to reuse the root/update/direct-npm cache across local reruns. diff --git a/docs/reference/test.md b/docs/reference/test.md index 00fce1a4af9..f0447216e4d 100644 --- a/docs/reference/test.md +++ b/docs/reference/test.md @@ -44,7 +44,7 @@ title: "Tests" - `pnpm test:docker:openwebui`: Starts Dockerized OpenClaw + Open WebUI, signs in through Open WebUI, checks `/api/models`, then runs a real proxied chat through `/api/chat/completions`. Requires a usable live model key (for example OpenAI in `~/.profile`), pulls an external Open WebUI image, and is not expected to be CI-stable like the normal unit/e2e suites. - `pnpm test:docker:mcp-channels`: Starts a seeded Gateway container and a second client container that spawns `openclaw mcp serve`, then verifies routed conversation discovery, transcript reads, attachment metadata, live event queue behavior, outbound send routing, and Claude-style channel + permission notifications over the real stdio bridge. The Claude notification assertion reads the raw stdio MCP frames directly so the smoke reflects what the bridge actually emits. - `pnpm test:docker:upgrade-survivor`: Installs the packed OpenClaw tarball over a dirty old-user fixture, runs package update plus non-interactive doctor without live provider or channel keys, then starts a loopback Gateway and checks that agents, channel config, plugin allowlists, workspace/session files, stale legacy plugin dependency state, startup, and RPC status survive. -- `pnpm test:docker:published-upgrade-survivor`: Installs `openclaw@latest` by default, seeds realistic existing-user files without live provider or channel keys, configures that baseline with a baked `openclaw config set` command recipe, updates that published install to the packed OpenClaw tarball, runs non-interactive doctor, writes `.artifacts/upgrade-survivor/summary.json`, then starts a loopback Gateway and checks that configured intents, workspace/session files, stale plugin config and legacy dependency state, startup, `/healthz`, `/readyz`, and RPC status survive or repair cleanly. Override one baseline with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC`, expand an exact matrix with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS`, or add scenario fixtures with `OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS=reported-issues`; Package Acceptance exposes those as `published_upgrade_survivor_baseline`, `published_upgrade_survivor_baselines`, and `published_upgrade_survivor_scenarios`. +- `pnpm test:docker:published-upgrade-survivor`: Installs `openclaw@latest` by default, seeds realistic existing-user files without live provider or channel keys, configures that baseline with a baked `openclaw config set` command recipe, updates that published install to the packed OpenClaw tarball, runs non-interactive doctor, writes `.artifacts/upgrade-survivor/summary.json`, then starts a loopback Gateway and checks that configured intents, workspace/session files, stale plugin config and legacy dependency state, startup, `/healthz`, `/readyz`, and RPC status survive or repair cleanly. Override one baseline with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC`, expand an exact matrix with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS`, or add scenario fixtures with `OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS=reported-issues`; the reported-issues set includes `configured-plugin-installs` to verify configured external OpenClaw plugins install automatically during upgrade. Package Acceptance exposes those as `published_upgrade_survivor_baseline`, `published_upgrade_survivor_baselines`, and `published_upgrade_survivor_scenarios`. - `pnpm test:docker:update-migration`: Runs the published-upgrade survivor harness in the cleanup-heavy `plugin-deps-cleanup` scenario, starting at `openclaw@2026.4.23` by default. The separate `Update Migration` workflow expands this lane with `baselines=all-since-2026.4.23` so every stable published package from `.23` onward updates to the candidate and proves configured-plugin dependency cleanup outside Full Release CI. - `pnpm test:docker:plugins`: Runs install/update smoke for local path, `file:`, npm registry packages with hoisted dependencies, git moving refs, ClawHub fixtures, marketplace updates, and Claude-bundle enable/inspect. diff --git a/scripts/e2e/lib/upgrade-survivor/assertions.mjs b/scripts/e2e/lib/upgrade-survivor/assertions.mjs index e5eaf18e39e..3e042142ba6 100644 --- a/scripts/e2e/lib/upgrade-survivor/assertions.mjs +++ b/scripts/e2e/lib/upgrade-survivor/assertions.mjs @@ -7,6 +7,7 @@ const SCENARIOS = new Set([ "feishu-channel", "bootstrap-persona", "plugin-deps-cleanup", + "configured-plugin-installs", "tilde-log-path", "versioned-runtime-deps", ]); @@ -210,12 +211,27 @@ function assertConfigSurvived() { const pluginAllow = config.plugins?.allow ?? []; assert(pluginAllow.includes("discord"), "discord plugin allow entry missing"); assert(pluginAllow.includes("telegram"), "telegram plugin allow entry missing"); - assert(pluginAllow.includes("whatsapp"), "whatsapp plugin allow entry missing"); + if (getScenario() === "configured-plugin-installs") { + assert(pluginAllow.includes("matrix"), "matrix plugin allow entry missing"); + } else { + assert(pluginAllow.includes("whatsapp"), "whatsapp plugin allow entry missing"); + } if (hasCoverage(coverage) && acceptsIntent(coverage, "feishu-channel")) { assert(pluginAllow.includes("feishu"), "feishu plugin allow entry missing"); } } + if (hasCoverage(coverage) && acceptsIntent(coverage, "configured-plugin-installs")) { + const pluginAllow = config.plugins?.allow ?? []; + assert(pluginAllow.includes("discord"), "configured install discord allow entry missing"); + assert(pluginAllow.includes("telegram"), "configured install telegram allow entry missing"); + assert(pluginAllow.includes("matrix"), "configured install matrix allow entry missing"); + assert( + config.plugins?.entries?.matrix?.enabled === true, + "configured install matrix entry changed", + ); + } + if (acceptsIntent(coverage, "discord-channel")) { const discord = config.channels?.discord; assert(discord?.enabled === true, "discord enabled flag changed"); @@ -243,7 +259,10 @@ function assertConfigSurvived() { ); } - if (acceptsIntent(coverage, "whatsapp-channel")) { + if ( + acceptsIntent(coverage, "whatsapp-channel") && + getScenario() !== "configured-plugin-installs" + ) { const whatsapp = config.channels?.whatsapp; assert(whatsapp?.enabled === true, "whatsapp enabled flag changed"); const whatsappGroup = whatsapp.groups?.["120363000000000000@g.us"]; @@ -257,6 +276,17 @@ function assertConfigSurvived() { } } + if (hasCoverage(coverage) && acceptsIntent(coverage, "configured-plugin-installs")) { + const matrix = config.channels?.matrix; + assert(matrix?.enabled === true, "matrix enabled flag changed"); + assert(matrix?.homeserver === "https://matrix.example.invalid", "matrix homeserver changed"); + assert(matrix?.userId === "@upgrade-survivor:matrix.example.invalid", "matrix userId changed"); + assert( + !config.channels?.whatsapp, + "whatsapp channel config should be absent in matrix scenario", + ); + } + if (hasCoverage(coverage) && acceptsIntent(coverage, "feishu-channel")) { const feishu = config.channels?.feishu; assert(feishu?.enabled === true, "feishu enabled flag changed"); @@ -321,6 +351,45 @@ function assertStateSurvived() { } } +function readInstalledPluginIndex() { + const stateDir = requireEnv("OPENCLAW_STATE_DIR"); + const file = path.join(stateDir, "plugins", "installs.json"); + assert(fs.existsSync(file), `installed plugin index missing: ${file}`); + return readJson(file); +} + +function assertConfiguredPluginInstalls() { + const coverage = getCoverage(); + const stage = process.env.OPENCLAW_UPGRADE_SURVIVOR_ASSERT_STAGE || "survival"; + if (!hasCoverage(coverage) || !acceptsIntent(coverage, "configured-plugin-installs")) { + return; + } + if (stage === "baseline") { + return; + } + const index = readInstalledPluginIndex(); + const records = index.installRecords ?? {}; + const matrix = records.matrix; + assert(matrix, "configured external matrix plugin install record missing"); + assert( + matrix.source === "clawhub" || matrix.source === "npm", + `configured external matrix plugin installed from unexpected source: ${matrix.source}`, + ); + if (matrix.source === "clawhub") { + assert( + String(matrix.spec ?? "").startsWith("clawhub:@openclaw/matrix"), + "configured external matrix plugin ClawHub spec changed", + ); + } else { + assert( + String(matrix.spec ?? matrix.resolvedSpec ?? "").startsWith("@openclaw/matrix"), + "configured external matrix plugin npm spec changed", + ); + } + assert(!records.discord, "internal discord plugin should not be installed externally"); + assert(!records.telegram, "internal telegram plugin should not be installed externally"); +} + function assertStatusJson([file]) { const status = readJson(file); assert(status && typeof status === "object", "gateway status JSON was not an object"); @@ -334,6 +403,7 @@ if (command === "seed") { assertConfigSurvived(); } else if (command === "assert-state") { assertStateSurvived(); + assertConfiguredPluginInstalls(); } else if (command === "assert-status-json") { assertStatusJson(process.argv.slice(3)); } else { diff --git a/scripts/e2e/lib/upgrade-survivor/config-recipe.mjs b/scripts/e2e/lib/upgrade-survivor/config-recipe.mjs index e105bb23575..952b65083c2 100644 --- a/scripts/e2e/lib/upgrade-survivor/config-recipe.mjs +++ b/scripts/e2e/lib/upgrade-survivor/config-recipe.mjs @@ -113,6 +113,28 @@ const scenarioConfigSteps = new Map([ }, ], ], + [ + "configured-plugin-installs", + [ + configSetJsonFile( + "plugins-configured-installs", + "configured-plugin-installs", + "plugins", + "plugins-configured-installs.json", + ), + { + id: "channels-whatsapp-unset", + intent: "configured-plugin-installs", + argv: ["config", "unset", "channels.whatsapp"], + }, + configSetJsonFile( + "channels-matrix", + "configured-plugin-installs", + "channels.matrix", + "channels-matrix.json", + ), + ], + ], ]); const recipe = [ diff --git a/scripts/e2e/lib/upgrade-survivor/config-recipe/channels-matrix.json b/scripts/e2e/lib/upgrade-survivor/config-recipe/channels-matrix.json new file mode 100644 index 00000000000..cbaa0d5f8e4 --- /dev/null +++ b/scripts/e2e/lib/upgrade-survivor/config-recipe/channels-matrix.json @@ -0,0 +1,24 @@ +{ + "enabled": true, + "homeserver": "https://matrix.example.invalid", + "userId": "@upgrade-survivor:matrix.example.invalid", + "accessToken": { + "source": "env", + "provider": "default", + "id": "MATRIX_ACCESS_TOKEN" + }, + "dm": { + "policy": "allowlist", + "allowFrom": ["@driver:matrix.example.invalid"] + }, + "groups": { + "!upgrade-survivor:matrix.example.invalid": { + "enabled": true, + "requireMention": true, + "tools": { + "allow": ["message_send"], + "deny": ["exec"] + } + } + } +} 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 new file mode 100644 index 00000000000..13868c3de31 --- /dev/null +++ b/scripts/e2e/lib/upgrade-survivor/config-recipe/plugins-configured-installs.json @@ -0,0 +1,15 @@ +{ + "enabled": true, + "allow": ["discord", "telegram", "matrix"], + "entries": { + "discord": { + "enabled": true + }, + "matrix": { + "enabled": true + }, + "telegram": { + "enabled": true + } + } +} diff --git a/scripts/e2e/lib/upgrade-survivor/run.sh b/scripts/e2e/lib/upgrade-survivor/run.sh index 2c9cc6aa026..8a7328641fe 100644 --- a/scripts/e2e/lib/upgrade-survivor/run.sh +++ b/scripts/e2e/lib/upgrade-survivor/run.sh @@ -17,6 +17,7 @@ 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 MATRIX_ACCESS_TOKEN="upgrade-survivor-matrix-token" ARTIFACT_ROOT="$(dirname "${OPENCLAW_UPGRADE_SURVIVOR_SUMMARY_JSON:-/tmp/openclaw-upgrade-survivor-artifacts/summary.json}")" mkdir -p "$ARTIFACT_ROOT" @@ -39,6 +40,8 @@ CURRENT_PHASE="setup" FAILURE_PHASE="" FAILURE_MESSAGE="" gateway_pid="" +clawhub_fixture_pid="" +configured_plugin_installs_clawhub_fixture_owned="" baseline_spec="" baseline_version="" baseline_version_expected="0" @@ -190,6 +193,10 @@ NODE } cleanup() { + if [ -n "${clawhub_fixture_pid:-}" ]; then + kill "$clawhub_fixture_pid" 2>/dev/null || true + wait "$clawhub_fixture_pid" 2>/dev/null || true + fi openclaw_e2e_terminate_gateways "${gateway_pid:-}" } @@ -276,6 +283,66 @@ plugin_deps_cleanup_plugin_dirs() { "$(package_root)/extensions/$plugin" } +configured_plugin_installs_enabled() { + [ "$SCENARIO" = "configured-plugin-installs" ] +} + +start_configured_plugin_installs_clawhub_fixture() { + configured_plugin_installs_enabled || return 0 + configured_plugin_installs_clawhub_fixture_owned="" + if [ -n "${OPENCLAW_CLAWHUB_URL:-}" ] || [ -n "${CLAWHUB_URL:-}" ]; then + return 0 + fi + + local port_file="$ARTIFACT_ROOT/clawhub-not-found.port" + local requests_file="$ARTIFACT_ROOT/clawhub-not-found-requests.jsonl" + rm -f "$port_file" "$requests_file" + node - "$port_file" "$requests_file" <<'NODE' & +const fs = require("node:fs"); +const http = require("node:http"); +const portFile = process.argv[2]; +const requestsFile = process.argv[3]; +const server = http.createServer((request, response) => { + fs.appendFileSync( + requestsFile, + `${JSON.stringify({ method: request.method, url: request.url, at: new Date().toISOString() })}\n`, + ); + response.writeHead(404, { "content-type": "application/json" }); + response.end('{"error":"fixture package not found"}\n'); +}); +server.listen(0, "127.0.0.1", () => { + fs.writeFileSync(portFile, String(server.address().port)); +}); +process.on("SIGTERM", () => server.close(() => process.exit(0))); +process.on("SIGINT", () => server.close(() => process.exit(0))); +NODE + clawhub_fixture_pid="$!" + for _ in $(seq 1 100); do + if [ -s "$port_file" ]; then + export OPENCLAW_CLAWHUB_URL="http://127.0.0.1:$(cat "$port_file")" + configured_plugin_installs_clawhub_fixture_owned="1" + echo "Configured plugin install scenario using ClawHub 404 fixture: $OPENCLAW_CLAWHUB_URL" + return 0 + fi + sleep 0.1 + done + echo "timed out starting ClawHub 404 fixture" >&2 + return 1 +} + +assert_configured_plugin_installs_clawhub_attempted() { + configured_plugin_installs_enabled || return 0 + if [ "${configured_plugin_installs_clawhub_fixture_owned:-}" != "1" ]; then + return 0 + fi + local requests_file="$ARTIFACT_ROOT/clawhub-not-found-requests.jsonl" + if ! grep -q '/api/v1/packages/%40openclaw%2Fmatrix' "$requests_file" 2>/dev/null; then + echo "configured plugin install scenario did not attempt ClawHub for @openclaw/matrix" >&2 + cat "$requests_file" >&2 2>/dev/null || true + return 1 + fi +} + legacy_plugin_dependency_probe_paths() { local plugin="$1" local plugin_dir @@ -652,7 +719,7 @@ start_gateway() { check_gateway_probes() { healthz_seconds="$(probe_gateway_endpoint /healthz live "$HEALTHZ_JSON")" - export OPENCLAW_UPGRADE_SURVIVOR_READYZ_ALLOW_FAILING="discord,telegram,whatsapp,feishu" + export OPENCLAW_UPGRADE_SURVIVOR_READYZ_ALLOW_FAILING="discord,telegram,whatsapp,feishu,matrix" readyz_seconds="$(probe_gateway_endpoint /readyz ready "$READYZ_JSON")" unset OPENCLAW_UPGRADE_SURVIVOR_READYZ_ALLOW_FAILING } @@ -691,9 +758,11 @@ phase assert-legacy-plugin-dependency-debris assert_legacy_plugin_dependency_deb phase assert-baseline assert_baseline_state phase seed-legacy-runtime-deps-symlink seed_legacy_runtime_deps_symlink phase resolve-candidate resolve_candidate_version +phase configured-plugin-installs-clawhub-fixture start_configured_plugin_installs_clawhub_fixture phase update-candidate update_candidate phase assert-legacy-plugin-dependency-debris-before-doctor assert_legacy_plugin_dependency_debris_before_doctor phase doctor run_doctor +phase configured-plugin-installs-clawhub-attempted assert_configured_plugin_installs_clawhub_attempted 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 phase validate-post-doctor-config validate_post_doctor_config diff --git a/scripts/lib/docker-e2e-plan.mjs b/scripts/lib/docker-e2e-plan.mjs index 9bcc238cc2b..4f0008e67b7 100644 --- a/scripts/lib/docker-e2e-plan.mjs +++ b/scripts/lib/docker-e2e-plan.mjs @@ -74,6 +74,7 @@ const UPGRADE_SURVIVOR_SCENARIOS = [ "feishu-channel", "bootstrap-persona", "plugin-deps-cleanup", + "configured-plugin-installs", "tilde-log-path", "versioned-runtime-deps", ]; diff --git a/src/cli/channel-options.test.ts b/src/cli/channel-options.test.ts index bb7ca02835b..51e1d639b6a 100644 --- a/src/cli/channel-options.test.ts +++ b/src/cli/channel-options.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { __testing, resolveCliChannelOptions } from "./channel-options.js"; import { __testing as startupMetadataTesting } from "./startup-metadata.js"; @@ -22,12 +22,17 @@ vi.mock("../channels/ids.js", () => ({ })); describe("resolveCliChannelOptions", () => { - afterEach(() => { + beforeEach(() => { __testing.resetPrecomputedChannelOptionsForTests(); startupMetadataTesting.clearStartupMetadataCache(); vi.clearAllMocks(); }); + afterEach(() => { + __testing.resetPrecomputedChannelOptionsForTests(); + delete process.env.OPENCLAW_PLUGIN_CATALOG_PATHS; + }); + it("uses precomputed startup metadata when available", async () => { readFileSyncMock.mockReturnValue( JSON.stringify({ channelOptions: ["cached", "quietchat", "cached"] }), @@ -49,6 +54,5 @@ describe("resolveCliChannelOptions", () => { readFileSyncMock.mockReturnValue(JSON.stringify({ channelOptions: ["cached", "quietchat"] })); expect(resolveCliChannelOptions()).toEqual(["cached", "quietchat"]); - delete process.env.OPENCLAW_PLUGIN_CATALOG_PATHS; }); }); diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index 31e6c882055..9ffe53d2304 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -77,6 +77,9 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { let pendingChanges = false; let fixHints: string[] = []; const doctorFixCommand = formatCliCommand("openclaw doctor --fix"); + const sourceMeta = (snapshot.sourceConfig as { meta?: { lastTouchedVersion?: unknown } })?.meta; + const sourceLastTouchedVersion = + typeof sourceMeta?.lastTouchedVersion === "string" ? sourceMeta.lastTouchedVersion : undefined; const legacyStep = applyLegacyCompatibilityStep({ snapshot, @@ -283,5 +286,6 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { path: snapshot.path ?? CONFIG_PATH, shouldWriteConfig: finalized.shouldWriteConfig, sourceConfigValid: snapshot.valid, + ...(sourceLastTouchedVersion ? { sourceLastTouchedVersion } : {}), }; } diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts index 2ba4729e669..a17f738384c 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts @@ -95,7 +95,7 @@ describe("repairMissingConfiguredPluginInstalls", () => { }); }); - it("installs a missing configured downloadable channel plugin", async () => { + it("installs a missing configured OpenClaw channel plugin from ClawHub", async () => { mocks.listChannelPluginCatalogEntries.mockReturnValue([ { id: "matrix", @@ -119,54 +119,6 @@ describe("repairMissingConfiguredPluginInstalls", () => { env: {}, }); - expect(mocks.installPluginFromNpmSpec).toHaveBeenCalledWith( - expect.objectContaining({ - spec: "@openclaw/plugin-matrix@1.2.3", - extensionsDir: "/tmp/openclaw-plugins", - expectedPluginId: "matrix", - expectedIntegrity: "sha512-test", - }), - ); - expect(mocks.writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith( - expect.objectContaining({ - matrix: expect.objectContaining({ - source: "npm", - spec: "@openclaw/plugin-matrix@1.2.3", - installPath: "/tmp/openclaw-plugins/matrix", - }), - }), - { env: {} }, - ); - expect(result.changes).toEqual([ - 'Installed missing configured plugin "matrix" from @openclaw/plugin-matrix@1.2.3.', - ]); - }); - - it("installs a missing configured channel plugin from ClawHub before npm", async () => { - mocks.listChannelPluginCatalogEntries.mockReturnValue([ - { - id: "matrix", - pluginId: "matrix", - meta: { label: "Matrix" }, - install: { - clawhubSpec: "clawhub:@openclaw/plugin-matrix@1.2.3", - npmSpec: "@openclaw/plugin-matrix@1.2.3", - expectedIntegrity: "sha512-test", - }, - }, - ]); - - const { repairMissingConfiguredPluginInstalls } = - await import("./missing-configured-plugin-install.js"); - const result = await repairMissingConfiguredPluginInstalls({ - cfg: { - channels: { - matrix: { enabled: true }, - }, - }, - env: {}, - }); - expect(mocks.installPluginFromClawHub).toHaveBeenCalledWith( expect.objectContaining({ spec: "clawhub:@openclaw/plugin-matrix@1.2.3", @@ -180,8 +132,8 @@ describe("repairMissingConfiguredPluginInstalls", () => { matrix: expect.objectContaining({ source: "clawhub", spec: "clawhub:@openclaw/plugin-matrix@1.2.3", + clawhubPackage: "@openclaw/plugin-matrix", installPath: "/tmp/openclaw-plugins/matrix", - clawpackSha256: "0".repeat(64), }), }), { env: {} }, @@ -192,19 +144,16 @@ describe("repairMissingConfiguredPluginInstalls", () => { expect(result.warnings).toEqual([]); }); - it("falls back to npm when a missing configured ClawHub package is absent", async () => { - mocks.installPluginFromClawHub.mockResolvedValue({ - ok: false, - code: "package_not_found", - error: "Package not found on ClawHub.", - }); - mocks.resolveProviderInstallCatalogEntries.mockReturnValue([ + it("uses an explicit ClawHub install spec before npm", async () => { + mocks.listChannelPluginCatalogEntries.mockReturnValue([ { + id: "matrix", pluginId: "matrix", - label: "Matrix", + meta: { label: "Matrix" }, install: { - clawhubSpec: "clawhub:@openclaw/plugin-matrix@1.2.3", + clawhubSpec: "clawhub:@openclaw/plugin-matrix@stable", npmSpec: "@openclaw/plugin-matrix@1.2.3", + expectedIntegrity: "sha512-test", }, }, ]); @@ -213,27 +162,136 @@ describe("repairMissingConfiguredPluginInstalls", () => { await import("./missing-configured-plugin-install.js"); const result = await repairMissingConfiguredPluginInstalls({ cfg: { - plugins: { - entries: { - matrix: { enabled: true }, - }, + channels: { + matrix: { enabled: true }, }, }, env: {}, }); expect(mocks.installPluginFromClawHub).toHaveBeenCalledWith( - expect.objectContaining({ spec: "clawhub:@openclaw/plugin-matrix@1.2.3" }), + expect.objectContaining({ + spec: "clawhub:@openclaw/plugin-matrix@stable", + expectedPluginId: "matrix", + }), ); + expect(mocks.installPluginFromNpmSpec).not.toHaveBeenCalled(); + expect(result.changes).toEqual([ + 'Installed missing configured plugin "matrix" from clawhub:@openclaw/plugin-matrix@stable.', + ]); + expect(result.warnings).toEqual([]); + }); + + it("falls back to npm when an OpenClaw channel plugin is not on ClawHub", async () => { + mocks.installPluginFromClawHub.mockResolvedValueOnce({ + ok: false, + code: "package_not_found", + error: "Package not found on ClawHub.", + }); + mocks.listChannelPluginCatalogEntries.mockReturnValue([ + { + id: "matrix", + pluginId: "matrix", + meta: { label: "Matrix" }, + install: { + npmSpec: "@openclaw/plugin-matrix@1.2.3", + }, + }, + ]); + + const { repairMissingPluginInstallsForIds } = + await import("./missing-configured-plugin-install.js"); + const result = await repairMissingPluginInstallsForIds({ + cfg: {}, + pluginIds: [], + channelIds: ["matrix"], + env: {}, + }); + expect(mocks.installPluginFromNpmSpec).toHaveBeenCalledWith( - expect.objectContaining({ spec: "@openclaw/plugin-matrix@1.2.3" }), + expect.objectContaining({ + spec: "@openclaw/plugin-matrix@1.2.3", + expectedPluginId: "matrix", + }), ); expect(result.changes).toEqual([ + 'ClawHub clawhub:@openclaw/plugin-matrix@1.2.3 unavailable for "matrix"; falling back to npm @openclaw/plugin-matrix@1.2.3.', 'Installed missing configured plugin "matrix" from @openclaw/plugin-matrix@1.2.3.', ]); - expect(result.warnings).toEqual([ - "ClawHub clawhub:@openclaw/plugin-matrix@1.2.3 unavailable for matrix; falling back to npm @openclaw/plugin-matrix@1.2.3.", + expect(result.warnings).toEqual([]); + }); + + it("installs a missing third-party downloadable plugin from npm only", async () => { + mocks.installPluginFromNpmSpec.mockResolvedValueOnce({ + ok: true, + pluginId: "wecom", + targetDir: "/tmp/openclaw-plugins/wecom", + version: "2026.4.23", + npmResolution: { + name: "@wecom/wecom-openclaw-plugin", + version: "2026.4.23", + resolvedSpec: "@wecom/wecom-openclaw-plugin@2026.4.23", + integrity: "sha512-third-party", + resolvedAt: "2026-05-01T00:00:00.000Z", + }, + }); + mocks.listChannelPluginCatalogEntries.mockReturnValue([ + { + id: "wecom", + pluginId: "wecom", + meta: { label: "WeCom" }, + install: { + npmSpec: "@wecom/wecom-openclaw-plugin@2026.4.23", + }, + }, ]); + + const { repairMissingPluginInstallsForIds } = + await import("./missing-configured-plugin-install.js"); + const result = await repairMissingPluginInstallsForIds({ + cfg: {}, + pluginIds: [], + channelIds: ["wecom"], + env: {}, + }); + + expect(mocks.installPluginFromClawHub).not.toHaveBeenCalled(); + expect(mocks.installPluginFromNpmSpec).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "@wecom/wecom-openclaw-plugin@2026.4.23", + expectedPluginId: "wecom", + }), + ); + expect(result.changes).toEqual([ + 'Installed missing configured plugin "wecom" from @wecom/wecom-openclaw-plugin@2026.4.23.', + ]); + }); + + it("does not install a blocked downloadable plugin from explicit channel ids", async () => { + mocks.listChannelPluginCatalogEntries.mockReturnValue([ + { + id: "matrix", + pluginId: "matrix", + meta: { label: "Matrix" }, + install: { + npmSpec: "@openclaw/plugin-matrix@1.2.3", + }, + }, + ]); + + const { repairMissingPluginInstallsForIds } = + await import("./missing-configured-plugin-install.js"); + const result = await repairMissingPluginInstallsForIds({ + cfg: {}, + pluginIds: [], + channelIds: ["matrix"], + blockedPluginIds: ["matrix"], + env: {}, + }); + + expect(mocks.installPluginFromClawHub).not.toHaveBeenCalled(); + expect(mocks.installPluginFromNpmSpec).not.toHaveBeenCalled(); + expect(result).toEqual({ changes: [], warnings: [] }); }); it("reinstalls a missing configured plugin from its persisted install record", async () => { diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.ts b/src/commands/doctor/shared/missing-configured-plugin-install.ts index efc1b6a09f0..a7c8aaa97b9 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.ts @@ -1,6 +1,7 @@ import { listChannelPluginCatalogEntries } from "../../../channels/plugins/catalog.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import type { PluginInstallRecord } from "../../../config/types.plugins.js"; +import { parseRegistryNpmSpec } from "../../../infra/npm-registry-spec.js"; import { CLAWHUB_INSTALL_ERROR_CODE, installPluginFromClawHub } from "../../../plugins/clawhub.js"; import { resolveDefaultPluginExtensionsDir } from "../../../plugins/install-paths.js"; import { installPluginFromNpmSpec } from "../../../plugins/install.js"; @@ -15,12 +16,20 @@ import { asObjectRecord } from "./object.js"; type DownloadableInstallCandidate = { pluginId: string; label: string; - clawhubSpec?: string; npmSpec?: string; + clawhubSpec?: string; expectedIntegrity?: string; }; -function shouldFallbackClawHubCandidateToNpm(result: { ok: false; code?: string }): boolean { +function buildOpenClawClawHubSpec(npmSpec: string): string | undefined { + const parsed = parseRegistryNpmSpec(npmSpec); + if (!parsed?.name.startsWith("@openclaw/")) { + return undefined; + } + return `clawhub:${parsed.name}${parsed.selector ? `@${parsed.selector}` : ""}`; +} + +function shouldFallbackClawHubToNpm(result: { ok: false; code?: string }): boolean { return ( result.code === CLAWHUB_INSTALL_ERROR_CODE.PACKAGE_NOT_FOUND || result.code === CLAWHUB_INSTALL_ERROR_CODE.VERSION_NOT_FOUND @@ -60,9 +69,13 @@ function collectDownloadableInstallCandidates(params: { cfg: OpenClawConfig; env?: NodeJS.ProcessEnv; missingPluginIds: ReadonlySet; + configuredPluginIds?: ReadonlySet; + configuredChannelIds?: ReadonlySet; + blockedPluginIds?: ReadonlySet; }): DownloadableInstallCandidate[] { - const configuredPluginIds = collectConfiguredPluginIds(params.cfg); - const configuredChannelIds = collectConfiguredChannelIds(params.cfg); + const configuredPluginIds = params.configuredPluginIds ?? collectConfiguredPluginIds(params.cfg); + const configuredChannelIds = + params.configuredChannelIds ?? collectConfiguredChannelIds(params.cfg); const candidates = new Map(); for (const entry of listChannelPluginCatalogEntries({ @@ -70,6 +83,9 @@ function collectDownloadableInstallCandidates(params: { excludeWorkspace: true, })) { const pluginId = entry.pluginId ?? entry.id; + if (params.blockedPluginIds?.has(pluginId)) { + continue; + } if ( !params.missingPluginIds.has(pluginId) && !configuredPluginIds.has(pluginId) && @@ -77,16 +93,18 @@ function collectDownloadableInstallCandidates(params: { ) { continue; } - const clawhubSpec = entry.install.clawhubSpec?.trim(); const npmSpec = entry.install.npmSpec?.trim(); - if (!clawhubSpec && !npmSpec) { + const clawhubSpec = + entry.install.clawhubSpec?.trim() ?? + (npmSpec ? buildOpenClawClawHubSpec(npmSpec) : undefined); + if (!npmSpec && !clawhubSpec) { continue; } candidates.set(pluginId, { pluginId, label: entry.meta.label, - ...(clawhubSpec ? { clawhubSpec } : {}), ...(npmSpec ? { npmSpec } : {}), + ...(clawhubSpec ? { clawhubSpec } : {}), ...(entry.install.expectedIntegrity ? { expectedIntegrity: entry.install.expectedIntegrity } : {}), @@ -101,16 +119,21 @@ function collectDownloadableInstallCandidates(params: { if (!configuredPluginIds.has(entry.pluginId) && !params.missingPluginIds.has(entry.pluginId)) { continue; } - const clawhubSpec = entry.install.clawhubSpec?.trim(); + if (params.blockedPluginIds?.has(entry.pluginId)) { + continue; + } const npmSpec = entry.install.npmSpec?.trim(); - if (!clawhubSpec && !npmSpec) { + const clawhubSpec = + entry.install.clawhubSpec?.trim() ?? + (npmSpec ? buildOpenClawClawHubSpec(npmSpec) : undefined); + if (!npmSpec && !clawhubSpec) { continue; } candidates.set(entry.pluginId, { pluginId: entry.pluginId, label: entry.label, - ...(clawhubSpec ? { clawhubSpec } : {}), ...(npmSpec ? { npmSpec } : {}), + ...(clawhubSpec ? { clawhubSpec } : {}), ...(entry.install.expectedIntegrity ? { expectedIntegrity: entry.install.expectedIntegrity } : {}), @@ -131,35 +154,36 @@ async function installCandidate(params: { warnings: string[]; }> { const { candidate } = params; - const warnings: string[] = []; + const extensionsDir = resolveDefaultPluginExtensionsDir(); + const changes: string[] = []; if (candidate.clawhubSpec) { - const result = await installPluginFromClawHub({ + const clawhubResult = await installPluginFromClawHub({ spec: candidate.clawhubSpec, - extensionsDir: resolveDefaultPluginExtensionsDir(), + extensionsDir, expectedPluginId: candidate.pluginId, mode: "install", }); - if (result.ok) { - const pluginId = result.pluginId; + if (clawhubResult.ok) { + const pluginId = clawhubResult.pluginId; return { records: { ...params.records, [pluginId]: { source: "clawhub", spec: candidate.clawhubSpec, - installPath: result.targetDir, - version: result.version, + installPath: clawhubResult.targetDir, + version: clawhubResult.version, installedAt: new Date().toISOString(), - integrity: result.clawhub.integrity, - resolvedAt: result.clawhub.resolvedAt, - clawhubUrl: result.clawhub.clawhubUrl, - clawhubPackage: result.clawhub.clawhubPackage, - clawhubFamily: result.clawhub.clawhubFamily, - clawhubChannel: result.clawhub.clawhubChannel, - clawpackSha256: result.clawhub.clawpackSha256, - clawpackSpecVersion: result.clawhub.clawpackSpecVersion, - clawpackManifestSha256: result.clawhub.clawpackManifestSha256, - clawpackSize: result.clawhub.clawpackSize, + integrity: clawhubResult.clawhub.integrity, + resolvedAt: clawhubResult.clawhub.resolvedAt, + clawhubUrl: clawhubResult.clawhub.clawhubUrl, + clawhubPackage: clawhubResult.clawhub.clawhubPackage, + clawhubFamily: clawhubResult.clawhub.clawhubFamily, + clawhubChannel: clawhubResult.clawhub.clawhubChannel, + clawpackSha256: clawhubResult.clawhub.clawpackSha256, + clawpackSpecVersion: clawhubResult.clawhub.clawpackSpecVersion, + clawpackManifestSha256: clawhubResult.clawhub.clawpackManifestSha256, + clawpackSize: clawhubResult.clawhub.clawpackSize, }, }, changes: [ @@ -168,17 +192,17 @@ async function installCandidate(params: { warnings: [], }; } - if (!candidate.npmSpec || !shouldFallbackClawHubCandidateToNpm(result)) { + if (!candidate.npmSpec || !shouldFallbackClawHubToNpm(clawhubResult)) { return { records: params.records, changes: [], warnings: [ - `Failed to install missing configured plugin "${candidate.pluginId}" from ${candidate.clawhubSpec}: ${result.error}`, + `Failed to install missing configured plugin "${candidate.pluginId}" from ${candidate.clawhubSpec}: ${clawhubResult.error}`, ], }; } - warnings.push( - `ClawHub ${candidate.clawhubSpec} unavailable for ${candidate.pluginId}; falling back to npm ${candidate.npmSpec}.`, + changes.push( + `ClawHub ${candidate.clawhubSpec} unavailable for "${candidate.pluginId}"; falling back to npm ${candidate.npmSpec}.`, ); } if (!candidate.npmSpec) { @@ -186,13 +210,13 @@ async function installCandidate(params: { records: params.records, changes: [], warnings: [ - `Failed to install missing configured plugin "${candidate.pluginId}": no supported install source found.`, + `Failed to install missing configured plugin "${candidate.pluginId}": missing npm spec.`, ], }; } const result = await installPluginFromNpmSpec({ spec: candidate.npmSpec, - extensionsDir: resolveDefaultPluginExtensionsDir(), + extensionsDir, expectedPluginId: candidate.pluginId, expectedIntegrity: candidate.expectedIntegrity, mode: "install", @@ -202,7 +226,6 @@ async function installCandidate(params: { records: params.records, changes: [], warnings: [ - ...warnings, `Failed to install missing configured plugin "${candidate.pluginId}" from ${candidate.npmSpec}: ${result.error}`, ], }; @@ -220,14 +243,58 @@ async function installCandidate(params: { ...buildNpmResolutionInstallFields(result.npmResolution), }, }, - changes: [`Installed missing configured plugin "${pluginId}" from ${candidate.npmSpec}.`], - warnings, + changes: [ + ...changes, + `Installed missing configured plugin "${pluginId}" from ${candidate.npmSpec}.`, + ], + warnings: [], }; } export async function repairMissingConfiguredPluginInstalls(params: { cfg: OpenClawConfig; env?: NodeJS.ProcessEnv; +}): Promise<{ changes: string[]; warnings: string[] }> { + return repairMissingPluginInstalls({ + cfg: params.cfg, + env: params.env, + pluginIds: collectConfiguredPluginIds(params.cfg), + channelIds: collectConfiguredChannelIds(params.cfg), + }); +} + +export async function repairMissingPluginInstallsForIds(params: { + cfg: OpenClawConfig; + pluginIds: Iterable; + channelIds?: Iterable; + blockedPluginIds?: Iterable; + env?: NodeJS.ProcessEnv; +}): Promise<{ changes: string[]; warnings: string[] }> { + return repairMissingPluginInstalls({ + cfg: params.cfg, + env: params.env, + pluginIds: new Set( + [...params.pluginIds].map((pluginId) => pluginId.trim()).filter((pluginId) => pluginId), + ), + channelIds: new Set( + [...(params.channelIds ?? [])] + .map((channelId) => channelId.trim()) + .filter((channelId) => channelId), + ), + blockedPluginIds: new Set( + [...(params.blockedPluginIds ?? [])] + .map((pluginId) => pluginId.trim()) + .filter((pluginId) => pluginId), + ), + }); +} + +async function repairMissingPluginInstalls(params: { + cfg: OpenClawConfig; + pluginIds: ReadonlySet; + channelIds: ReadonlySet; + blockedPluginIds?: ReadonlySet; + env?: NodeJS.ProcessEnv; }): Promise<{ changes: string[]; warnings: string[] }> { const env = params.env ?? process.env; const knownIds = new Set( @@ -237,9 +304,8 @@ export async function repairMissingConfiguredPluginInstalls(params: { }).plugins.map((plugin) => plugin.id), ); const records = await loadInstalledPluginIndexInstallRecords({ env }); - const configuredPluginIds = collectConfiguredPluginIds(params.cfg); const missingRecordedPluginIds = Object.keys(records).filter( - (pluginId) => configuredPluginIds.has(pluginId) && !knownIds.has(pluginId), + (pluginId) => params.pluginIds.has(pluginId) && !knownIds.has(pluginId), ); const changes: string[] = []; const warnings: string[] = []; @@ -271,7 +337,7 @@ export async function repairMissingConfiguredPluginInstalls(params: { } const missingPluginIds = new Set( - [...configuredPluginIds].filter( + [...params.pluginIds].filter( (pluginId) => !knownIds.has(pluginId) && !Object.hasOwn(nextRecords, pluginId), ), ); @@ -279,6 +345,9 @@ export async function repairMissingConfiguredPluginInstalls(params: { cfg: params.cfg, env, missingPluginIds, + configuredPluginIds: params.pluginIds, + configuredChannelIds: params.channelIds, + blockedPluginIds: params.blockedPluginIds, })) { if (knownIds.has(candidate.pluginId) || Object.hasOwn(nextRecords, candidate.pluginId)) { continue; @@ -299,4 +368,5 @@ export const __testing = { collectConfiguredChannelIds, collectConfiguredPluginIds, collectDownloadableInstallCandidates, + buildOpenClawClawHubSpec, }; diff --git a/src/commands/doctor/shared/release-configured-plugin-installs.test.ts b/src/commands/doctor/shared/release-configured-plugin-installs.test.ts new file mode 100644 index 00000000000..d2c56ff8a30 --- /dev/null +++ b/src/commands/doctor/shared/release-configured-plugin-installs.test.ts @@ -0,0 +1,234 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + detectPluginAutoEnableCandidates: vi.fn(), + repairMissingPluginInstallsForIds: vi.fn(), + resolveProviderInstallCatalogEntries: vi.fn(), +})); + +vi.mock("../../../config/plugin-auto-enable.js", () => ({ + detectPluginAutoEnableCandidates: mocks.detectPluginAutoEnableCandidates, +})); + +vi.mock("../../../plugins/provider-install-catalog.js", () => ({ + resolveProviderInstallCatalogEntries: mocks.resolveProviderInstallCatalogEntries, +})); + +vi.mock("./missing-configured-plugin-install.js", () => ({ + repairMissingPluginInstallsForIds: mocks.repairMissingPluginInstallsForIds, +})); + +describe("configured plugin install release step", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.detectPluginAutoEnableCandidates.mockReturnValue([]); + mocks.resolveProviderInstallCatalogEntries.mockReturnValue([]); + mocks.repairMissingPluginInstallsForIds.mockResolvedValue({ + changes: [], + warnings: [], + }); + }); + + it("runs only for configs last touched before 2026.5.2", async () => { + const { shouldRunConfiguredPluginInstallReleaseStep } = + await import("./release-configured-plugin-installs.js"); + + expect( + shouldRunConfiguredPluginInstallReleaseStep({ + currentVersion: "2026.5.1", + touchedVersion: "2026.4.30", + }), + ).toBe(false); + expect( + shouldRunConfiguredPluginInstallReleaseStep({ + currentVersion: "2026.5.2", + touchedVersion: "2026.5.1", + }), + ).toBe(true); + expect( + shouldRunConfiguredPluginInstallReleaseStep({ + currentVersion: "2026.5.2", + touchedVersion: "2026.5.2", + }), + ).toBe(false); + expect( + shouldRunConfiguredPluginInstallReleaseStep({ + currentVersion: "2026.5.3", + touchedVersion: "2026.5.3", + }), + ).toBe(false); + expect( + shouldRunConfiguredPluginInstallReleaseStep({ + currentVersion: "2026.5.2", + touchedVersion: "not-a-version", + }), + ).toBe(true); + }); + + it("collects used plugin ids without treating allow-only entries as usage", async () => { + mocks.detectPluginAutoEnableCandidates.mockReturnValue([ + { pluginId: "matrix", kind: "channel-configured", channelId: "matrix" }, + { pluginId: "denied", kind: "setup-auto-enable", reason: "test" }, + { pluginId: "disabled-entry", kind: "setup-auto-enable", reason: "test" }, + ]); + mocks.resolveProviderInstallCatalogEntries.mockReturnValue([ + { + pluginId: "anthropic-provider", + providerId: "anthropic", + }, + { + pluginId: "unused-provider", + providerId: "unused", + }, + ]); + + const { collectReleaseConfiguredPluginIds } = + await import("./release-configured-plugin-installs.js"); + const result = collectReleaseConfiguredPluginIds({ + cfg: { + auth: { + profiles: { + work: { + provider: "anthropic", + mode: "api_key", + }, + }, + }, + channels: { + wecom: { enabled: true }, + off: { enabled: false }, + }, + plugins: { + allow: ["allow-only"], + deny: ["denied"], + slots: { + memory: "memory-lancedb", + contextEngine: "none", + }, + entries: { + configured: { config: { nested: true } }, + "disabled-entry": { enabled: false, config: { nested: true } }, + }, + }, + }, + env: {}, + }); + + expect(result.pluginIds).toEqual([ + "anthropic-provider", + "configured", + "matrix", + "memory-lancedb", + ]); + expect(result.channelIds).toEqual(["wecom"]); + }); + + it("does not collect channel ids when the matching plugin id is blocked", async () => { + const { collectReleaseConfiguredPluginIds } = + await import("./release-configured-plugin-installs.js"); + + expect( + collectReleaseConfiguredPluginIds({ + cfg: { + channels: { + matrix: { accessToken: "test" }, + }, + plugins: { + deny: ["matrix"], + }, + }, + env: {}, + }).channelIds, + ).toEqual([]); + + expect( + collectReleaseConfiguredPluginIds({ + cfg: { + channels: { + matrix: { accessToken: "test" }, + }, + plugins: { + entries: { + matrix: { enabled: false }, + }, + }, + }, + env: {}, + }).channelIds, + ).toEqual([]); + }); + + it("marks the release step complete when there is nothing to install", async () => { + const { maybeRunConfiguredPluginInstallReleaseStep } = + await import("./release-configured-plugin-installs.js"); + const result = await maybeRunConfiguredPluginInstallReleaseStep({ + cfg: {}, + currentVersion: "2026.5.2", + touchedVersion: "2026.5.1", + env: {}, + }); + + expect(mocks.repairMissingPluginInstallsForIds).not.toHaveBeenCalled(); + expect(result).toEqual({ + changes: [], + warnings: [], + completed: true, + touchedConfig: true, + }); + }); + + it("repairs used plugin installs and touches config only on success", async () => { + mocks.detectPluginAutoEnableCandidates.mockReturnValue([ + { pluginId: "matrix", kind: "channel-configured", channelId: "matrix" }, + ]); + mocks.repairMissingPluginInstallsForIds.mockResolvedValue({ + changes: ['Installed missing configured plugin "matrix".'], + warnings: [], + }); + + const { maybeRunConfiguredPluginInstallReleaseStep } = + await import("./release-configured-plugin-installs.js"); + const result = await maybeRunConfiguredPluginInstallReleaseStep({ + cfg: {}, + currentVersion: "2026.5.2", + touchedVersion: "2026.5.1", + env: {}, + }); + + expect(mocks.repairMissingPluginInstallsForIds).toHaveBeenCalledWith( + expect.objectContaining({ + pluginIds: ["matrix"], + channelIds: [], + env: {}, + }), + ); + expect(result.touchedConfig).toBe(true); + expect(result.completed).toBe(true); + }); + + it("does not touch config when install repair warns", async () => { + mocks.detectPluginAutoEnableCandidates.mockReturnValue([ + { pluginId: "matrix", kind: "channel-configured", channelId: "matrix" }, + ]); + mocks.repairMissingPluginInstallsForIds.mockResolvedValue({ + changes: [], + warnings: ["install failed"], + }); + + const { maybeRunConfiguredPluginInstallReleaseStep } = + await import("./release-configured-plugin-installs.js"); + const result = await maybeRunConfiguredPluginInstallReleaseStep({ + cfg: {}, + currentVersion: "2026.5.2", + touchedVersion: "2026.5.1", + env: {}, + }); + + expect(result).toEqual({ + changes: [], + warnings: ["install failed"], + completed: false, + touchedConfig: false, + }); + }); +}); diff --git a/src/commands/doctor/shared/release-configured-plugin-installs.ts b/src/commands/doctor/shared/release-configured-plugin-installs.ts new file mode 100644 index 00000000000..4c47afd06e4 --- /dev/null +++ b/src/commands/doctor/shared/release-configured-plugin-installs.ts @@ -0,0 +1,315 @@ +import { listPotentialConfiguredChannelPresenceSignals } from "../../../channels/config-presence.js"; +import { normalizeChatChannelId } from "../../../channels/registry.js"; +import { isChannelConfigured } from "../../../config/channel-configured.js"; +import { detectPluginAutoEnableCandidates } from "../../../config/plugin-auto-enable.js"; +import type { OpenClawConfig } from "../../../config/types.openclaw.js"; +import { compareOpenClawVersions } from "../../../config/version.js"; +import { resolveProviderInstallCatalogEntries } from "../../../plugins/provider-install-catalog.js"; +import { VERSION } from "../../../version.js"; +import { repairMissingPluginInstallsForIds } from "./missing-configured-plugin-install.js"; +import { asObjectRecord } from "./object.js"; + +export const CONFIGURED_PLUGIN_INSTALL_RELEASE_VERSION = "2026.5.2"; + +type ReleaseConfiguredPluginIds = { + pluginIds: string[]; + channelIds: string[]; +}; + +function normalizeId(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function isPluginsGloballyDisabled(cfg: OpenClawConfig): boolean { + return cfg.plugins?.enabled === false; +} + +function isDenied(cfg: OpenClawConfig, pluginId: string): boolean { + const deny = cfg.plugins?.deny; + return Array.isArray(deny) && deny.includes(pluginId); +} + +function collectBlockedPluginIds(cfg: OpenClawConfig): string[] { + const ids = new Set(); + const deny = cfg.plugins?.deny; + if (Array.isArray(deny)) { + for (const pluginId of deny) { + const normalized = normalizeId(pluginId); + if (normalized) { + ids.add(normalized); + } + } + } + const entries = asObjectRecord(cfg.plugins?.entries); + for (const [pluginId, entry] of Object.entries(entries ?? {})) { + if (asObjectRecord(entry)?.enabled === false && pluginId.trim()) { + ids.add(pluginId.trim()); + } + } + return [...ids].toSorted((left, right) => left.localeCompare(right)); +} + +function isPluginEntryDisabled(cfg: OpenClawConfig, pluginId: string): boolean { + return cfg.plugins?.entries?.[pluginId]?.enabled === false; +} + +function isChannelDisabled(cfg: OpenClawConfig, channelId: string): boolean { + const channels = asObjectRecord(cfg.channels); + const entry = asObjectRecord(channels?.[channelId]); + return entry?.enabled === false; +} + +function isDisabled(cfg: OpenClawConfig, pluginId: string): boolean { + if (isPluginEntryDisabled(cfg, pluginId)) { + return true; + } + const channelId = normalizeChatChannelId(pluginId); + return channelId ? isChannelDisabled(cfg, channelId) : false; +} + +function hasMaterialPluginEntry(entry: unknown): boolean { + const record = asObjectRecord(entry); + if (!record) { + return false; + } + return ( + record.enabled === true || + asObjectRecord(record.config) !== null || + asObjectRecord(record.hooks) !== null || + asObjectRecord(record.subagent) !== null || + record.apiKey !== undefined || + record.env !== undefined + ); +} + +function collectMaterialPluginEntryIds(cfg: OpenClawConfig): string[] { + const entries = asObjectRecord(cfg.plugins?.entries); + if (!entries) { + return []; + } + return Object.entries(entries) + .filter(([, entry]) => hasMaterialPluginEntry(entry)) + .map(([pluginId]) => pluginId.trim()) + .filter((pluginId) => pluginId); +} + +function collectSlotPluginIds(cfg: OpenClawConfig): string[] { + const slots = asObjectRecord(cfg.plugins?.slots); + return ["memory", "contextEngine"] + .map((key) => normalizeId(slots?.[key])) + .filter((pluginId): pluginId is string => !!pluginId && pluginId.toLowerCase() !== "none"); +} + +function collectConfiguredChannelIds(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): string[] { + const ids = new Set(); + const channels = asObjectRecord(cfg.channels); + if (channels) { + for (const [channelId, value] of Object.entries(channels)) { + if (channelId === "defaults" || channelId === "modelByChannel" || !channelId.trim()) { + continue; + } + const entry = asObjectRecord(value); + if (entry?.enabled === false) { + continue; + } + if (entry?.enabled === true || Object.keys(entry ?? {}).some((key) => key !== "enabled")) { + ids.add(channelId.trim()); + } + } + } + for (const signal of listPotentialConfiguredChannelPresenceSignals(cfg, env, { + includePersistedAuthState: false, + })) { + const channelId = normalizeChatChannelId(signal.channelId) ?? signal.channelId; + if (!isChannelDisabled(cfg, channelId) && isChannelConfigured(cfg, channelId, env)) { + ids.add(channelId); + } + } + return [...ids].toSorted((left, right) => left.localeCompare(right)); +} + +function collectConfiguredProviderIds(cfg: OpenClawConfig): Set { + const ids = new Set(); + const add = (value: unknown) => { + const id = normalizeId(value); + if (id) { + ids.add(id.toLowerCase()); + } + }; + for (const profile of Object.values(asObjectRecord(cfg.auth?.profiles) ?? {})) { + add(asObjectRecord(profile)?.provider); + } + for (const providerId of Object.keys(asObjectRecord(cfg.models?.providers) ?? {})) { + add(providerId); + } + const collectModelRef = (value: unknown) => { + const ref = normalizeId(value); + const slash = ref?.indexOf("/") ?? -1; + if (ref && slash > 0) { + add(ref.slice(0, slash)); + } + }; + const collectModelConfig = (value: unknown) => { + if (typeof value === "string") { + collectModelRef(value); + return; + } + const record = asObjectRecord(value); + if (!record) { + return; + } + collectModelRef(record.primary); + if (Array.isArray(record.fallbacks)) { + for (const fallback of record.fallbacks) { + collectModelRef(fallback); + } + } + }; + const collectAgent = (agent: unknown) => { + const record = asObjectRecord(agent); + if (!record) { + return; + } + for (const key of [ + "model", + "imageGenerationModel", + "videoGenerationModel", + "musicGenerationModel", + ]) { + collectModelConfig(record[key]); + } + for (const modelRef of Object.keys(asObjectRecord(record.models) ?? {})) { + collectModelRef(modelRef); + } + }; + collectAgent(cfg.agents?.defaults); + for (const agent of Array.isArray(cfg.agents?.list) ? cfg.agents.list : []) { + collectAgent(agent); + } + return ids; +} + +function collectProviderPluginIds(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): string[] { + const configuredProviders = collectConfiguredProviderIds(cfg); + if (configuredProviders.size === 0) { + return []; + } + const ids = new Set(); + for (const entry of resolveProviderInstallCatalogEntries({ + config: cfg, + env, + includeUntrustedWorkspacePlugins: false, + })) { + if (configuredProviders.has(entry.providerId.toLowerCase())) { + ids.add(entry.pluginId); + } + } + return [...ids].toSorted((left, right) => left.localeCompare(right)); +} + +function addEligiblePluginId(cfg: OpenClawConfig, pluginIds: Set, pluginId: string): void { + const normalized = pluginId.trim(); + if (!normalized || isDenied(cfg, normalized) || isDisabled(cfg, normalized)) { + return; + } + pluginIds.add(normalized); +} + +export function shouldRunConfiguredPluginInstallReleaseStep(params: { + currentVersion?: string | null; + touchedVersion?: string | null; + releaseVersion?: string; +}): boolean { + const releaseVersion = params.releaseVersion ?? CONFIGURED_PLUGIN_INSTALL_RELEASE_VERSION; + const currentComparedToRelease = compareOpenClawVersions( + params.currentVersion ?? VERSION, + releaseVersion, + ); + if (currentComparedToRelease === null || currentComparedToRelease < 0) { + return false; + } + const touchedComparedToRelease = compareOpenClawVersions(params.touchedVersion, releaseVersion); + return touchedComparedToRelease === null || touchedComparedToRelease < 0; +} + +export function collectReleaseConfiguredPluginIds(params: { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; +}): ReleaseConfiguredPluginIds { + const env = params.env ?? process.env; + const pluginIds = new Set(); + const channelIds = new Set(); + if (isPluginsGloballyDisabled(params.cfg)) { + return { pluginIds: [], channelIds: [] }; + } + + for (const candidate of detectPluginAutoEnableCandidates({ + config: params.cfg, + env, + })) { + addEligiblePluginId(params.cfg, pluginIds, candidate.pluginId); + } + for (const pluginId of collectMaterialPluginEntryIds(params.cfg)) { + addEligiblePluginId(params.cfg, pluginIds, pluginId); + } + for (const pluginId of collectSlotPluginIds(params.cfg)) { + addEligiblePluginId(params.cfg, pluginIds, pluginId); + } + for (const pluginId of collectProviderPluginIds(params.cfg, env)) { + addEligiblePluginId(params.cfg, pluginIds, pluginId); + } + for (const channelId of collectConfiguredChannelIds(params.cfg, env)) { + if ( + !isChannelDisabled(params.cfg, channelId) && + !isDenied(params.cfg, channelId) && + !isPluginEntryDisabled(params.cfg, channelId) + ) { + channelIds.add(channelId); + } + } + + return { + pluginIds: [...pluginIds].toSorted((left, right) => left.localeCompare(right)), + channelIds: [...channelIds].toSorted((left, right) => left.localeCompare(right)), + }; +} + +export async function maybeRunConfiguredPluginInstallReleaseStep(params: { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; + touchedVersion?: string | null; + currentVersion?: string | null; +}): Promise<{ + changes: string[]; + warnings: string[]; + completed: boolean; + touchedConfig: boolean; +}> { + if ( + !shouldRunConfiguredPluginInstallReleaseStep({ + currentVersion: params.currentVersion, + touchedVersion: params.touchedVersion, + }) + ) { + return { changes: [], warnings: [], completed: false, touchedConfig: false }; + } + const env = params.env ?? process.env; + const configured = collectReleaseConfiguredPluginIds({ cfg: params.cfg, env }); + if (configured.pluginIds.length === 0 && configured.channelIds.length === 0) { + return { changes: [], warnings: [], completed: true, touchedConfig: true }; + } + const repaired = await repairMissingPluginInstallsForIds({ + cfg: params.cfg, + pluginIds: configured.pluginIds, + channelIds: configured.channelIds, + blockedPluginIds: collectBlockedPluginIds(params.cfg), + env, + }); + const completed = repaired.warnings.length === 0; + return { + changes: repaired.changes, + warnings: repaired.warnings, + completed, + touchedConfig: completed, + }; +} diff --git a/src/flows/doctor-health-contributions.test.ts b/src/flows/doctor-health-contributions.test.ts index b64c3a84b40..eb3e8f6b9fb 100644 --- a/src/flows/doctor-health-contributions.test.ts +++ b/src/flows/doctor-health-contributions.test.ts @@ -1,16 +1,94 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { resolveDoctorHealthContributions, shouldSkipLegacyUpdateDoctorConfigWrite, } from "./doctor-health-contributions.js"; +const mocks = vi.hoisted(() => ({ + maybeRunConfiguredPluginInstallReleaseStep: vi.fn(), + note: vi.fn(), +})); + +vi.mock("../commands/doctor/shared/release-configured-plugin-installs.js", () => ({ + maybeRunConfiguredPluginInstallReleaseStep: mocks.maybeRunConfiguredPluginInstallReleaseStep, +})); + +vi.mock("../terminal/note.js", () => ({ + note: mocks.note, +})); + +vi.mock("../version.js", () => ({ + VERSION: "2026.5.2-test", +})); + describe("doctor health contributions", () => { - it("runs plugin registry repair before final config writes", () => { + beforeEach(() => { + mocks.maybeRunConfiguredPluginInstallReleaseStep.mockReset(); + mocks.note.mockReset(); + }); + + it("runs release configured plugin install repair before plugin registry and final config writes", () => { const ids = resolveDoctorHealthContributions().map((entry) => entry.id); + expect(ids.indexOf("doctor:release-configured-plugin-installs")).toBeGreaterThan(-1); expect(ids.indexOf("doctor:plugin-registry")).toBeGreaterThan(-1); + expect(ids.indexOf("doctor:release-configured-plugin-installs")).toBeLessThan( + ids.indexOf("doctor:plugin-registry"), + ); expect(ids.indexOf("doctor:plugin-registry")).toBeLessThan(ids.indexOf("doctor:write-config")); }); + + it("keeps release configured plugin installs repair-only", async () => { + const contribution = resolveDoctorHealthContributions().find( + (entry) => entry.id === "doctor:release-configured-plugin-installs", + ); + expect(contribution).toBeDefined(); + const ctx = { + cfg: {}, + configResult: { cfg: {}, sourceLastTouchedVersion: "2026.4.29" }, + sourceConfigValid: true, + prompter: { shouldRepair: false }, + env: {}, + } as Parameters["run"]>[0]; + + await contribution?.run(ctx); + + expect(mocks.maybeRunConfiguredPluginInstallReleaseStep).not.toHaveBeenCalled(); + expect(mocks.note).not.toHaveBeenCalled(); + }); + + it("stamps release configured plugin installs after repair changes", async () => { + mocks.maybeRunConfiguredPluginInstallReleaseStep.mockResolvedValue({ + changes: ["Installed configured plugin matrix."], + warnings: [], + touchedConfig: true, + }); + const contribution = resolveDoctorHealthContributions().find( + (entry) => entry.id === "doctor:release-configured-plugin-installs", + ); + expect(contribution).toBeDefined(); + const ctx = { + cfg: {}, + configResult: { cfg: {}, sourceLastTouchedVersion: "2026.4.29" }, + sourceConfigValid: true, + prompter: { shouldRepair: true }, + env: {}, + } as Parameters["run"]>[0]; + + await contribution?.run(ctx); + + expect(mocks.maybeRunConfiguredPluginInstallReleaseStep).toHaveBeenCalledWith({ + cfg: {}, + env: {}, + touchedVersion: "2026.4.29", + }); + expect(mocks.note).toHaveBeenCalledWith( + "Installed configured plugin matrix.", + "Doctor changes", + ); + expect(ctx.cfg.meta?.lastTouchedVersion).toBe("2026.5.2-test"); + }); + it("checks command owner configuration before final config writes", () => { const ids = resolveDoctorHealthContributions().map((entry) => entry.id); diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index f4208e7fc20..36b9f2bbf9f 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -13,6 +13,7 @@ type DoctorConfigResult = { path?: string; shouldWriteConfig?: boolean; sourceConfigValid?: boolean; + sourceLastTouchedVersion?: string; }; type DoctorHealthFlowContext = { @@ -268,6 +269,43 @@ async function runPluginRegistryHealth(ctx: DoctorHealthFlowContext): Promise { + if (!ctx.sourceConfigValid) { + return; + } + if (!ctx.prompter.shouldRepair) { + return; + } + const { maybeRunConfiguredPluginInstallReleaseStep } = + await import("../commands/doctor/shared/release-configured-plugin-installs.js"); + const { note } = await import("../terminal/note.js"); + const { VERSION } = await import("../version.js"); + const result = await maybeRunConfiguredPluginInstallReleaseStep({ + cfg: ctx.cfg, + env: ctx.env ?? process.env, + touchedVersion: ctx.configResult.sourceLastTouchedVersion ?? ctx.cfg.meta?.lastTouchedVersion, + }); + if (result.changes.length > 0) { + note(result.changes.join("\n"), "Doctor changes"); + } + if (result.warnings.length > 0) { + note(result.warnings.join("\n"), "Doctor warnings"); + } + if (!result.touchedConfig) { + return; + } + ctx.cfg = { + ...ctx.cfg, + meta: { + ...ctx.cfg.meta, + lastTouchedVersion: VERSION, + lastTouchedAt: new Date().toISOString(), + }, + }; +} + async function runStateIntegrityHealth(ctx: DoctorHealthFlowContext): Promise { const { noteStateIntegrity } = await import("../commands/doctor-state-integrity.js"); await noteStateIntegrity(ctx.cfg, ctx.prompter, ctx.configPath); @@ -599,6 +637,11 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] { label: "Legacy plugin manifests", run: runLegacyPluginManifestHealth, }), + createDoctorHealthContribution({ + id: "doctor:release-configured-plugin-installs", + label: "Configured plugin installs", + run: runReleaseConfiguredPluginInstallsHealth, + }), createDoctorHealthContribution({ id: "doctor:plugin-registry", label: "Plugin registry", diff --git a/test/scripts/docker-e2e-plan.test.ts b/test/scripts/docker-e2e-plan.test.ts index a1e6c173ee1..7faa467950b 100644 --- a/test/scripts/docker-e2e-plan.test.ts +++ b/test/scripts/docker-e2e-plan.test.ts @@ -353,6 +353,7 @@ describe("scripts/lib/docker-e2e-plan", () => { "published-upgrade-survivor-2026.4.29-feishu-channel", "published-upgrade-survivor-2026.4.29-bootstrap-persona", "published-upgrade-survivor-2026.4.29-plugin-deps-cleanup", + "published-upgrade-survivor-2026.4.29-configured-plugin-installs", "published-upgrade-survivor-2026.4.29-tilde-log-path", "published-upgrade-survivor-2026.4.29-versioned-runtime-deps", ]); @@ -370,11 +371,13 @@ describe("scripts/lib/docker-e2e-plan", () => { "published-upgrade-survivor-2026.4.29-feishu-channel", "published-upgrade-survivor-2026.4.29-bootstrap-persona", "published-upgrade-survivor-2026.4.29-plugin-deps-cleanup", + "published-upgrade-survivor-2026.4.29-configured-plugin-installs", "published-upgrade-survivor-2026.4.29-tilde-log-path", "published-upgrade-survivor-2026.4.29-versioned-runtime-deps", "published-upgrade-survivor-2026.3.13", "published-upgrade-survivor-2026.3.13-feishu-channel", "published-upgrade-survivor-2026.3.13-bootstrap-persona", + "published-upgrade-survivor-2026.3.13-configured-plugin-installs", "published-upgrade-survivor-2026.3.13-tilde-log-path", "published-upgrade-survivor-2026.3.13-versioned-runtime-deps", ]);