mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 19:00:45 +00:00
Tolerate corrupt plugins during update (#77706)
* fix(update): tolerate corrupt plugin state * fix(update): preserve corrupt plugin proof state * fix(update): narrow corrupt plugin warnings --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -595,7 +595,7 @@ jobs:
|
||||
artifact_name: ${{ needs.prepare_release_package.outputs.artifact_name }}
|
||||
package_sha256: ${{ needs.prepare_release_package.outputs.package_sha256 }}
|
||||
suite_profile: custom
|
||||
docker_lanes: doctor-switch update-channel-switch upgrade-survivor published-upgrade-survivor update-restart-auth plugins-offline plugin-update
|
||||
docker_lanes: doctor-switch update-channel-switch update-corrupt-plugin upgrade-survivor published-upgrade-survivor update-restart-auth plugins-offline plugin-update
|
||||
published_upgrade_survivor_baselines: ${{ needs.resolve_target.outputs.run_release_soak == 'true' && 'last-stable-4 2026.4.23 2026.5.2 2026.4.15' || '' }}
|
||||
published_upgrade_survivor_scenarios: ${{ needs.resolve_target.outputs.run_release_soak == 'true' && 'reported-issues' || '' }}
|
||||
telegram_mode: mock-openai
|
||||
|
||||
4
.github/workflows/package-acceptance.yml
vendored
4
.github/workflows/package-acceptance.yml
vendored
@@ -386,10 +386,10 @@ jobs:
|
||||
docker_lanes="npm-onboard-channel-agent gateway-network config-reload"
|
||||
;;
|
||||
package)
|
||||
docker_lanes="npm-onboard-channel-agent doctor-switch update-channel-switch upgrade-survivor published-upgrade-survivor update-restart-auth plugins-offline plugin-update"
|
||||
docker_lanes="npm-onboard-channel-agent doctor-switch update-channel-switch update-corrupt-plugin upgrade-survivor published-upgrade-survivor update-restart-auth plugins-offline plugin-update"
|
||||
;;
|
||||
product)
|
||||
docker_lanes="npm-onboard-channel-agent doctor-switch update-channel-switch upgrade-survivor published-upgrade-survivor update-restart-auth plugins plugin-update mcp-channels cron-mcp-cleanup openai-web-search-minimal openwebui"
|
||||
docker_lanes="npm-onboard-channel-agent doctor-switch update-channel-switch update-corrupt-plugin upgrade-survivor published-upgrade-survivor update-restart-auth plugins plugin-update mcp-channels cron-mcp-cleanup openai-web-search-minimal openwebui"
|
||||
include_openwebui=true
|
||||
;;
|
||||
full)
|
||||
|
||||
@@ -245,7 +245,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Doctor/sessions: clear auto-created stale session routing state from the sessions store when `doctor --fix` sees plugin-owned model/runtime/auth/session bindings outside the current configured route, while leaving explicit user model choices for manual review. Refs #68615.
|
||||
- CLI/sessions: prune old unreferenced transcript, compaction checkpoint, and trajectory artifacts during normal `sessions cleanup`, so gateway restart or crash orphans do not accumulate indefinitely outside `sessions.json`. Fixes #77608. Thanks @slideshow-dingo.
|
||||
- CLI/sessions: cap `openclaw sessions` output to the newest 100 rows by default and add `--limit <n|all>` plus JSON pagination metadata, so repeated machine polling of large session stores cannot fan out into unbounded per-row enrichment/output work. Fixes #77500. Thanks @Kaotic3.
|
||||
- CLI/update: disable and skip plugins that fail package-update plugin sync, so a broken npm/ClawHub/git/marketplace plugin cannot turn a successful OpenClaw package update into a failed update result. Thanks @vincentkoc.
|
||||
- CLI/update: report corrupt or unloadable managed plugins as post-update warnings instead of disabling them or turning a successful OpenClaw package update into a failed update result. Thanks @vincentkoc and @Patrick-Erichsen.
|
||||
- CLI/update: use an absolute POSIX npm script shell during package-manager updates, so restricted PATH environments can still run dependency lifecycle scripts while updating from `--tag main`. Fixes #77530. Thanks @PeterTremonti.
|
||||
- CLI/update: make package-update follow-up processes write completion results and exit explicitly, so Windows packaged upgrades do not hang after the new package finishes post-core plugin work. Thanks @vincentkoc.
|
||||
- CLI/update: stage pnpm-detected npm-layout global package updates through a clean npm prefix swap, keep plugin install runtime imports behind a stable alias, and ship legacy install-runtime aliases back to `2026.3.22`, preventing stale overlay chunks from breaking plugin post-update sync. Thanks @vincentkoc.
|
||||
|
||||
@@ -38,8 +38,9 @@ openclaw --update
|
||||
- `--tag <dist-tag|version|spec>`: override the package target for this update only. For package installs, `main` maps to `github:openclaw/openclaw#main`.
|
||||
- `--dry-run`: preview planned update actions (channel/tag/target/restart flow) without writing config, installing, syncing plugins, or restarting.
|
||||
- `--json`: print machine-readable `UpdateRunResult` JSON, including
|
||||
`postUpdate.plugins.integrityDrifts` when npm plugin artifact drift is
|
||||
detected during post-update plugin sync.
|
||||
`postUpdate.plugins.warnings` when corrupt or unloadable managed plugins need
|
||||
repair after the core update succeeds, and `postUpdate.plugins.integrityDrifts`
|
||||
when npm plugin artifact drift is detected during post-update plugin sync.
|
||||
- `--timeout <seconds>`: per-step timeout (default is 1800s).
|
||||
- `--yes`: skip confirmation prompts (for example downgrade confirmation).
|
||||
|
||||
@@ -177,7 +178,7 @@ If an exact pinned npm plugin update resolves to an artifact whose integrity dif
|
||||
</Warning>
|
||||
|
||||
<Note>
|
||||
Post-update plugin sync failures fail the update result and stop restart follow-up work. Fix the plugin install or update error, then rerun `openclaw update`.
|
||||
Post-update plugin sync failures that are scoped to a managed plugin are reported as warnings after the core update succeeds. The JSON result keeps the top-level update `status: "ok"` and reports `postUpdate.plugins.status: "warning"` with `openclaw doctor --fix` and `openclaw plugins inspect <id> --runtime --json` guidance. Unexpected updater or sync exceptions still fail the update result. Fix the plugin install or update error, then rerun `openclaw doctor --fix` or `openclaw update`.
|
||||
|
||||
When the updated Gateway starts, plugin loading is verify-only: startup does not run package managers or mutate dependency trees. Package-manager `update.run` restarts bypass the normal idle deferral and restart cooldown after the package tree has been swapped, so the old process cannot keep lazy-loading removed chunks.
|
||||
|
||||
|
||||
@@ -172,7 +172,7 @@ targets the shipped npm package instead.
|
||||
Release checks call Package Acceptance with the package/update/restart/plugin set:
|
||||
|
||||
```text
|
||||
doctor-switch update-channel-switch upgrade-survivor published-upgrade-survivor update-restart-auth plugins-offline plugin-update
|
||||
doctor-switch update-channel-switch update-corrupt-plugin upgrade-survivor published-upgrade-survivor update-restart-auth plugins-offline plugin-update
|
||||
```
|
||||
|
||||
When release soak is enabled, they also pass:
|
||||
@@ -183,10 +183,10 @@ published_upgrade_survivor_scenarios=reported-issues
|
||||
telegram_mode=mock-openai
|
||||
```
|
||||
|
||||
This keeps package migration, update channel switching, stale plugin dependency
|
||||
cleanup, offline plugin coverage, plugin update behavior, and Telegram package
|
||||
QA on the same resolved artifact without making the default release package gate
|
||||
walk every published release.
|
||||
This keeps package migration, update channel switching, corrupt managed-plugin
|
||||
tolerance, stale plugin dependency cleanup, offline plugin coverage, plugin
|
||||
update behavior, and Telegram package QA on the same resolved artifact without
|
||||
making the default release package gate walk every published release.
|
||||
|
||||
`last-stable-4` resolves to the four latest stable npm-published OpenClaw
|
||||
releases. Release package acceptance pins `2026.4.23` as the first plugin-update
|
||||
|
||||
@@ -1570,6 +1570,7 @@
|
||||
"test:docker:session-runtime-context": "bash scripts/e2e/session-runtime-context-docker.sh",
|
||||
"test:docker:timings": "node scripts/docker-e2e-timings.mjs",
|
||||
"test:docker:update-channel-switch": "bash scripts/e2e/update-channel-switch-docker.sh",
|
||||
"test:docker:update-corrupt-plugin": "bash scripts/e2e/update-corrupt-plugin-docker.sh",
|
||||
"test:docker:update-migration": "env OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE=1 OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC=${OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC:-openclaw@2026.4.23} OPENCLAW_UPGRADE_SURVIVOR_SCENARIO=${OPENCLAW_UPGRADE_SURVIVOR_SCENARIO:-plugin-deps-cleanup} bash scripts/e2e/upgrade-survivor-docker.sh",
|
||||
"test:docker:update-restart-auth": "env OPENCLAW_UPGRADE_SURVIVOR_UPDATE_RESTART_MODE=auto-auth OPENCLAW_UPGRADE_SURVIVOR_DOCKER_RUN_TIMEOUT=${OPENCLAW_UPGRADE_SURVIVOR_DOCKER_RUN_TIMEOUT:-1500s} bash scripts/e2e/upgrade-survivor-docker.sh",
|
||||
"test:docker:upgrade-survivor": "bash scripts/e2e/upgrade-survivor-docker.sh",
|
||||
|
||||
89
scripts/e2e/lib/plugin-update/corrupt-update-scenario.sh
Normal file
89
scripts/e2e/lib/plugin-update/corrupt-update-scenario.sh
Normal file
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
source scripts/lib/openclaw-e2e-instance.sh
|
||||
source scripts/e2e/lib/plugins/fixtures.sh
|
||||
|
||||
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
|
||||
|
||||
export npm_config_loglevel=error
|
||||
export npm_config_fund=false
|
||||
export npm_config_audit=false
|
||||
export npm_config_prefix=/tmp/npm-prefix
|
||||
export NPM_CONFIG_PREFIX=/tmp/npm-prefix
|
||||
export PATH="/tmp/npm-prefix/bin:$PATH"
|
||||
export CI=true
|
||||
export OPENCLAW_DISABLE_BUNDLED_PLUGINS=1
|
||||
export OPENCLAW_NO_ONBOARD=1
|
||||
export OPENCLAW_NO_PROMPT=1
|
||||
|
||||
baseline="${OPENCLAW_UPDATE_CORRUPT_PLUGIN_BASELINE:-openclaw@latest}"
|
||||
echo "Installing baseline OpenClaw package: $baseline"
|
||||
if ! npm install -g --prefix /tmp/npm-prefix --omit=optional "$baseline" >/tmp/openclaw-update-corrupt-baseline-install.log 2>&1; then
|
||||
cat /tmp/openclaw-update-corrupt-baseline-install.log >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
package_root="$(openclaw_e2e_package_root /tmp/npm-prefix)"
|
||||
entry="$(openclaw_e2e_package_entrypoint "$package_root")"
|
||||
export OPENCLAW_ENTRY="$entry"
|
||||
|
||||
npm_pack_dir="$(mktemp -d "/tmp/openclaw-corrupt-plugin-pack.XXXXXX")"
|
||||
npm_registry_dir="$(mktemp -d "/tmp/openclaw-corrupt-plugin-registry.XXXXXX")"
|
||||
pack_fixture_plugin "$npm_pack_dir" /tmp/demo-corrupt-plugin.tgz demo-corrupt-plugin 0.0.1 demo.corrupt "Demo Corrupt Plugin"
|
||||
start_npm_fixture_registry "@openclaw/demo-corrupt-plugin" "0.0.1" /tmp/demo-corrupt-plugin.tgz "$npm_registry_dir"
|
||||
|
||||
echo "Installing managed external plugin..."
|
||||
node "$entry" plugins install "npm:@openclaw/demo-corrupt-plugin@0.0.1" >/tmp/openclaw-corrupt-plugin-install.log 2>&1
|
||||
node "$entry" plugins inspect demo-corrupt-plugin --runtime --json >/tmp/openclaw-corrupt-plugin-before.json
|
||||
unset NPM_CONFIG_REGISTRY npm_config_registry
|
||||
|
||||
plugin_dir="$(
|
||||
node -e '
|
||||
const fs = require("node:fs");
|
||||
const payload = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
|
||||
const installPath = payload.install?.installPath ?? payload.plugin?.rootDir;
|
||||
if (!installPath) {
|
||||
throw new Error("missing plugin install path in inspect output");
|
||||
}
|
||||
process.stdout.write(installPath);
|
||||
' /tmp/openclaw-corrupt-plugin-before.json
|
||||
)"
|
||||
rm -f "$plugin_dir/package.json"
|
||||
if [ -f "$plugin_dir/package.json" ]; then
|
||||
echo "Expected corrupt plugin package.json to be removed before update." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Updating OpenClaw with corrupt plugin present..."
|
||||
set +e
|
||||
node "$entry" update --channel beta --tag "${OPENCLAW_CURRENT_PACKAGE_TGZ:?missing OPENCLAW_CURRENT_PACKAGE_TGZ}" --yes --no-restart --json >/tmp/openclaw-update-corrupt-plugin.json 2>/tmp/openclaw-update-corrupt-plugin.err
|
||||
update_status=$?
|
||||
set -e
|
||||
if [ "$update_status" -ne 0 ]; then
|
||||
if ! node scripts/e2e/lib/plugin-update/probe.mjs assert-legacy-post-update-plugin-failure /tmp/openclaw-update-corrupt-plugin.json; then
|
||||
echo "openclaw update failed with corrupt plugin present" >&2
|
||||
cat /tmp/openclaw-update-corrupt-plugin.err >&2 || true
|
||||
cat /tmp/openclaw-update-corrupt-plugin.json >&2 || true
|
||||
exit "$update_status"
|
||||
fi
|
||||
echo "Legacy updater reported post-update plugin failure after installing the new core; verifying updated entrypoint..."
|
||||
set +e
|
||||
OPENCLAW_UPDATE_POST_CORE=1 \
|
||||
OPENCLAW_UPDATE_POST_CORE_CHANNEL=beta \
|
||||
OPENCLAW_UPDATE_POST_CORE_RESULT_PATH=/tmp/openclaw-update-corrupt-plugin-post-core.json \
|
||||
node "$entry" update --yes --no-restart --json >/tmp/openclaw-update-corrupt-plugin-post-core.stdout 2>/tmp/openclaw-update-corrupt-plugin-post-core.err
|
||||
post_core_status=$?
|
||||
set -e
|
||||
if [ "$post_core_status" -ne 0 ]; then
|
||||
echo "updated OpenClaw entry failed post-core plugin verification" >&2
|
||||
cat /tmp/openclaw-update-corrupt-plugin-post-core.err >&2 || true
|
||||
cat /tmp/openclaw-update-corrupt-plugin-post-core.stdout >&2 || true
|
||||
cat /tmp/openclaw-update-corrupt-plugin-post-core.json >&2 || true
|
||||
exit "$post_core_status"
|
||||
fi
|
||||
node scripts/e2e/lib/plugin-update/probe.mjs assert-corrupt-plugin-result /tmp/openclaw-update-corrupt-plugin-post-core.json demo-corrupt-plugin
|
||||
exit 0
|
||||
fi
|
||||
|
||||
node scripts/e2e/lib/plugin-update/probe.mjs assert-corrupt-update /tmp/openclaw-update-corrupt-plugin.json demo-corrupt-plugin
|
||||
@@ -112,7 +112,79 @@ function assertOutput(logPath) {
|
||||
}
|
||||
}
|
||||
|
||||
const [command, arg] = process.argv.slice(2);
|
||||
function assertCorruptUpdate(updateJsonPath, pluginId) {
|
||||
const payload = readJson(updateJsonPath);
|
||||
if (payload.status !== "ok") {
|
||||
throw new Error(`expected core update status ok, got ${JSON.stringify(payload.status)}`);
|
||||
}
|
||||
const plugins = payload.postUpdate?.plugins;
|
||||
if (!plugins) {
|
||||
throw new Error(`missing postUpdate.plugins in update output: ${JSON.stringify(payload)}`);
|
||||
}
|
||||
if (plugins.status !== "warning") {
|
||||
throw new Error(
|
||||
`expected post-update plugin status warning, got ${JSON.stringify(plugins.status)}`,
|
||||
);
|
||||
}
|
||||
assertCorruptPluginDetails(plugins, pluginId);
|
||||
}
|
||||
|
||||
function assertCorruptPluginResult(pluginJsonPath, pluginId) {
|
||||
const plugins = readJson(pluginJsonPath);
|
||||
if (plugins.status !== "warning") {
|
||||
throw new Error(
|
||||
`expected post-update plugin status warning, got ${JSON.stringify(plugins.status)}`,
|
||||
);
|
||||
}
|
||||
assertCorruptPluginDetails(plugins, pluginId);
|
||||
}
|
||||
|
||||
function assertCorruptPluginDetails(plugins, pluginId) {
|
||||
const outcomes = plugins.npm?.outcomes ?? [];
|
||||
const outcome = outcomes.find((entry) => entry?.pluginId === pluginId);
|
||||
if (!outcome || outcome.status !== "error") {
|
||||
throw new Error(
|
||||
`expected error outcome for ${pluginId}, got ${JSON.stringify({
|
||||
outcomes,
|
||||
warnings: plugins.warnings ?? [],
|
||||
sync: plugins.sync,
|
||||
integrityDrifts: plugins.integrityDrifts ?? [],
|
||||
})}`,
|
||||
);
|
||||
}
|
||||
const warnings = plugins.warnings ?? [];
|
||||
const warning = warnings.find((entry) => entry?.pluginId === pluginId);
|
||||
if (!warning) {
|
||||
throw new Error(`expected warning for ${pluginId}, got ${JSON.stringify(warnings)}`);
|
||||
}
|
||||
const text = JSON.stringify({ outcome, warning });
|
||||
for (const expected of [
|
||||
"package.json is missing",
|
||||
"Run openclaw doctor --fix to attempt automatic repair.",
|
||||
`Run openclaw plugins inspect ${pluginId} --runtime --json for details.`,
|
||||
]) {
|
||||
if (!text.includes(expected)) {
|
||||
throw new Error(`expected update output to include ${expected}: ${text}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertLegacyPostUpdatePluginFailure(updateJsonPath) {
|
||||
const payload = readJson(updateJsonPath);
|
||||
if (payload.status !== "error" || payload.reason !== "post-update-plugins") {
|
||||
throw new Error(
|
||||
`expected legacy post-update plugin failure, got ${JSON.stringify({
|
||||
status: payload.status,
|
||||
reason: payload.reason,
|
||||
})}`,
|
||||
);
|
||||
}
|
||||
if (!payload.after?.version) {
|
||||
throw new Error(`expected core update to install a new version: ${JSON.stringify(payload)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const [command, arg, arg2] = process.argv.slice(2);
|
||||
const commands = {
|
||||
"legacy-compat": () => console.log(legacyPackageAcceptanceCompat(arg || "") ? "1" : "0"),
|
||||
seed: seedInstallState,
|
||||
@@ -120,6 +192,9 @@ const commands = {
|
||||
snapshot: () => process.stdout.write(JSON.stringify(pluginRecordSnapshot(), null, 2)),
|
||||
"assert-snapshot": () => assertSnapshot(arg),
|
||||
"assert-output": () => assertOutput(arg),
|
||||
"assert-corrupt-update": () => assertCorruptUpdate(arg, arg2),
|
||||
"assert-corrupt-plugin-result": () => assertCorruptPluginResult(arg, arg2),
|
||||
"assert-legacy-post-update-plugin-failure": () => assertLegacyPostUpdatePluginFailure(arg),
|
||||
};
|
||||
const run = commands[command];
|
||||
await (
|
||||
|
||||
30
scripts/e2e/update-corrupt-plugin-docker.sh
Normal file
30
scripts/e2e/update-corrupt-plugin-docker.sh
Normal file
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
# Verifies `openclaw update` succeeds when a managed external plugin is corrupt.
|
||||
# The lane installs an older published OpenClaw package, corrupts an npm-managed
|
||||
# plugin payload, then updates to the prepared package artifact.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
|
||||
source "$ROOT_DIR/scripts/lib/docker-e2e-package.sh"
|
||||
|
||||
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-update-corrupt-plugin-e2e" OPENCLAW_UPDATE_CORRUPT_PLUGIN_E2E_IMAGE)"
|
||||
SKIP_BUILD="${OPENCLAW_UPDATE_CORRUPT_PLUGIN_E2E_SKIP_BUILD:-0}"
|
||||
PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz update-corrupt-plugin "${OPENCLAW_CURRENT_PACKAGE_TGZ:-}")"
|
||||
# Bare lanes mount the package artifact instead of baking app sources into the image.
|
||||
docker_e2e_package_mount_args "$PACKAGE_TGZ"
|
||||
|
||||
docker_e2e_build_or_reuse "$IMAGE_NAME" update-corrupt-plugin "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "bare" "$SKIP_BUILD"
|
||||
OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 update-corrupt-plugin empty)"
|
||||
|
||||
echo "Running corrupt plugin update tolerance E2E..."
|
||||
docker_e2e_run_with_harness \
|
||||
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
-e OPENCLAW_SKIP_CHANNELS=1 \
|
||||
-e OPENCLAW_SKIP_PROVIDERS=1 \
|
||||
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \
|
||||
"${DOCKER_E2E_PACKAGE_ARGS[@]}" \
|
||||
"$IMAGE_NAME" \
|
||||
bash scripts/e2e/lib/plugin-update/corrupt-update-scenario.sh
|
||||
|
||||
echo "Corrupt plugin update tolerance Docker E2E passed."
|
||||
@@ -273,6 +273,15 @@ export const mainLanes = [
|
||||
npmLane("plugin-update", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:plugin-update", {
|
||||
stateScenario: "empty",
|
||||
}),
|
||||
npmLane(
|
||||
"update-corrupt-plugin",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:update-corrupt-plugin",
|
||||
{
|
||||
stateScenario: "empty",
|
||||
timeoutMs: 30 * 60 * 1000,
|
||||
weight: 3,
|
||||
},
|
||||
),
|
||||
npmLane(
|
||||
"plugin-lifecycle-matrix",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:plugin-lifecycle-matrix",
|
||||
|
||||
@@ -979,7 +979,7 @@ describe("update-cli", () => {
|
||||
expect(logs.join("\n")).toContain("Plugin update aborted");
|
||||
});
|
||||
|
||||
it("fails json update output when post-core plugin updates fail", async () => {
|
||||
it("keeps json update output successful when post-core plugin updates warn", async () => {
|
||||
updateNpmInstalledPlugins.mockImplementationOnce(
|
||||
async (params: {
|
||||
config: OpenClawConfig;
|
||||
@@ -1025,9 +1025,9 @@ describe("update-cli", () => {
|
||||
const jsonOutput = vi.mocked(defaultRuntime.writeJson).mock.calls.at(-1)?.[0] as
|
||||
| UpdateRunResult
|
||||
| undefined;
|
||||
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
|
||||
expect(jsonOutput?.status).toBe("error");
|
||||
expect(jsonOutput?.reason).toBe("post-update-plugins");
|
||||
expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1);
|
||||
expect(jsonOutput?.status).toBe("ok");
|
||||
expect(jsonOutput?.reason).toBeUndefined();
|
||||
expect(jsonOutput?.postUpdate?.plugins?.integrityDrifts).toEqual([
|
||||
{
|
||||
pluginId: "demo",
|
||||
@@ -1039,11 +1039,88 @@ describe("update-cli", () => {
|
||||
action: "aborted",
|
||||
},
|
||||
]);
|
||||
expect(jsonOutput?.postUpdate?.plugins?.status).toBe("error");
|
||||
expect(jsonOutput?.postUpdate?.plugins?.status).toBe("warning");
|
||||
expect(jsonOutput?.postUpdate?.plugins?.warnings?.[0]).toMatchObject({
|
||||
pluginId: "demo",
|
||||
guidance: [
|
||||
"Run openclaw doctor --fix to attempt automatic repair.",
|
||||
"Run openclaw plugins inspect demo --runtime --json for details.",
|
||||
],
|
||||
});
|
||||
expect(jsonOutput?.postUpdate?.plugins?.warnings?.[0]?.reason).toContain(
|
||||
"npm package integrity drift",
|
||||
);
|
||||
expect(jsonOutput?.postUpdate?.plugins?.npm.outcomes[0]?.status).toBe("error");
|
||||
expect(jsonOutput?.postUpdate?.plugins?.npm.outcomes[0]?.message).toContain(
|
||||
"Run openclaw doctor --fix to attempt automatic repair.",
|
||||
);
|
||||
expect(jsonOutput?.postUpdate?.plugins?.npm.outcomes[0]?.message).toContain(
|
||||
"Run openclaw plugins inspect demo --runtime --json for details.",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails before restart when post-core plugin updates fail", async () => {
|
||||
it("detects missing plugin payloads from persisted records before npm updates", async () => {
|
||||
const installPath = createCaseDir("openclaw-missing-plugin-payload");
|
||||
fsSync.mkdirSync(installPath, { recursive: true });
|
||||
const config = {
|
||||
plugins: {
|
||||
entries: {
|
||||
demo: { enabled: true },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
vi.mocked(readConfigFileSnapshot).mockResolvedValue({
|
||||
...baseSnapshot,
|
||||
parsed: config,
|
||||
resolved: config,
|
||||
sourceConfig: config,
|
||||
config,
|
||||
runtimeConfig: config,
|
||||
});
|
||||
loadInstalledPluginIndexInstallRecords.mockResolvedValueOnce({
|
||||
demo: {
|
||||
source: "npm",
|
||||
spec: "@openclaw/demo@1.0.0",
|
||||
installPath,
|
||||
},
|
||||
});
|
||||
syncPluginsForUpdateChannel.mockResolvedValueOnce({
|
||||
changed: false,
|
||||
config,
|
||||
summary: {
|
||||
switchedToBundled: [],
|
||||
switchedToNpm: [],
|
||||
warnings: [],
|
||||
errors: [],
|
||||
},
|
||||
});
|
||||
pathExists.mockImplementation(async (candidate: string) => candidate === installPath);
|
||||
vi.mocked(defaultRuntime.writeJson).mockClear();
|
||||
|
||||
await updateCommand({ json: true, restart: false });
|
||||
|
||||
const updateCall = updateNpmInstalledPlugins.mock.calls.at(-1)?.[0] as
|
||||
| { skipIds?: Set<string> }
|
||||
| undefined;
|
||||
expect(updateCall?.skipIds?.has("demo")).toBe(true);
|
||||
const jsonOutput = vi.mocked(defaultRuntime.writeJson).mock.calls.at(-1)?.[0] as
|
||||
| UpdateRunResult
|
||||
| undefined;
|
||||
expect(jsonOutput?.status).toBe("ok");
|
||||
expect(jsonOutput?.postUpdate?.plugins?.status).toBe("warning");
|
||||
expect(jsonOutput?.postUpdate?.plugins?.warnings?.[0]).toMatchObject({
|
||||
pluginId: "demo",
|
||||
});
|
||||
expect(jsonOutput?.postUpdate?.plugins?.warnings?.[0]?.reason).toContain(
|
||||
"package.json is missing",
|
||||
);
|
||||
expect(jsonOutput?.postUpdate?.plugins?.npm.outcomes[0]).toMatchObject({
|
||||
pluginId: "demo",
|
||||
status: "error",
|
||||
});
|
||||
});
|
||||
|
||||
it("prints non-fatal plugin warnings in human update output", async () => {
|
||||
updateNpmInstalledPlugins.mockResolvedValueOnce({
|
||||
changed: false,
|
||||
config: baseConfig,
|
||||
@@ -1055,11 +1132,10 @@ describe("update-cli", () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
serviceLoaded.mockResolvedValue(true);
|
||||
|
||||
await updateCommand({ yes: true });
|
||||
await updateCommand({ yes: true, restart: false });
|
||||
|
||||
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
|
||||
expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1);
|
||||
expect(runDaemonInstall).not.toHaveBeenCalled();
|
||||
expect(runDaemonRestart).not.toHaveBeenCalled();
|
||||
expect(runRestartScript).not.toHaveBeenCalled();
|
||||
@@ -1068,10 +1144,33 @@ describe("update-cli", () => {
|
||||
.mocked(defaultRuntime.error)
|
||||
.mock.calls.map((call) => String(call[0]))
|
||||
.join("\n"),
|
||||
).toContain("Update failed during plugin post-update sync.");
|
||||
).not.toContain("Update failed during plugin post-update sync.");
|
||||
const logs = vi
|
||||
.mocked(defaultRuntime.log)
|
||||
.mock.calls.map((call) => String(call[0]))
|
||||
.join("\n");
|
||||
expect(logs).toContain("Failed to update demo: registry timeout");
|
||||
expect(logs).toContain("Run openclaw doctor --fix to attempt automatic repair.");
|
||||
expect(logs).toContain("Run openclaw plugins inspect demo --runtime --json for details.");
|
||||
});
|
||||
|
||||
it("preserves fresh-process plugin failure details in parent json output", async () => {
|
||||
it("fails unexpected post-core plugin sync exceptions", async () => {
|
||||
syncPluginsForUpdateChannel.mockRejectedValueOnce(new Error("plugin sync invariant broke"));
|
||||
|
||||
await expect(updateCommand({ json: true, restart: false })).rejects.toThrow(
|
||||
"plugin sync invariant broke",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails unexpected post-core npm update exceptions", async () => {
|
||||
updateNpmInstalledPlugins.mockRejectedValueOnce(new Error("npm update invariant broke"));
|
||||
|
||||
await expect(updateCommand({ json: true, restart: false })).rejects.toThrow(
|
||||
"npm update invariant broke",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves fresh-process plugin warning details in parent json output", async () => {
|
||||
setupUpdatedRootRefresh();
|
||||
spawn.mockImplementationOnce((_node, _argv, options) => {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
@@ -1084,8 +1183,20 @@ describe("update-cli", () => {
|
||||
await fs.writeFile(
|
||||
resultPath,
|
||||
JSON.stringify({
|
||||
status: "error",
|
||||
status: "warning",
|
||||
changed: false,
|
||||
warnings: [
|
||||
{
|
||||
pluginId: "demo",
|
||||
reason: "Failed to update demo: registry timeout",
|
||||
message:
|
||||
'Plugin "demo" could not be processed after the core update: Failed to update demo: registry timeout Run openclaw doctor --fix to attempt automatic repair. Run openclaw plugins inspect demo --runtime --json for details.',
|
||||
guidance: [
|
||||
"Run openclaw doctor --fix to attempt automatic repair.",
|
||||
"Run openclaw plugins inspect demo --runtime --json for details.",
|
||||
],
|
||||
},
|
||||
],
|
||||
sync: {
|
||||
changed: false,
|
||||
switchedToBundled: [],
|
||||
@@ -1108,7 +1219,7 @@ describe("update-cli", () => {
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
child.emit("exit", 1, null);
|
||||
child.emit("exit", 0, null);
|
||||
});
|
||||
return child;
|
||||
});
|
||||
@@ -1119,9 +1230,12 @@ describe("update-cli", () => {
|
||||
const jsonOutput = vi.mocked(defaultRuntime.writeJson).mock.calls.at(-1)?.[0] as
|
||||
| UpdateRunResult
|
||||
| undefined;
|
||||
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
|
||||
expect(jsonOutput?.status).toBe("error");
|
||||
expect(jsonOutput?.reason).toBe("post-update-plugins");
|
||||
expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1);
|
||||
expect(jsonOutput?.status).toBe("ok");
|
||||
expect(jsonOutput?.reason).toBeUndefined();
|
||||
expect(jsonOutput?.postUpdate?.plugins?.warnings?.[0]?.guidance).toContain(
|
||||
"Run openclaw doctor --fix to attempt automatic repair.",
|
||||
);
|
||||
expect(jsonOutput?.postUpdate?.plugins?.npm.outcomes[0]?.message).toContain("registry timeout");
|
||||
});
|
||||
|
||||
|
||||
@@ -123,6 +123,7 @@ const POST_INSTALL_DOCTOR_SERVICE_ENV_KEYS = [
|
||||
...SERVICE_REFRESH_PATH_ENV_KEYS,
|
||||
"OPENCLAW_PROFILE",
|
||||
] as const;
|
||||
const POST_UPDATE_PLUGIN_REPAIR_GUIDANCE = "Run openclaw doctor --fix to attempt automatic repair.";
|
||||
|
||||
const UPDATE_QUIPS = [
|
||||
"Leveled up! New skills unlocked. You're welcome.",
|
||||
@@ -157,6 +158,8 @@ type MissingPluginInstallPayload = {
|
||||
reason: "missing-install-path" | "missing-package-dir" | "missing-package-json";
|
||||
};
|
||||
|
||||
type PostUpdatePluginWarning = NonNullable<PostCorePluginUpdateResult["warnings"]>[number];
|
||||
|
||||
function pickUpdateQuip(): string {
|
||||
return UPDATE_QUIPS[Math.floor(Math.random() * UPDATE_QUIPS.length)] ?? "Update complete.";
|
||||
}
|
||||
@@ -240,6 +243,49 @@ function formatMissingPluginPayloadReason(entry: MissingPluginInstallPayload): s
|
||||
return `package directory is missing: ${entry.installPath}`;
|
||||
}
|
||||
|
||||
function formatPostUpdatePluginInspectGuidance(pluginId: string): string {
|
||||
return `Run openclaw plugins inspect ${pluginId} --runtime --json for details.`;
|
||||
}
|
||||
|
||||
function createPostUpdatePluginWarning(params: {
|
||||
pluginId?: string;
|
||||
reason: string;
|
||||
}): PostUpdatePluginWarning {
|
||||
const reason = params.reason.trim() || "unknown plugin post-update failure";
|
||||
const guidance = [
|
||||
POST_UPDATE_PLUGIN_REPAIR_GUIDANCE,
|
||||
...(params.pluginId ? [formatPostUpdatePluginInspectGuidance(params.pluginId)] : []),
|
||||
];
|
||||
return {
|
||||
...(params.pluginId ? { pluginId: params.pluginId } : {}),
|
||||
reason,
|
||||
message: params.pluginId
|
||||
? `Plugin "${params.pluginId}" could not be processed after the core update: ${reason} ${guidance.join(" ")}`
|
||||
: `Plugin post-update processing could not complete after the core update: ${reason} ${guidance.join(" ")}`,
|
||||
guidance,
|
||||
};
|
||||
}
|
||||
|
||||
function createGuidedPostUpdatePluginOutcome(outcome: PluginUpdateOutcome): {
|
||||
outcome: PluginUpdateOutcome;
|
||||
warning?: PostUpdatePluginWarning;
|
||||
} {
|
||||
if (outcome.status !== "error") {
|
||||
return { outcome };
|
||||
}
|
||||
const warning = createPostUpdatePluginWarning({
|
||||
...(outcome.pluginId && outcome.pluginId !== "unknown" ? { pluginId: outcome.pluginId } : {}),
|
||||
reason: outcome.message,
|
||||
});
|
||||
return {
|
||||
outcome: {
|
||||
...outcome,
|
||||
message: warning.message,
|
||||
},
|
||||
warning,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldPrepareUpdatedInstallRestart(params: {
|
||||
updateMode: UpdateRunResult["mode"];
|
||||
serviceInstalled: boolean;
|
||||
@@ -1050,6 +1096,7 @@ async function updatePluginsAfterCoreUpdate(params: {
|
||||
outcomes: [],
|
||||
},
|
||||
integrityDrifts: [],
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1066,9 +1113,14 @@ async function updatePluginsAfterCoreUpdate(params: {
|
||||
defaultRuntime.log(theme.heading("Updating plugins..."));
|
||||
}
|
||||
|
||||
const warnings: PostUpdatePluginWarning[] = [];
|
||||
const pluginInstallRecords = await loadInstalledPluginIndexInstallRecords();
|
||||
const syncConfig = withPluginInstallRecords(
|
||||
params.configSnapshot.sourceConfig,
|
||||
pluginInstallRecords,
|
||||
);
|
||||
const syncResult = await syncPluginsForUpdateChannel({
|
||||
config: withPluginInstallRecords(params.configSnapshot.sourceConfig, pluginInstallRecords),
|
||||
config: syncConfig,
|
||||
channel: params.channel,
|
||||
workspaceDir: params.root,
|
||||
externalizedBundledPluginBridges: await listPersistedBundledPluginLocationBridges({
|
||||
@@ -1076,6 +1128,9 @@ async function updatePluginsAfterCoreUpdate(params: {
|
||||
}),
|
||||
logger: pluginLogger,
|
||||
});
|
||||
for (const error of syncResult.summary.errors) {
|
||||
warnings.push(createPostUpdatePluginWarning({ reason: error }));
|
||||
}
|
||||
let pluginConfig = syncResult.config;
|
||||
const integrityDrifts: PostCorePluginUpdateResult["integrityDrifts"] = [];
|
||||
const pluginUpdateOutcomes: PluginUpdateOutcome[] = [];
|
||||
@@ -1106,7 +1161,7 @@ async function updatePluginsAfterCoreUpdate(params: {
|
||||
return false;
|
||||
};
|
||||
|
||||
const repairMissingPayloads = async (
|
||||
const collectMissingPayloadWarnings = async (
|
||||
records: Record<string, PluginInstallRecord>,
|
||||
): Promise<readonly string[]> => {
|
||||
const missing = await collectMissingPluginInstallPayloads({
|
||||
@@ -1118,50 +1173,45 @@ async function updatePluginsAfterCoreUpdate(params: {
|
||||
return [];
|
||||
}
|
||||
const missingIds = missing.map((entry) => entry.pluginId);
|
||||
if (!params.opts.json) {
|
||||
defaultRuntime.log(
|
||||
theme.warn(
|
||||
`Recovering missing plugin install payloads: ${missing
|
||||
.map((entry) => `${entry.pluginId} (${formatMissingPluginPayloadReason(entry)})`)
|
||||
.join(", ")}.`,
|
||||
),
|
||||
);
|
||||
for (const entry of missing) {
|
||||
const warning = createPostUpdatePluginWarning({
|
||||
pluginId: entry.pluginId,
|
||||
reason: `Plugin install payload missing after update: ${formatMissingPluginPayloadReason(entry)}.`,
|
||||
});
|
||||
warnings.push(warning);
|
||||
pluginUpdateOutcomes.push({
|
||||
pluginId: entry.pluginId,
|
||||
status: "error",
|
||||
message: warning.message,
|
||||
});
|
||||
if (!params.opts.json) {
|
||||
defaultRuntime.log(theme.warn(warning.message));
|
||||
}
|
||||
}
|
||||
const repairResult = await updateNpmInstalledPlugins({
|
||||
config: pluginConfig,
|
||||
pluginIds: missingIds,
|
||||
timeoutMs: params.timeoutMs,
|
||||
updateChannel: params.channel,
|
||||
skipDisabledPlugins: true,
|
||||
disableOnFailure: true,
|
||||
logger: pluginLogger,
|
||||
onIntegrityDrift: onPluginIntegrityDrift,
|
||||
});
|
||||
pluginConfig = repairResult.config;
|
||||
pluginsChanged ||= repairResult.changed;
|
||||
npmPluginsChanged ||= repairResult.changed;
|
||||
pluginUpdateOutcomes.push(...repairResult.outcomes);
|
||||
return missingIds;
|
||||
};
|
||||
|
||||
const repairedMissingPayloadIds = await repairMissingPayloads(
|
||||
pluginConfig.plugins?.installs ?? {},
|
||||
);
|
||||
const missingPayloadIds = await collectMissingPayloadWarnings(pluginInstallRecords);
|
||||
|
||||
const npmResult = await updateNpmInstalledPlugins({
|
||||
config: pluginConfig,
|
||||
timeoutMs: params.timeoutMs,
|
||||
updateChannel: params.channel,
|
||||
skipIds: new Set([...syncResult.summary.switchedToNpm, ...repairedMissingPayloadIds]),
|
||||
skipIds: new Set([...syncResult.summary.switchedToNpm, ...missingPayloadIds]),
|
||||
skipDisabledPlugins: true,
|
||||
disableOnFailure: true,
|
||||
logger: pluginLogger,
|
||||
onIntegrityDrift: onPluginIntegrityDrift,
|
||||
});
|
||||
pluginConfig = npmResult.config;
|
||||
pluginsChanged ||= npmResult.changed;
|
||||
npmPluginsChanged ||= npmResult.changed;
|
||||
pluginUpdateOutcomes.push(...npmResult.outcomes);
|
||||
for (const rawOutcome of npmResult.outcomes) {
|
||||
const guided = createGuidedPostUpdatePluginOutcome(rawOutcome);
|
||||
pluginUpdateOutcomes.push(guided.outcome);
|
||||
if (guided.warning) {
|
||||
warnings.push(guided.warning);
|
||||
}
|
||||
}
|
||||
|
||||
const remainingMissingPayloads = await collectMissingPluginInstallPayloads({
|
||||
records: pluginConfig.plugins?.installs ?? {},
|
||||
@@ -1169,13 +1219,20 @@ async function updatePluginsAfterCoreUpdate(params: {
|
||||
skipDisabledPlugins: true,
|
||||
});
|
||||
pluginUpdateOutcomes.push(
|
||||
...remainingMissingPayloads.map(
|
||||
(entry): PluginUpdateOutcome => ({
|
||||
pluginId: entry.pluginId,
|
||||
status: "error",
|
||||
message: `Plugin install payload missing after update: ${formatMissingPluginPayloadReason(entry)}.`,
|
||||
...remainingMissingPayloads
|
||||
.filter((entry) => !missingPayloadIds.includes(entry.pluginId))
|
||||
.map((entry): PluginUpdateOutcome => {
|
||||
const warning = createPostUpdatePluginWarning({
|
||||
pluginId: entry.pluginId,
|
||||
reason: `Plugin install payload missing after update: ${formatMissingPluginPayloadReason(entry)}.`,
|
||||
});
|
||||
warnings.push(warning);
|
||||
return {
|
||||
pluginId: entry.pluginId,
|
||||
status: "error",
|
||||
message: warning.message,
|
||||
};
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
if (pluginsChanged) {
|
||||
@@ -1198,12 +1255,9 @@ async function updatePluginsAfterCoreUpdate(params: {
|
||||
|
||||
if (params.opts.json) {
|
||||
return {
|
||||
status:
|
||||
syncResult.summary.errors.length > 0 ||
|
||||
pluginUpdateOutcomes.some((outcome) => outcome.status === "error")
|
||||
? "error"
|
||||
: "ok",
|
||||
status: warnings.length > 0 ? "warning" : "ok",
|
||||
changed: pluginsChanged,
|
||||
warnings,
|
||||
sync: {
|
||||
changed: syncResult.changed,
|
||||
switchedToBundled: syncResult.summary.switchedToBundled,
|
||||
@@ -1242,7 +1296,7 @@ async function updatePluginsAfterCoreUpdate(params: {
|
||||
defaultRuntime.log(theme.warn(warning));
|
||||
}
|
||||
for (const error of syncResult.summary.errors) {
|
||||
defaultRuntime.log(theme.error(error));
|
||||
defaultRuntime.log(theme.warn(createPostUpdatePluginWarning({ reason: error }).message));
|
||||
}
|
||||
|
||||
const updated = pluginUpdateOutcomes.filter((entry) => entry.status === "updated").length;
|
||||
@@ -1267,16 +1321,13 @@ async function updatePluginsAfterCoreUpdate(params: {
|
||||
if (outcome.status !== "error") {
|
||||
continue;
|
||||
}
|
||||
defaultRuntime.log(theme.error(outcome.message));
|
||||
defaultRuntime.log(theme.warn(outcome.message));
|
||||
}
|
||||
|
||||
return {
|
||||
status:
|
||||
syncResult.summary.errors.length > 0 ||
|
||||
pluginUpdateOutcomes.some((outcome) => outcome.status === "error")
|
||||
? "error"
|
||||
: "ok",
|
||||
status: warnings.length > 0 ? "warning" : "ok",
|
||||
changed: pluginsChanged,
|
||||
warnings,
|
||||
sync: {
|
||||
changed: syncResult.changed,
|
||||
switchedToBundled: syncResult.summary.switchedToBundled,
|
||||
@@ -1626,7 +1677,10 @@ async function readPostCorePluginUpdateResultFile(
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
(parsed.status === "ok" || parsed.status === "skipped" || parsed.status === "error")
|
||||
(parsed.status === "ok" ||
|
||||
parsed.status === "warning" ||
|
||||
parsed.status === "skipped" ||
|
||||
parsed.status === "error")
|
||||
) {
|
||||
return parsed;
|
||||
}
|
||||
@@ -1854,10 +1908,6 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
||||
defaultRuntime.writeJson(result);
|
||||
}
|
||||
}
|
||||
if (pluginUpdate.status === "error") {
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
defaultRuntime.exit(0);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -907,14 +907,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
|
||||
expect(mocks.updateNpmInstalledPlugins).not.toHaveBeenCalled();
|
||||
expect(mocks.installPluginFromClawHub).not.toHaveBeenCalled();
|
||||
expect(mocks.installPluginFromNpmSpec).not.toHaveBeenCalled();
|
||||
expect(mocks.writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(
|
||||
{},
|
||||
{
|
||||
env: {
|
||||
OPENCLAW_UPDATE_IN_PROGRESS: "1",
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(mocks.writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
changes: [
|
||||
'Skipped package-manager repair for configured plugin "discord" during package update; rerun "openclaw doctor --fix" after the update completes.',
|
||||
@@ -959,14 +952,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
|
||||
expect(mocks.updateNpmInstalledPlugins).not.toHaveBeenCalled();
|
||||
expect(mocks.installPluginFromClawHub).not.toHaveBeenCalled();
|
||||
expect(mocks.installPluginFromNpmSpec).not.toHaveBeenCalled();
|
||||
expect(mocks.writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(
|
||||
{},
|
||||
{
|
||||
env: {
|
||||
OPENCLAW_UPDATE_IN_PROGRESS: "1",
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(mocks.writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
changes: [
|
||||
'Skipped package-manager repair for configured plugin "discord" during package update; rerun "openclaw doctor --fix" after the update completes.',
|
||||
|
||||
@@ -698,10 +698,6 @@ async function repairMissingPluginInstalls(params: {
|
||||
if (!record || !isInstalledRecordMissingOnDisk(record, env)) {
|
||||
continue;
|
||||
}
|
||||
if (nextRecords === records) {
|
||||
nextRecords = { ...records };
|
||||
}
|
||||
delete nextRecords[pluginId];
|
||||
changes.push(
|
||||
`Skipped package-manager repair for configured plugin "${pluginId}" during package update; rerun "openclaw doctor --fix" after the update completes.`,
|
||||
);
|
||||
@@ -710,6 +706,7 @@ async function repairMissingPluginInstalls(params: {
|
||||
|
||||
const missingRecordedPluginIds = Object.keys(records).filter(
|
||||
(pluginId) =>
|
||||
!deferredPluginIds.has(pluginId) &&
|
||||
Object.hasOwn(nextRecords, pluginId) &&
|
||||
!bundledPluginsById.has(pluginId) &&
|
||||
((params.pluginIds.has(pluginId) &&
|
||||
|
||||
@@ -58,9 +58,15 @@ export type UpdateRunResult = {
|
||||
durationMs: number;
|
||||
postUpdate?: {
|
||||
plugins?: {
|
||||
status: "ok" | "skipped" | "error";
|
||||
status: "ok" | "warning" | "skipped" | "error";
|
||||
reason?: string;
|
||||
changed: boolean;
|
||||
warnings?: Array<{
|
||||
pluginId?: string;
|
||||
reason: string;
|
||||
message: string;
|
||||
guidance: string[];
|
||||
}>;
|
||||
sync: {
|
||||
changed: boolean;
|
||||
switchedToBundled: string[];
|
||||
|
||||
@@ -141,12 +141,15 @@ function mergeRecoveredManagedNpmInstallRecords(
|
||||
function extractPluginInstallRecordsFromPersistedInstalledPluginIndex(
|
||||
index: unknown,
|
||||
): Record<string, PluginInstallRecord> | null {
|
||||
if (!isRecord(index) || !Array.isArray(index.plugins)) {
|
||||
if (!isRecord(index)) {
|
||||
return null;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(index, "installRecords")) {
|
||||
return readRecordMap(index.installRecords) ?? {};
|
||||
}
|
||||
if (!Array.isArray(index.plugins)) {
|
||||
return null;
|
||||
}
|
||||
const records: Record<string, PluginInstallRecord> = {};
|
||||
for (const entry of index.plugins) {
|
||||
if (!isRecord(entry) || typeof entry.pluginId !== "string" || !isRecord(entry.installRecord)) {
|
||||
|
||||
@@ -219,6 +219,33 @@ describe("plugin index install records store", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reads legacy persisted records when the plugin index has no plugin list", async () => {
|
||||
const stateDir = makeStateDir();
|
||||
const indexPath = resolveInstalledPluginIndexRecordsStorePath({ stateDir });
|
||||
fs.mkdirSync(path.dirname(indexPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
indexPath,
|
||||
JSON.stringify({
|
||||
installRecords: {
|
||||
legacy: {
|
||||
source: "npm",
|
||||
spec: "legacy@1.0.0",
|
||||
installPath: path.join(stateDir, "plugins", "legacy"),
|
||||
},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await expect(loadInstalledPluginIndexInstallRecords({ stateDir })).resolves.toEqual({
|
||||
legacy: {
|
||||
source: "npm",
|
||||
spec: "legacy@1.0.0",
|
||||
installPath: path.join(stateDir, "plugins", "legacy"),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("recovers managed npm plugin records when the persisted ledger is empty", async () => {
|
||||
const stateDir = makeStateDir();
|
||||
const discordDir = writeManagedNpmPlugin({
|
||||
|
||||
@@ -927,7 +927,16 @@ export async function updateNpmInstalledPlugins(params: {
|
||||
recordFailure(pluginId, `Invalid install path for "${pluginId}": ${String(err)}`);
|
||||
continue;
|
||||
}
|
||||
const currentVersion = await readInstalledPackageVersion(installPath);
|
||||
let currentVersion: string | undefined;
|
||||
try {
|
||||
currentVersion = await readInstalledPackageVersion(installPath);
|
||||
} catch (err) {
|
||||
recordFailure(
|
||||
pluginId,
|
||||
`Failed to inspect installed package for ${pluginId}: ${String(err)}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const extensionsDir = resolveRecordedExtensionsDir({
|
||||
pluginId,
|
||||
installPath,
|
||||
|
||||
@@ -103,7 +103,7 @@ describe("package acceptance workflow", () => {
|
||||
expect(workflow).toContain('"all-since-"');
|
||||
expect(workflow).toContain("npm-onboard-channel-agent gateway-network config-reload");
|
||||
expect(workflow).toContain("npm-onboard-channel-agent doctor-switch");
|
||||
expect(workflow).toContain("update-channel-switch upgrade-survivor");
|
||||
expect(workflow).toContain("update-channel-switch update-corrupt-plugin upgrade-survivor");
|
||||
expect(workflow).toContain("published-upgrade-survivor");
|
||||
expect(workflow).toContain("published-upgrade-survivor update-restart-auth");
|
||||
expect(workflow).toContain("plugins-offline plugin-update");
|
||||
@@ -547,7 +547,7 @@ describe("package artifact reuse", () => {
|
||||
);
|
||||
expect(workflow).toContain("suite_profile: custom");
|
||||
expect(workflow).toContain(
|
||||
"docker_lanes: doctor-switch update-channel-switch upgrade-survivor published-upgrade-survivor update-restart-auth plugins-offline plugin-update",
|
||||
"docker_lanes: doctor-switch update-channel-switch update-corrupt-plugin upgrade-survivor published-upgrade-survivor update-restart-auth plugins-offline plugin-update",
|
||||
);
|
||||
expect(workflow).toContain(
|
||||
"published_upgrade_survivor_baselines: ${{ needs.resolve_target.outputs.run_release_soak == 'true' && 'last-stable-4 2026.4.23 2026.5.2 2026.4.15' || '' }}",
|
||||
|
||||
Reference in New Issue
Block a user