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

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