fix: repair configured plugin installs (#76129)

Summary:
- The PR adds a 2026.5.2 doctor repair pass for actively used configured downloadable plugins, prefers ClawHub ... pm fallback, records installed plugin state, extends upgrade-survivor coverage, and updates docs/changelog.
- Reproducibility: yes. Static inspection of current main and the PR head gives a high-confidence reproduction ... d-plugin install pass, while the PR tests the new repair-only path, success stamping, and warning behavior.

ClawSweeper fixups:
- Included follow-up commit: test: cover configured plugin install update path
- Included follow-up commit: test: isolate channel option metadata cache
- Included follow-up commit: fix: keep configured plugin repair scoped

Validation:
- ClawSweeper review passed for head d3519ce42c.
- Required merge gates passed before the squash merge.

Prepared head SHA: d3519ce42c
Review: https://github.com/openclaw/openclaw/pull/76129#issuecomment-4364120658

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Peter Steinberger
2026-05-02 16:49:52 +01:00
committed by GitHub
parent 7b6b6401ce
commit b63d098e8c
22 changed files with 1133 additions and 121 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -45,7 +45,7 @@ Notes:
- State integrity checks now detect orphan transcript files in the sessions directory. Archiving them as `.deleted.<timestamp>` 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.<id>` 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.

View File

@@ -344,7 +344,7 @@ That stages grounded durable candidates into the short-term dreaming store while
<Accordion title="7b. Plugin install cleanup">
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.
</Accordion>
<Accordion title="8. Gateway service migrations and cleanup hints">

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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 {

View File

@@ -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 = [

View File

@@ -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"]
}
}
}
}

View File

@@ -0,0 +1,15 @@
{
"enabled": true,
"allow": ["discord", "telegram", "matrix"],
"entries": {
"discord": {
"enabled": true
},
"matrix": {
"enabled": true
},
"telegram": {
"enabled": true
}
}
}

View File

@@ -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

View File

@@ -74,6 +74,7 @@ const UPGRADE_SURVIVOR_SCENARIOS = [
"feishu-channel",
"bootstrap-persona",
"plugin-deps-cleanup",
"configured-plugin-installs",
"tilde-log-path",
"versioned-runtime-deps",
];

View File

@@ -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;
});
});

View File

@@ -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 } : {}),
};
}

View File

@@ -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 () => {

View File

@@ -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<string>;
configuredPluginIds?: ReadonlySet<string>;
configuredChannelIds?: ReadonlySet<string>;
blockedPluginIds?: ReadonlySet<string>;
}): 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<string, DownloadableInstallCandidate>();
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<string>;
channelIds?: Iterable<string>;
blockedPluginIds?: Iterable<string>;
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<string>;
channelIds: ReadonlySet<string>;
blockedPluginIds?: ReadonlySet<string>;
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,
};

View File

@@ -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,
});
});
});

View File

@@ -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<string>();
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<string>();
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<string> {
const ids = new Set<string>();
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<string>();
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<string>, 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<string>();
const channelIds = new Set<string>();
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,
};
}

View File

@@ -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<NonNullable<typeof contribution>["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<NonNullable<typeof contribution>["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);

View File

@@ -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<vo
});
}
async function runReleaseConfiguredPluginInstallsHealth(
ctx: DoctorHealthFlowContext,
): Promise<void> {
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<void> {
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",

View File

@@ -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",
]);