mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 17:53:39 +00:00
fix(postinstall): preserve other installs' runtime dependencies (#134099)
Keep package postinstall cleanup inside its installed package and remove Doctor's shared runtime dependency pruning. Follow actual filesystem targets when repairing dangling aliases, preserving dependencies and mirrors used by other installations or profiles. Retain lifecycle completion, package-local cleanup, and the shipped informational Doctor selector. Refresh survivor fixtures and documentation, remove redundant vision fixture coverage, and align the companion cleanup-phase guard. Use the canonical audit fix from current main after resolving the overlapping CI repair. Validation includes failing/passing owner regressions, focused owner and sibling suites, full package integrity checks, native before/fixed update-and-Doctor survivor comparisons, and independent P0-P2 reviews. Detailed final package, native, and CI evidence is in the PR body. Related to #128782; follows the separate Doctor-owned database migration repair in #129475, with prior contributions from @BryanTegomoh and @obviyus. Maintainer override: Peter explicitly waived the npm advisory-service outage for this landing. Exact-head CI passed every other selected lane; the audit returned only timeouts/HTTP 503 and no clearance. The failed checks remain recorded, and audit/repository policy is unchanged.
This commit is contained in:
parent
fa70549fe4
commit
f584661fac
33 changed files with 910 additions and 1704 deletions
|
|
@ -2508,7 +2508,6 @@ src/commands/doctor/shared/legacy-config-state-migration-input.ts 2
|
|||
src/commands/doctor/shared/legacy-talk-config-normalizer.ts 2
|
||||
src/commands/doctor/shared/legacy-web-tools-migrate.ts 1
|
||||
src/commands/doctor/shared/open-policy-allowfrom.ts 2
|
||||
src/commands/doctor/shared/plugin-dependency-cleanup.ts 1
|
||||
src/commands/doctor/shared/plugin-registry-migration.ts 1
|
||||
src/commands/doctor/shared/plugin-runtime-symlinks.ts 4
|
||||
src/commands/doctor/shared/plugin-tool-allowlist-warnings.ts 2
|
||||
|
|
|
|||
|
|
@ -3,4 +3,4 @@
|
|||
# Worker bundle installation no longer injects OPENCLAW_STATE_DIR into a remote npm process.
|
||||
# The internal Node-version check no longer contributes a false OPENCLAW_* name.
|
||||
# The next-turn runtime-context preface constant no longer exists as an OPENCLAW_* name.
|
||||
499
|
||||
498
|
||||
|
|
|
|||
|
|
@ -639,7 +639,7 @@ compare restored legacy artifacts with the SQLite rows before importing.
|
|||
- When HTTP(S) proxy environment variables are present but `tools.web.fetch.useTrustedEnvProxy` is disabled, doctor explains that `web_fetch` still uses direct routing, runs a short direct TLS connectivity probe, and names the explicit opt-in. It never enables proxy trust automatically.
|
||||
- Doctor rewrites legacy `codex/*` and `openai-codex/*` model refs to canonical `openai/*` refs across primary models, fallbacks, model allowlists, image/video generation models, heartbeat/subagent/compaction overrides, hooks, channel model overrides, cron payloads, and stale session/transcript route pins. `--fix` also merges legacy `models.providers.codex` and `models.providers.openai-codex` config when safe, migrates legacy `openai-codex:*` auth profiles and `auth.order.openai-codex` entries to `openai:*`, moves Codex intent onto provider/model-scoped `agentRuntime.id: "codex"` entries, removes stale whole-agent/session runtime pins, and keeps repaired OpenAI agent refs on Codex auth routing instead of direct OpenAI API-key auth.
|
||||
- Doctor reports nonempty `auth.order.<provider>` lists whose referenced profiles are all gone while compatible stored credentials exist. `doctor --fix` deletes only those stale overrides, restoring automatic per-agent credential selection; explicit empty orders, partially live lists, and orders without a compatible stored credential stay unchanged. If an active SQLite auth store is unreadable or malformed, doctor explains why it skipped this repair. Restart a running Gateway before rechecking auth status if its config reload mode does not apply the write automatically.
|
||||
- Doctor cleans legacy plugin dependency staging state from older OpenClaw versions and relinks the host `openclaw` package for managed npm plugins that declare it as a peer dependency. It also repairs missing downloadable plugins referenced by config (`plugins.entries`, configured channels, configured provider/search settings, configured agent runtimes). During package updates, doctor skips package-manager plugin repair until the package swap completes; rerun `openclaw doctor --fix` afterward if a configured plugin still needs recovery. If a download fails, doctor reports the install error and preserves the configured plugin entry for the next repair attempt.
|
||||
- Doctor preserves legacy shared plugin-runtime caches that another installation or profile may still use and removes only genuinely dangling plugin-runtime symlinks. It relinks the host `openclaw` package for managed npm plugins that declare it as a peer dependency. It also repairs missing downloadable plugins referenced by config (`plugins.entries`, configured channels, configured provider/search settings, configured agent runtimes). During package updates, doctor skips package-manager plugin repair until the package swap completes; rerun `openclaw doctor --fix` afterward if a configured plugin still needs recovery. If a download fails, doctor reports the install error and preserves the configured plugin entry for the next repair attempt.
|
||||
- Doctor repairs stale plugin config by removing missing plugin ids from `plugins.allow`/`plugins.deny`/`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 keep running.
|
||||
- Doctor removes the retired `plugins.entries.codex.config.codexDynamicToolsProfile`; the Codex app-server always keeps Codex-native workspace tools native.
|
||||
|
|
|
|||
|
|
@ -511,7 +511,11 @@ That stages grounded durable candidates into the short-term dreaming store while
|
|||
When sandboxing is enabled, doctor checks Docker images and offers to build or switch to legacy names if the current image is missing.
|
||||
</Accordion>
|
||||
<Accordion title="7b. Plugin install cleanup">
|
||||
Doctor removes legacy OpenClaw-generated plugin dependency staging state in `openclaw doctor --fix` / `openclaw doctor --repair` mode: stale generated dependency roots, old install-stage directories, package-local debris from earlier bundled-plugin dependency repair code, and orphaned or recovered managed npm copies of bundled `@openclaw/*` plugins that can shadow the current bundled manifest. Doctor also relinks the host `openclaw` package into managed npm plugins that declare `peerDependencies.openclaw`, so package-local runtime imports such as `openclaw/plugin-sdk/*` keep resolving after updates or npm repairs.
|
||||
Doctor preserves shared plugin runtime caches and staging directories, including older versioned buckets. Another installation or profile can still depend on them; a directory name or marker does not establish that it is unused. `openclaw doctor --fix` / `openclaw doctor --repair` removes global plugin-runtime symlinks only when their targets no longer exist, not merely because they point into an older cache.
|
||||
|
||||
The `core/doctor/legacy-plugin-dependencies` lint selector shipped in v2026.8.1 remains available as a deprecated, non-destructive informational check. It no longer scans cache roots or recommends deleting them. Use `--severity-min info` to display its deprecation notice.
|
||||
|
||||
Package-local cleanup remains with the package installer. Doctor still removes orphaned or recovered managed npm copies of bundled `@openclaw/*` plugins that can shadow the current bundled manifest. It also relinks the host `openclaw` package into managed npm plugins that declare `peerDependencies.openclaw`, so package-local runtime imports such as `openclaw/plugin-sdk/*` keep resolving after updates or npm repairs.
|
||||
|
||||
Doctor can also reinstall missing downloadable plugins when config references them but the local plugin registry cannot find them (material `plugins.entries`, configured channel/provider/search settings, configured agent runtimes). During package updates, doctor avoids reinstalling plugin packages while the core package is being swapped; run `openclaw doctor --fix` again after the update if a configured plugin still needs recovery. Outside the container image startup exception below, gateway startup and config reload do not run package repair; plugin installs remain explicit doctor/install/update work.
|
||||
|
||||
|
|
|
|||
|
|
@ -22,9 +22,11 @@ keys and network-touching suites, see [Testing live](/help/testing-live).
|
|||
- A user can move from an older published package to the candidate package
|
||||
without losing config, agents, sessions, workspaces, plugin allowlists, or
|
||||
channel config.
|
||||
- `openclaw doctor --fix --non-interactive` owns legacy cleanup and repair
|
||||
paths. Startup should not grow hidden compatibility migrations for stale
|
||||
plugin state.
|
||||
- `openclaw doctor --fix --non-interactive` owns legacy migrations and repairs,
|
||||
including genuinely dangling plugin-runtime aliases. Package postinstall owns
|
||||
package-local dependency debris; both preserve valid shared runtime roots that
|
||||
another installation or profile may use. Startup should not grow hidden
|
||||
compatibility migrations for stale plugin state.
|
||||
- Plugin installs work from local directories, git repos, npm packages, and the
|
||||
ClawHub registry path.
|
||||
- Plugin npm dependencies install in one managed npm project per plugin,
|
||||
|
|
@ -121,11 +123,10 @@ Important lanes:
|
|||
restart the Gateway before the normal probes.
|
||||
- `test:docker:update-migration` is the cleanup-heavy published-update lane. It
|
||||
installs the latest stable release by default, starts from a configured
|
||||
Discord/Telegram-style user state, runs baseline
|
||||
doctor so configured plugin dependencies have a chance to materialize, seeds
|
||||
legacy plugin dependency debris for a configured packaged plugin, updates to
|
||||
the candidate tarball, and requires post-update doctor to remove the legacy
|
||||
dependency roots.
|
||||
Discord/Telegram-style user state, seeds package-local plugin dependency debris
|
||||
and shared runtime sentinels, and updates to the candidate tarball. Package
|
||||
postinstall must remove package-local debris while update and Doctor preserve
|
||||
the shared runtime roots.
|
||||
|
||||
Useful published-upgrade survivor variants:
|
||||
|
||||
|
|
|
|||
|
|
@ -307,13 +307,15 @@ openclaw doctor --lint --json
|
|||
|
||||
When `openclaw update` manages a global npm install, it installs the target
|
||||
into a temporary npm prefix first. The candidate package validates the host
|
||||
Node version during `preinstall`; only then does OpenClaw verify the packaged
|
||||
`dist` inventory and swap the clean package tree into the real global prefix. A
|
||||
packed completion guard is omitted from the expected inventory and removed only
|
||||
after `preinstall` succeeds, so skipped lifecycle scripts also fail before the
|
||||
swap. The updater probes the owning npm before mutation. On npm 11.15 and
|
||||
earlier it omits the unsupported lifecycle-policy flag. On npm 12 and npm
|
||||
11.16+, it approves only the candidate OpenClaw lifecycle; transitive
|
||||
Node version during `preinstall`; OpenClaw verifies the packaged `dist` inventory
|
||||
before swapping the clean package tree into the real global prefix. Pending
|
||||
lifecycle work is recorded in `.openclaw-lifecycle-pending` at the package root,
|
||||
outside the `dist` inventory. `postinstall` removes that marker after completion.
|
||||
If package scripts were skipped, the CLI completes the pending lifecycle before
|
||||
running any command, including `--version`; failure stops the command with
|
||||
reinstall guidance. The updater probes the owning npm before mutation. On npm
|
||||
11.15 and earlier it omits the unsupported lifecycle-policy flag. On npm 12 and
|
||||
npm 11.16+, it approves only the candidate OpenClaw lifecycle; transitive
|
||||
dependency scripts remain unapproved.
|
||||
This avoids npm overlaying a new package onto stale files from the old one. If
|
||||
the install command fails, OpenClaw retries once with `--omit=optional`, which
|
||||
|
|
@ -355,11 +357,34 @@ bun add -g --trust openclaw@latest
|
|||
`--trust` allows OpenClaw's lifecycle scripts. The canonical `openclaw update`
|
||||
path applies the same OpenClaw-only Bun trust when it owns the install.
|
||||
|
||||
### Package lifecycle and operator state
|
||||
|
||||
Package lifecycle hooks validate the Node runtime and update only package-local
|
||||
artifacts: the installed `dist` tree and lifecycle markers. Plugin-registry and
|
||||
operator-state migration belong to Doctor, not package installation. Doctor also
|
||||
removes genuinely dangling global plugin-runtime links, but preserves shared and
|
||||
versioned runtime caches and valid links to them: other installs or profiles may
|
||||
still use them. `openclaw update` still runs Doctor after installing the candidate;
|
||||
after a manual package replacement, run `openclaw doctor --fix` before restarting
|
||||
the Gateway.
|
||||
|
||||
`OPENCLAW_DISABLE_BUNDLED_PLUGIN_POSTINSTALL=1` skips package-local postinstall
|
||||
cleanup, but still completes the lifecycle marker. It does not disable Doctor or
|
||||
Gateway startup migrations.
|
||||
|
||||
<Warning>
|
||||
Older packages, including `2026.8.1`, can migrate the state database during
|
||||
installation even with that postinstall opt-out set. Back up before upgrading.
|
||||
To evaluate an affected package without changing a working Gateway, use a
|
||||
disposable environment with separate home, config, and state directories. A
|
||||
different npm prefix alone does not isolate operator state.
|
||||
</Warning>
|
||||
|
||||
### Advanced npm install topics
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Read-only package tree">
|
||||
OpenClaw treats packaged global installs as read-only at runtime, even when the global package directory is writable by the current user. Plugin package installs live in OpenClaw-owned npm/git roots under the user config directory, and Gateway startup does not mutate the OpenClaw package tree.
|
||||
After package lifecycle completion, OpenClaw treats packaged global installs as read-only at runtime, even when the global package directory is writable by the current user. Plugin package installs live in OpenClaw-owned npm/git roots under the user config directory, and Gateway startup does not mutate the OpenClaw package tree.
|
||||
|
||||
Some Linux npm setups install global packages under root-owned directories such as `/usr/lib/node_modules/openclaw`. OpenClaw supports that layout because plugin install/update commands write outside that global package directory.
|
||||
|
||||
|
|
|
|||
|
|
@ -187,7 +187,7 @@ openclaw plugins install <source>
|
|||
openclaw doctor --fix
|
||||
```
|
||||
|
||||
`doctor --fix` cleans legacy OpenClaw-generated dependency state and can
|
||||
`doctor --fix` removes dangling global plugin-runtime symlinks and can
|
||||
recover downloadable plugins that are missing from local install records when
|
||||
config still references them. Doctor does not repair dependencies for an
|
||||
already-installed local plugin.
|
||||
|
|
@ -262,14 +262,17 @@ this setup or runs a package manager.
|
|||
## Legacy cleanup
|
||||
|
||||
Older OpenClaw versions generated bundled-plugin dependency roots at startup
|
||||
or during doctor repair. Current doctor cleanup removes those stale
|
||||
directories and symlinks with `--fix`, including old `plugin-runtime-deps`
|
||||
roots, global Node-prefix package symlinks pointing at pruned
|
||||
`plugin-runtime-deps` targets, `.openclaw-runtime-deps*` manifests, generated
|
||||
plugin `node_modules`, install stage directories, and package-local pnpm
|
||||
stores. Packaged postinstall also removes those global symlinks before
|
||||
pruning the legacy target roots, so upgrades do not leave dangling ESM
|
||||
package imports.
|
||||
or during doctor repair. Packaged postinstall now cleans only its own
|
||||
installation: obsolete bundled-plugin `node_modules` and
|
||||
`.openclaw-install-stage*` directories under `dist/extensions`, `dist` files
|
||||
absent from the packaged inventory, and empty `dist` directories.
|
||||
|
||||
`doctor --fix` removes global Node-prefix package symlinks into
|
||||
`plugin-runtime-deps` only when the alias itself is genuinely dangling. Live
|
||||
aliases are preserved. Neither Doctor nor postinstall deletes shared
|
||||
`plugin-runtime-deps` roots or mirrors, which may still serve another
|
||||
installation or profile. The deprecated `core/doctor/legacy-plugin-dependencies`
|
||||
selector is informational only; it no longer scans shared roots for removal.
|
||||
|
||||
Older npm installs also used a shared `~/.openclaw/npm/node_modules` root.
|
||||
Current install, update, uninstall, and doctor flows still recognize that
|
||||
|
|
|
|||
|
|
@ -670,17 +670,19 @@ function assertStateSurvived() {
|
|||
assertAuthProfileMigrationSurvived(stateDir, stage);
|
||||
}
|
||||
const legacyRuntimeRoot = path.join(stateDir, "plugin-runtime-deps");
|
||||
if (stage === "baseline") {
|
||||
if (fs.existsSync(legacyRuntimeRoot)) {
|
||||
assert(
|
||||
fs.existsSync(path.join(legacyRuntimeRoot, "discord")),
|
||||
"legacy plugin runtime deps root exists but discord debris is missing before doctor cleanup",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
assert(
|
||||
!fs.existsSync(legacyRuntimeRoot),
|
||||
`legacy plugin runtime deps root survived update/doctor: ${legacyRuntimeRoot}`,
|
||||
for (const plugin of ["discord", "telegram", "whatsapp"]) {
|
||||
const sentinel = path.join(
|
||||
legacyRuntimeRoot,
|
||||
plugin,
|
||||
".openclaw-runtime-deps-copy-stale",
|
||||
"node_modules",
|
||||
"stale-sentinel",
|
||||
"package.json",
|
||||
);
|
||||
assertStrict.deepEqual(
|
||||
readJson(sentinel),
|
||||
{ name: "stale-sentinel", version: "0.0.0" },
|
||||
`shared plugin runtime cache changed during update/doctor: ${sentinel}`,
|
||||
);
|
||||
}
|
||||
if (scenario === "bootstrap-persona") {
|
||||
|
|
@ -697,18 +699,21 @@ function assertStateSurvived() {
|
|||
);
|
||||
}
|
||||
if (scenario === "versioned-runtime-deps") {
|
||||
if (stage === "baseline") {
|
||||
return;
|
||||
}
|
||||
const version = process.env.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_VERSION || "2026.4.24";
|
||||
const runtimeRoot = path.join(stateDir, "plugin-runtime-deps");
|
||||
const staleVersionedRoots = fs.existsSync(runtimeRoot)
|
||||
? fs.readdirSync(runtimeRoot).filter((entry) => entry.startsWith(`openclaw-${version}-`))
|
||||
: [];
|
||||
assert(
|
||||
staleVersionedRoots.length === 0,
|
||||
`stale versioned runtime deps survived update/doctor: ${staleVersionedRoots.join(", ")}`,
|
||||
);
|
||||
for (const plugin of ["discord", "feishu", "telegram", "whatsapp"]) {
|
||||
const sentinel = path.join(
|
||||
legacyRuntimeRoot,
|
||||
`openclaw-${version}-${plugin}`,
|
||||
"node_modules",
|
||||
"stale-sentinel",
|
||||
"package.json",
|
||||
);
|
||||
assertStrict.deepEqual(
|
||||
readJson(sentinel),
|
||||
{ name: "stale-sentinel", version: "0.0.0" },
|
||||
`versioned shared runtime cache changed during update/doctor: ${sentinel}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
143
scripts/e2e/lib/upgrade-survivor/plugin-dependency-fixtures.sh
Normal file
143
scripts/e2e/lib/upgrade-survivor/plugin-dependency-fixtures.sh
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
# Keep the seeded set across update/Doctor phases: requested plugins may be absent
|
||||
# from a historical package, while seeded shared state must survive its replacement.
|
||||
seeded_plugin_dependency_ids=()
|
||||
|
||||
plugin_deps_cleanup_enabled() {
|
||||
[ "$SCENARIO" = "plugin-deps-cleanup" ]
|
||||
}
|
||||
|
||||
plugin_deps_cleanup_plugins() {
|
||||
printf '%s\n' "${OPENCLAW_UPGRADE_SURVIVOR_PLUGIN_DEPS_CLEANUP_PLUGINS:-discord telegram}"
|
||||
}
|
||||
|
||||
plugin_deps_cleanup_plugin_dirs() {
|
||||
local plugin="$1"
|
||||
printf '%s\n' \
|
||||
"$(package_root)/dist/extensions/$plugin" \
|
||||
"$(package_root)/extensions/$plugin"
|
||||
}
|
||||
|
||||
legacy_plugin_dependency_probe_paths() {
|
||||
local plugin="$1"
|
||||
local plugin_dir
|
||||
while IFS= read -r plugin_dir; do
|
||||
printf '%s\n' \
|
||||
"$plugin_dir/node_modules" \
|
||||
"$plugin_dir/.openclaw-runtime-deps.json" \
|
||||
"$plugin_dir/.openclaw-runtime-deps-stamp.json" \
|
||||
"$plugin_dir/.openclaw-runtime-deps-copy-upgrade-survivor" \
|
||||
"$plugin_dir/.openclaw-install-stage-upgrade-survivor" \
|
||||
"$plugin_dir/.openclaw-pnpm-store"
|
||||
done < <(plugin_deps_cleanup_plugin_dirs "$plugin")
|
||||
printf '%s\n' "$(package_root)/.local/bundled-plugin-runtime-deps/$plugin-upgrade-survivor"
|
||||
}
|
||||
|
||||
install_baseline_plugin_dependencies() {
|
||||
plugin_deps_cleanup_enabled || return 0
|
||||
echo "Skipping baseline doctor for plugin dependency cleanup scenario; the package update owns package-local cleanup."
|
||||
}
|
||||
|
||||
seed_legacy_plugin_dependency_debris() {
|
||||
plugin_deps_cleanup_enabled || return 0
|
||||
|
||||
seeded_plugin_dependency_ids=()
|
||||
local plugin
|
||||
for plugin in $(plugin_deps_cleanup_plugins); do
|
||||
local plugin_dir
|
||||
plugin_dir=""
|
||||
local candidate_dir
|
||||
while IFS= read -r candidate_dir; do
|
||||
if [ -d "$candidate_dir" ]; then
|
||||
plugin_dir="$candidate_dir"
|
||||
break
|
||||
fi
|
||||
done < <(plugin_deps_cleanup_plugin_dirs "$plugin")
|
||||
[ -n "$plugin_dir" ] || continue
|
||||
mkdir -p \
|
||||
"$plugin_dir/node_modules/openclaw-upgrade-survivor-dep" \
|
||||
"$plugin_dir/.openclaw-runtime-deps-copy-upgrade-survivor/node_modules/openclaw-upgrade-survivor-dep" \
|
||||
"$plugin_dir/.openclaw-install-stage-upgrade-survivor" \
|
||||
"$plugin_dir/.openclaw-pnpm-store" \
|
||||
"$(package_root)/.local/bundled-plugin-runtime-deps/$plugin-upgrade-survivor/node_modules/openclaw-upgrade-survivor-dep" \
|
||||
"$OPENCLAW_STATE_DIR/.local/bundled-plugin-runtime-deps/$plugin-upgrade-survivor/node_modules/openclaw-upgrade-survivor-dep" \
|
||||
"$OPENCLAW_STATE_DIR/plugin-runtime-deps/$plugin-upgrade-survivor/node_modules/openclaw-upgrade-survivor-dep"
|
||||
printf '{"name":"openclaw-upgrade-survivor-dep","version":"0.0.0"}\n' \
|
||||
>"$plugin_dir/node_modules/openclaw-upgrade-survivor-dep/package.json"
|
||||
printf '{"plugin":"%s","scenario":"plugin-deps-cleanup"}\n' "$plugin" \
|
||||
>"$plugin_dir/.openclaw-runtime-deps.json"
|
||||
printf '{"plugin":"%s","scenario":"plugin-deps-cleanup","stale":true}\n' "$plugin" \
|
||||
>"$plugin_dir/.openclaw-runtime-deps-stamp.json"
|
||||
printf '{"name":"openclaw-upgrade-survivor-dep","version":"0.0.0"}\n' \
|
||||
>"$plugin_dir/.openclaw-runtime-deps-copy-upgrade-survivor/node_modules/openclaw-upgrade-survivor-dep/package.json"
|
||||
printf '{"name":"openclaw-upgrade-survivor-dep","version":"0.0.0"}\n' \
|
||||
>"$(package_root)/.local/bundled-plugin-runtime-deps/$plugin-upgrade-survivor/node_modules/openclaw-upgrade-survivor-dep/package.json"
|
||||
printf '{"name":"openclaw-upgrade-survivor-dep","version":"0.0.0"}\n' \
|
||||
>"$OPENCLAW_STATE_DIR/.local/bundled-plugin-runtime-deps/$plugin-upgrade-survivor/node_modules/openclaw-upgrade-survivor-dep/package.json"
|
||||
printf '{"name":"openclaw-upgrade-survivor-dep","version":"0.0.0"}\n' \
|
||||
>"$OPENCLAW_STATE_DIR/plugin-runtime-deps/$plugin-upgrade-survivor/node_modules/openclaw-upgrade-survivor-dep/package.json"
|
||||
seeded_plugin_dependency_ids+=("$plugin")
|
||||
echo "Seeded legacy plugin dependency debris for configured plugin: $plugin"
|
||||
done
|
||||
|
||||
if [ "${#seeded_plugin_dependency_ids[@]}" -eq 0 ]; then
|
||||
echo "plugin-deps-cleanup scenario could not find a requested packaged plugin directory" >&2
|
||||
find "$(package_root)/dist" -maxdepth 3 -type d 2>/dev/null >&2 || true
|
||||
find "$(package_root)/extensions" -maxdepth 2 -type d 2>/dev/null >&2 || true
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_legacy_plugin_dependency_debris_present() {
|
||||
plugin_deps_cleanup_enabled || return 0
|
||||
|
||||
local found
|
||||
found="$(legacy_plugin_dependency_debris_count)"
|
||||
if [ "$found" -eq 0 ]; then
|
||||
echo "plugin-deps-cleanup scenario did not create legacy plugin dependency debris" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
legacy_plugin_dependency_debris_count() {
|
||||
local found=0
|
||||
local plugin
|
||||
for plugin in "${seeded_plugin_dependency_ids[@]}"; do
|
||||
local probe
|
||||
while IFS= read -r probe; do
|
||||
if [ -e "$probe" ] || [ -L "$probe" ]; then
|
||||
found=1
|
||||
fi
|
||||
done < <(legacy_plugin_dependency_probe_paths "$plugin")
|
||||
done
|
||||
printf '%s\n' "$found"
|
||||
}
|
||||
|
||||
assert_legacy_plugin_dependency_debris_cleaned() {
|
||||
plugin_deps_cleanup_enabled || return 0
|
||||
|
||||
local remaining=0
|
||||
local plugin
|
||||
for plugin in "${seeded_plugin_dependency_ids[@]}"; do
|
||||
local probe
|
||||
while IFS= read -r probe; do
|
||||
if [ -e "$probe" ] || [ -L "$probe" ]; then
|
||||
echo "legacy plugin dependency debris survived update/doctor: $probe" >&2
|
||||
remaining=1
|
||||
fi
|
||||
done < <(legacy_plugin_dependency_probe_paths "$plugin")
|
||||
local shared_root
|
||||
for shared_root in "$OPENCLAW_STATE_DIR/.local/bundled-plugin-runtime-deps" "$OPENCLAW_STATE_DIR/plugin-runtime-deps"; do
|
||||
local sentinel="$shared_root/$plugin-upgrade-survivor/node_modules/openclaw-upgrade-survivor-dep/package.json"
|
||||
if [ ! -f "$sentinel" ]; then
|
||||
echo "shared plugin dependency state was removed during update/doctor: $sentinel" >&2
|
||||
remaining=1
|
||||
fi
|
||||
done
|
||||
done
|
||||
if [ "$remaining" -ne 0 ]; then
|
||||
return 1
|
||||
fi
|
||||
echo "Package-local dependency debris cleaned; shared plugin runtime state preserved."
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ exec 3>&1
|
|||
|
||||
source scripts/lib/openclaw-e2e-instance.sh
|
||||
source scripts/e2e/lib/prepublish-plugin-registry.sh
|
||||
source scripts/e2e/lib/upgrade-survivor/plugin-dependency-fixtures.sh
|
||||
|
||||
SCENARIO="${OPENCLAW_UPGRADE_SURVIVOR_SCENARIO:-base}"
|
||||
|
||||
|
|
@ -551,21 +552,6 @@ legacy_runtime_deps_symlink_source() {
|
|||
"$plugin"
|
||||
}
|
||||
|
||||
plugin_deps_cleanup_enabled() {
|
||||
[ "$SCENARIO" = "plugin-deps-cleanup" ]
|
||||
}
|
||||
|
||||
plugin_deps_cleanup_plugins() {
|
||||
printf '%s\n' "${OPENCLAW_UPGRADE_SURVIVOR_PLUGIN_DEPS_CLEANUP_PLUGINS:-discord telegram}"
|
||||
}
|
||||
|
||||
plugin_deps_cleanup_plugin_dirs() {
|
||||
local plugin="$1"
|
||||
printf '%s\n' \
|
||||
"$(package_root)/dist/extensions/$plugin" \
|
||||
"$(package_root)/extensions/$plugin"
|
||||
}
|
||||
|
||||
configured_plugin_installs_enabled() {
|
||||
[ "$SCENARIO" = "configured-plugin-installs" ] || [ "$SCENARIO" = "sqlite-volume" ]
|
||||
}
|
||||
|
|
@ -744,136 +730,6 @@ NODE
|
|||
"${registry_args[@]}"
|
||||
}
|
||||
|
||||
legacy_plugin_dependency_probe_paths() {
|
||||
local plugin="$1"
|
||||
local plugin_dir
|
||||
while IFS= read -r plugin_dir; do
|
||||
printf '%s\n' \
|
||||
"$plugin_dir/node_modules" \
|
||||
"$plugin_dir/.openclaw-runtime-deps.json" \
|
||||
"$plugin_dir/.openclaw-runtime-deps-stamp.json" \
|
||||
"$plugin_dir/.openclaw-runtime-deps-copy-upgrade-survivor" \
|
||||
"$plugin_dir/.openclaw-install-stage-upgrade-survivor" \
|
||||
"$plugin_dir/.openclaw-pnpm-store"
|
||||
done < <(plugin_deps_cleanup_plugin_dirs "$plugin")
|
||||
printf '%s\n' \
|
||||
"$(package_root)/.local/bundled-plugin-runtime-deps/$plugin-upgrade-survivor" \
|
||||
"$OPENCLAW_STATE_DIR/.local/bundled-plugin-runtime-deps/$plugin-upgrade-survivor" \
|
||||
"$OPENCLAW_STATE_DIR/plugin-runtime-deps/$plugin-upgrade-survivor"
|
||||
}
|
||||
|
||||
install_baseline_plugin_dependencies() {
|
||||
plugin_deps_cleanup_enabled || return 0
|
||||
echo "Skipping baseline doctor for plugin dependency cleanup scenario; candidate doctor owns stale dependency cleanup."
|
||||
}
|
||||
|
||||
seed_legacy_plugin_dependency_debris() {
|
||||
plugin_deps_cleanup_enabled || return 0
|
||||
|
||||
local found=0
|
||||
local plugin
|
||||
for plugin in $(plugin_deps_cleanup_plugins); do
|
||||
local plugin_dir
|
||||
plugin_dir=""
|
||||
local candidate_dir
|
||||
while IFS= read -r candidate_dir; do
|
||||
if [ -d "$candidate_dir" ]; then
|
||||
plugin_dir="$candidate_dir"
|
||||
break
|
||||
fi
|
||||
done < <(plugin_deps_cleanup_plugin_dirs "$plugin")
|
||||
[ -n "$plugin_dir" ] || continue
|
||||
found=1
|
||||
mkdir -p \
|
||||
"$plugin_dir/node_modules/openclaw-upgrade-survivor-dep" \
|
||||
"$plugin_dir/.openclaw-runtime-deps-copy-upgrade-survivor/node_modules/openclaw-upgrade-survivor-dep" \
|
||||
"$plugin_dir/.openclaw-install-stage-upgrade-survivor" \
|
||||
"$plugin_dir/.openclaw-pnpm-store" \
|
||||
"$(package_root)/.local/bundled-plugin-runtime-deps/$plugin-upgrade-survivor/node_modules/openclaw-upgrade-survivor-dep" \
|
||||
"$OPENCLAW_STATE_DIR/.local/bundled-plugin-runtime-deps/$plugin-upgrade-survivor/node_modules/openclaw-upgrade-survivor-dep" \
|
||||
"$OPENCLAW_STATE_DIR/plugin-runtime-deps/$plugin-upgrade-survivor/node_modules/openclaw-upgrade-survivor-dep"
|
||||
printf '{"name":"openclaw-upgrade-survivor-dep","version":"0.0.0"}\n' \
|
||||
>"$plugin_dir/node_modules/openclaw-upgrade-survivor-dep/package.json"
|
||||
printf '{"plugin":"%s","scenario":"plugin-deps-cleanup"}\n' "$plugin" \
|
||||
>"$plugin_dir/.openclaw-runtime-deps.json"
|
||||
printf '{"plugin":"%s","scenario":"plugin-deps-cleanup","stale":true}\n' "$plugin" \
|
||||
>"$plugin_dir/.openclaw-runtime-deps-stamp.json"
|
||||
printf '{"name":"openclaw-upgrade-survivor-dep","version":"0.0.0"}\n' \
|
||||
>"$plugin_dir/.openclaw-runtime-deps-copy-upgrade-survivor/node_modules/openclaw-upgrade-survivor-dep/package.json"
|
||||
printf '{"name":"openclaw-upgrade-survivor-dep","version":"0.0.0"}\n' \
|
||||
>"$(package_root)/.local/bundled-plugin-runtime-deps/$plugin-upgrade-survivor/node_modules/openclaw-upgrade-survivor-dep/package.json"
|
||||
printf '{"name":"openclaw-upgrade-survivor-dep","version":"0.0.0"}\n' \
|
||||
>"$OPENCLAW_STATE_DIR/.local/bundled-plugin-runtime-deps/$plugin-upgrade-survivor/node_modules/openclaw-upgrade-survivor-dep/package.json"
|
||||
printf '{"name":"openclaw-upgrade-survivor-dep","version":"0.0.0"}\n' \
|
||||
>"$OPENCLAW_STATE_DIR/plugin-runtime-deps/$plugin-upgrade-survivor/node_modules/openclaw-upgrade-survivor-dep/package.json"
|
||||
echo "Seeded legacy plugin dependency debris for configured plugin: $plugin"
|
||||
done
|
||||
|
||||
if [ "$found" -ne 1 ]; then
|
||||
echo "plugin-deps-cleanup scenario could not find a packaged Discord or Telegram plugin directory" >&2
|
||||
find "$(package_root)/dist" -maxdepth 3 -type d 2>/dev/null >&2 || true
|
||||
find "$(package_root)/extensions" -maxdepth 2 -type d 2>/dev/null >&2 || true
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_legacy_plugin_dependency_debris_present() {
|
||||
plugin_deps_cleanup_enabled || return 0
|
||||
|
||||
local found
|
||||
found="$(legacy_plugin_dependency_debris_count)"
|
||||
if [ "$found" -eq 0 ]; then
|
||||
echo "plugin-deps-cleanup scenario did not create legacy plugin dependency debris" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
legacy_plugin_dependency_debris_count() {
|
||||
local found=0
|
||||
local plugin
|
||||
for plugin in $(plugin_deps_cleanup_plugins); do
|
||||
local probe
|
||||
while IFS= read -r probe; do
|
||||
if [ -e "$probe" ] || [ -L "$probe" ]; then
|
||||
found=1
|
||||
fi
|
||||
done < <(legacy_plugin_dependency_probe_paths "$plugin")
|
||||
done
|
||||
printf '%s\n' "$found"
|
||||
}
|
||||
|
||||
assert_legacy_plugin_dependency_debris_before_doctor() {
|
||||
plugin_deps_cleanup_enabled || return 0
|
||||
|
||||
local found
|
||||
found="$(legacy_plugin_dependency_debris_count)"
|
||||
if [ "$found" -eq 0 ]; then
|
||||
echo "Legacy plugin dependency debris was already removed before doctor; post-doctor cleanup assertion will verify it stays gone."
|
||||
else
|
||||
echo "Legacy plugin dependency debris survived update and will be cleaned by doctor."
|
||||
fi
|
||||
}
|
||||
|
||||
assert_legacy_plugin_dependency_debris_cleaned() {
|
||||
plugin_deps_cleanup_enabled || return 0
|
||||
|
||||
local remaining=0
|
||||
local plugin
|
||||
for plugin in $(plugin_deps_cleanup_plugins); do
|
||||
local probe
|
||||
while IFS= read -r probe; do
|
||||
if [ -e "$probe" ] || [ -L "$probe" ]; then
|
||||
echo "legacy plugin dependency debris survived update/doctor: $probe" >&2
|
||||
remaining=1
|
||||
fi
|
||||
done < <(legacy_plugin_dependency_probe_paths "$plugin")
|
||||
done
|
||||
if [ "$remaining" -ne 0 ]; then
|
||||
return 1
|
||||
fi
|
||||
echo "Legacy plugin dependency debris cleaned for configured plugin dependencies."
|
||||
}
|
||||
|
||||
seed_legacy_runtime_deps_symlink() {
|
||||
local plugin
|
||||
plugin="$(legacy_runtime_deps_symlink_plugin)" || {
|
||||
|
|
@ -914,13 +770,19 @@ assert_legacy_runtime_deps_symlink_repaired() {
|
|||
return "$status"
|
||||
}
|
||||
|
||||
local target_dir
|
||||
local target_dir source_dir
|
||||
target_dir="$(legacy_runtime_deps_symlink_target "$plugin")"
|
||||
if [ -L "$target_dir" ]; then
|
||||
echo "legacy runtime deps symlink survived update/doctor: $target_dir -> $(readlink "$target_dir")" >&2
|
||||
source_dir="$(legacy_runtime_deps_symlink_source "$plugin")"
|
||||
if [ -e "$source_dir" ]; then
|
||||
if [ ! -L "$target_dir" ] || [ "$(readlink "$target_dir")" != "$source_dir" ]; then
|
||||
echo "valid runtime deps symlink was changed during update/doctor: $target_dir" >&2
|
||||
return 1
|
||||
fi
|
||||
elif [ -L "$target_dir" ]; then
|
||||
echo "dangling runtime deps symlink survived update/doctor: $target_dir" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "Legacy runtime deps symlink repaired for $plugin."
|
||||
echo "Runtime deps symlink preserved or repaired according to target existence for $plugin."
|
||||
}
|
||||
|
||||
read_installed_version() {
|
||||
|
|
@ -1809,7 +1671,7 @@ if [ -n "${OPENCLAW_CLAWHUB_URL:-}" ]; then
|
|||
run_plugin_fixture_phase assert-prepublish-requests assert_prepublish_plugin_install 1
|
||||
fi
|
||||
phase root-managed-vps-cli-usable assert_root_managed_vps_cli_usable
|
||||
run_plugin_fixture_phase assert-legacy-plugin-dependency-debris-before-doctor assert_legacy_plugin_dependency_debris_before_doctor
|
||||
run_plugin_fixture_phase assert-package-local-dependency-cleanup assert_legacy_plugin_dependency_debris_cleaned
|
||||
if [ "$SCENARIO" != "sqlite-volume" ] && [ "$SCENARIO" != "recovery-cleanup" ]; then
|
||||
phase doctor run_doctor
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -376,91 +376,6 @@ assert_update_restart_service_replaced() {
|
|||
echo "Update-owned fixture restart replaced supervisor $previous_pid with $current_pid."
|
||||
}
|
||||
|
||||
seed_update_restart_probe_device_auth() {
|
||||
node --input-type=module <<'NODE'
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const stateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
if (!stateDir) {
|
||||
throw new Error("missing OPENCLAW_STATE_DIR");
|
||||
}
|
||||
|
||||
const base64UrlEncode = (buf) =>
|
||||
buf.toString("base64").replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, "");
|
||||
const ed25519SpkiPrefix = Buffer.from("302a300506032b6570032100", "hex");
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
||||
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
|
||||
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
|
||||
const spki = crypto.createPublicKey(publicKeyPem).export({ type: "spki", format: "der" });
|
||||
const rawPublicKey =
|
||||
spki.length === ed25519SpkiPrefix.length + 32 &&
|
||||
spki.subarray(0, ed25519SpkiPrefix.length).equals(ed25519SpkiPrefix)
|
||||
? spki.subarray(ed25519SpkiPrefix.length)
|
||||
: spki;
|
||||
const publicKeyRaw = base64UrlEncode(rawPublicKey);
|
||||
const deviceId = crypto.createHash("sha256").update(rawPublicKey).digest("hex");
|
||||
const token = base64UrlEncode(crypto.randomBytes(32));
|
||||
const now = Date.now();
|
||||
const scopes = ["operator.read"];
|
||||
|
||||
function writeJson(filePath, value) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
||||
try {
|
||||
fs.chmodSync(filePath, 0o600);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
writeJson(path.join(stateDir, "identity", "device.json"), {
|
||||
version: 1,
|
||||
deviceId,
|
||||
publicKeyPem,
|
||||
privateKeyPem,
|
||||
createdAtMs: now,
|
||||
});
|
||||
writeJson(path.join(stateDir, "identity", "device-auth.json"), {
|
||||
version: 1,
|
||||
deviceId,
|
||||
tokens: {
|
||||
operator: {
|
||||
token,
|
||||
role: "operator",
|
||||
scopes,
|
||||
updatedAtMs: now,
|
||||
},
|
||||
},
|
||||
});
|
||||
writeJson(path.join(stateDir, "devices", "paired.json"), {
|
||||
[deviceId]: {
|
||||
deviceId,
|
||||
publicKey: publicKeyRaw,
|
||||
displayName: "upgrade survivor restart probe",
|
||||
platform: process.platform,
|
||||
clientId: "openclaw-cli",
|
||||
clientMode: "probe",
|
||||
role: "operator",
|
||||
roles: ["operator"],
|
||||
scopes,
|
||||
approvedScopes: scopes,
|
||||
tokens: {
|
||||
operator: {
|
||||
token,
|
||||
role: "operator",
|
||||
scopes,
|
||||
createdAtMs: now,
|
||||
},
|
||||
},
|
||||
createdAtMs: now,
|
||||
approvedAtMs: now,
|
||||
},
|
||||
});
|
||||
writeJson(path.join(stateDir, "devices", "pending.json"), {});
|
||||
NODE
|
||||
}
|
||||
|
||||
write_update_restart_service_auth_env() {
|
||||
mkdir -p "$OPENCLAW_STATE_DIR"
|
||||
local dotenv_path="$OPENCLAW_STATE_DIR/.env"
|
||||
|
|
@ -477,8 +392,8 @@ write_update_restart_service_auth_env() {
|
|||
|
||||
migrate_update_restart_probe_device_auth() {
|
||||
local doctor_log="$1" command_timeout="$2"
|
||||
# Both setup paths migrate their probe identity under parked, plugin-disabled
|
||||
# config. The published path runs this before creating migration specimens.
|
||||
# Current-install setup repairs state under parked, plugin-disabled config.
|
||||
# The published-upgrade path leaves its migration specimens to the candidate.
|
||||
openclaw_e2e_maybe_timeout \
|
||||
"$command_timeout" \
|
||||
env \
|
||||
|
|
@ -603,7 +518,7 @@ prepare_update_restart_probe_current_install() {
|
|||
|
||||
echo "Preparing candidate-auth gateway for automatic update restart."
|
||||
install_update_restart_systemctl_shim
|
||||
seed_update_restart_probe_device_auth
|
||||
# Use the managed service token; setup may already own a canonical device identity.
|
||||
# Service installation persists OPENCLAW_CONFIG_PATH, so isolate the canonical file in place.
|
||||
# Keep reload off until the manager owns the installed service and its descendants.
|
||||
node "$parking_helper" \
|
||||
|
|
@ -624,7 +539,7 @@ prepare_update_restart_probe_current_install() {
|
|||
failure_stage="doctor"
|
||||
}
|
||||
if [ "$probe_status" -ne 0 ]; then
|
||||
echo "candidate device identity migration failed" >&2
|
||||
echo "candidate setup Doctor failed" >&2
|
||||
openclaw_e2e_print_log "$doctor_log" >&2
|
||||
fi
|
||||
if [ "$probe_status" -eq 0 ]; then
|
||||
|
|
|
|||
|
|
@ -1,21 +1,18 @@
|
|||
#!/usr/bin/env node
|
||||
// Runs after install to keep packaged dist safe and compatible.
|
||||
// Keep packaged dist safe and compatible. Plugin package dependencies are
|
||||
// installed only by explicit plugin install/update flows, never postinstall.
|
||||
// Package lifecycle cleanup and completion touch only this installed package.
|
||||
// Doctor owns operator-state migration and genuinely dangling runtime-link repair;
|
||||
// shared caches outside this package can still serve other installs or profiles.
|
||||
import {
|
||||
existsSync,
|
||||
lstatSync,
|
||||
opendirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
readlinkSync,
|
||||
realpathSync,
|
||||
rmdirSync,
|
||||
rmSync,
|
||||
unlinkSync,
|
||||
} from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { basename, dirname, isAbsolute, join, relative, resolve as pathResolve } from "node:path";
|
||||
import { dirname, isAbsolute, join, relative } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { PACKAGE_LIFECYCLE_PENDING_RELATIVE_PATH } from "./lib/package-lifecycle-marker.mjs";
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
|
|
@ -28,37 +25,12 @@ const DIST_INVENTORY_PATH = "dist/postinstall-inventory.json";
|
|||
// headroom so dist growth cannot fail `npm install -g` while still refusing
|
||||
// pathological/unbounded trees.
|
||||
export const MAX_INSTALLED_DIST_SCAN_ENTRIES = 100_000;
|
||||
const LEGACY_PLUGIN_RUNTIME_DEPS_DIR = "plugin-runtime-deps";
|
||||
class InstalledDistScanLimitError extends Error {}
|
||||
|
||||
function normalizeRelativePath(filePath) {
|
||||
return filePath.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
function resolvePostinstallOsHomeDir(env, getHomedir = homedir) {
|
||||
return env?.HOME?.trim() || env?.USERPROFILE?.trim() || getHomedir();
|
||||
}
|
||||
|
||||
function resolvePostinstallTildePath(input, homeDir) {
|
||||
if (input === "~") {
|
||||
return homeDir;
|
||||
}
|
||||
if (input.startsWith("~/") || input.startsWith("~\\")) {
|
||||
return join(homeDir, input.slice(2));
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
function resolvePostinstallOpenClawHomeDir(env, getHomedir = homedir) {
|
||||
const osHome = resolvePostinstallOsHomeDir(env, getHomedir);
|
||||
const override = env?.OPENCLAW_HOME?.trim();
|
||||
return override ? pathResolve(resolvePostinstallTildePath(override, osHome)) : osHome;
|
||||
}
|
||||
|
||||
function resolvePostinstallUserPath(input, openClawHome) {
|
||||
return pathResolve(resolvePostinstallTildePath(input, openClawHome));
|
||||
}
|
||||
|
||||
function readInstalledDistInventory(params = {}) {
|
||||
const packageRoot = params.packageRoot ?? DEFAULT_PACKAGE_ROOT;
|
||||
const pathExists = params.existsSync ?? existsSync;
|
||||
|
|
@ -312,169 +284,6 @@ function pruneLegacyInstalledPluginDependencyDirs(params) {
|
|||
return removed;
|
||||
}
|
||||
|
||||
function splitPostinstallPathList(value) {
|
||||
return value
|
||||
? value
|
||||
.split(pathDelimiter)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
}
|
||||
|
||||
const pathDelimiter = process.platform === "win32" ? ";" : ":";
|
||||
|
||||
export function collectLegacyPluginRuntimeDepsStateRoots(params = {}) {
|
||||
const env = params.env ?? process.env;
|
||||
const getHomedir = params.homedir ?? homedir;
|
||||
const openClawHome = resolvePostinstallOpenClawHomeDir(env, getHomedir);
|
||||
const stateRoots = [];
|
||||
const addStateRoot = (root) => {
|
||||
if (root) {
|
||||
stateRoots.push(join(root, LEGACY_PLUGIN_RUNTIME_DEPS_DIR));
|
||||
}
|
||||
};
|
||||
|
||||
const stateOverride = env?.OPENCLAW_STATE_DIR?.trim();
|
||||
if (stateOverride) {
|
||||
addStateRoot(resolvePostinstallUserPath(stateOverride, openClawHome));
|
||||
}
|
||||
const configPath = env?.OPENCLAW_CONFIG_PATH?.trim();
|
||||
if (configPath) {
|
||||
addStateRoot(dirname(resolvePostinstallUserPath(configPath, openClawHome)));
|
||||
}
|
||||
addStateRoot(join(openClawHome, ".openclaw"));
|
||||
addStateRoot(join(openClawHome, ".clawdbot"));
|
||||
|
||||
for (const entry of splitPostinstallPathList(env?.STATE_DIRECTORY)) {
|
||||
addStateRoot(resolvePostinstallUserPath(entry, openClawHome));
|
||||
}
|
||||
|
||||
return [...new Set(stateRoots.map((root) => pathResolve(root)))].toSorted((left, right) =>
|
||||
left.localeCompare(right),
|
||||
);
|
||||
}
|
||||
|
||||
function isPathInsideRoot(candidate, root) {
|
||||
const relativePath = relative(root, candidate);
|
||||
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
|
||||
}
|
||||
|
||||
function collectLegacyPluginRuntimeDepsSymlinkPaths(roots, params = {}) {
|
||||
const packageRoot = params.packageRoot ?? DEFAULT_PACKAGE_ROOT;
|
||||
const readDir = params.readdirSync ?? readdirSync;
|
||||
const pathLstat = params.lstatSync ?? lstatSync;
|
||||
const readLink = params.readlinkSync ?? readlinkSync;
|
||||
const pathExists = params.existsSync ?? existsSync;
|
||||
const containingNodeModules = dirname(packageRoot);
|
||||
if (basename(containingNodeModules) !== "node_modules") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const normalizedRoots = roots.map((root) => pathResolve(root));
|
||||
const candidates = [];
|
||||
function addCandidate(linkPath) {
|
||||
let linkStat;
|
||||
try {
|
||||
linkStat = pathLstat(linkPath);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!linkStat.isSymbolicLink()) {
|
||||
return;
|
||||
}
|
||||
let target;
|
||||
try {
|
||||
target = readLink(linkPath);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!target.includes(LEGACY_PLUGIN_RUNTIME_DEPS_DIR)) {
|
||||
return;
|
||||
}
|
||||
const resolvedTarget = pathResolve(dirname(linkPath), target);
|
||||
const pointsIntoPrunedRoot = normalizedRoots.some((root) =>
|
||||
isPathInsideRoot(resolvedTarget, root),
|
||||
);
|
||||
if (pointsIntoPrunedRoot || !pathExists(resolvedTarget)) {
|
||||
candidates.push(linkPath);
|
||||
}
|
||||
}
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = readDir(containingNodeModules, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && entry.name.startsWith("@")) {
|
||||
const scopeDir = join(containingNodeModules, entry.name);
|
||||
let scopeEntries;
|
||||
try {
|
||||
scopeEntries = readDir(scopeDir, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const scopeEntry of scopeEntries) {
|
||||
addCandidate(join(scopeDir, scopeEntry.name));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (entry.isSymbolicLink()) {
|
||||
addCandidate(join(containingNodeModules, entry.name));
|
||||
}
|
||||
}
|
||||
return [...new Set(candidates.map((entry) => pathResolve(entry)))].toSorted((left, right) =>
|
||||
left.localeCompare(right),
|
||||
);
|
||||
}
|
||||
|
||||
export function pruneLegacyPluginRuntimeDepsState(params = {}) {
|
||||
const pathExists = params.existsSync ?? existsSync;
|
||||
const removePath = params.rmSync ?? rmSync;
|
||||
const unlinkPath = params.unlinkSync ?? unlinkSync;
|
||||
const log = params.log ?? console;
|
||||
const removed = [];
|
||||
const removedSymlinks = [];
|
||||
const roots = collectLegacyPluginRuntimeDepsStateRoots(params);
|
||||
|
||||
for (const linkPath of collectLegacyPluginRuntimeDepsSymlinkPaths(roots, params)) {
|
||||
try {
|
||||
unlinkPath(linkPath);
|
||||
removedSymlinks.push(linkPath);
|
||||
} catch (error) {
|
||||
log.warn?.(
|
||||
`[postinstall] could not prune legacy plugin runtime deps symlink ${linkPath}: ${String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const root of roots) {
|
||||
if (!pathExists(root)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
removePath(root, { recursive: true, force: true, maxRetries: 2, retryDelay: 100 });
|
||||
removed.push(root);
|
||||
} catch (error) {
|
||||
log.warn?.(
|
||||
`[postinstall] could not prune legacy plugin runtime deps ${root}: ${String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (removed.length > 0) {
|
||||
log.log?.(`[postinstall] pruned legacy plugin runtime deps: ${removed.join(", ")}`);
|
||||
}
|
||||
if (removedSymlinks.length > 0) {
|
||||
log.log?.(
|
||||
`[postinstall] pruned legacy plugin runtime deps symlinks: ${removedSymlinks.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
export function pruneInstalledPackageDist(params = {}) {
|
||||
const packageRoot = params.packageRoot ?? DEFAULT_PACKAGE_ROOT;
|
||||
const removeFile = params.unlinkSync ?? unlinkSync;
|
||||
|
|
@ -558,17 +367,6 @@ export function runBundledPluginPostinstall(params = {}) {
|
|||
// must not alter that install or the operator state from a development checkout.
|
||||
return;
|
||||
}
|
||||
pruneLegacyPluginRuntimeDepsState({
|
||||
env,
|
||||
packageRoot,
|
||||
existsSync: pathExists,
|
||||
lstatSync: params.lstatSync,
|
||||
readlinkSync: params.readlinkSync,
|
||||
rmSync: params.rmSync,
|
||||
unlinkSync: params.unlinkSync,
|
||||
log,
|
||||
homedir: params.homedir,
|
||||
});
|
||||
pruneInstalledPackageDist({
|
||||
packageRoot,
|
||||
existsSync: pathExists,
|
||||
|
|
|
|||
83
src/cli/completion-cli.runner.process.test.ts
Normal file
83
src/cli/completion-cli.runner.process.test.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import * as childProcess from "node:child_process";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import { Command } from "commander";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { createDeferred } from "../../test/helpers/promise.js";
|
||||
import { createTempDirTracker } from "../../test/helpers/temp-dir.js";
|
||||
|
||||
type ChildExit = { code: number | null; signal: NodeJS.Signals | null };
|
||||
type Settlement =
|
||||
| { state: "pending" }
|
||||
| { state: "fulfilled" }
|
||||
| { state: "rejected"; error: unknown };
|
||||
|
||||
function observeSettlement(promise: Promise<unknown>) {
|
||||
const observed: { value: Settlement } = { value: { state: "pending" } };
|
||||
void promise.then(
|
||||
() => {
|
||||
observed.value = { state: "fulfilled" };
|
||||
},
|
||||
(error: unknown) => {
|
||||
observed.value = { state: "rejected", error };
|
||||
},
|
||||
);
|
||||
return observed;
|
||||
}
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"rejects queued completions and settles close when a real child exits before READY",
|
||||
async () => {
|
||||
const tempDirs = createTempDirTracker();
|
||||
const children: Promise<ChildExit>[] = [];
|
||||
try {
|
||||
const executable = path.join(tempDirs.make("openclaw-completion-exit-"), "pwsh");
|
||||
writeFileSync(executable, "#!/bin/sh\nexit 23\n", { mode: 0o700 });
|
||||
const closed = createDeferred<ChildExit>();
|
||||
vi.resetModules();
|
||||
vi.stubEnv("OPENCLAW_TEST_PWSH", executable);
|
||||
vi.doMock("node:child_process", () => ({
|
||||
...childProcess,
|
||||
spawn(...args: Parameters<typeof childProcess.spawn>) {
|
||||
const child = childProcess.spawn(...args);
|
||||
if (args[0] === executable) {
|
||||
const exit = createDeferred<ChildExit>();
|
||||
child.once("close", (code, signal) => exit.resolve({ code, signal }));
|
||||
children.push(exit.promise);
|
||||
void exit.promise.then(closed.resolve);
|
||||
}
|
||||
return child;
|
||||
},
|
||||
}));
|
||||
const { PowerShellCompletionRunner } = await import("./completion-cli.test-support.js");
|
||||
const runner = new PowerShellCompletionRunner();
|
||||
const program = new Command().name("openclaw");
|
||||
const first = observeSettlement(runner.complete(program, "openclaw "));
|
||||
const second = observeSettlement(runner.complete(program, "openclaw --"));
|
||||
|
||||
expect(await closed.promise).toEqual({ code: 23, signal: null });
|
||||
// The real child is closed; only promise continuations remain.
|
||||
// Node drains those before setImmediate's check phase, regardless of chain depth.
|
||||
await setImmediate();
|
||||
const exitFailure = expect.objectContaining({
|
||||
message: expect.stringContaining("code 23 signal null"),
|
||||
});
|
||||
expect(first.value).toEqual({ state: "rejected", error: exitFailure });
|
||||
expect(second.value).toEqual(first.value);
|
||||
expect(children).toHaveLength(1);
|
||||
|
||||
const closing = observeSettlement(runner.close());
|
||||
// The exit promise is settled; observe close's remaining promise continuations.
|
||||
await setImmediate();
|
||||
expect(closing.value).toEqual({ state: "rejected", error: exitFailure });
|
||||
} finally {
|
||||
// Joining the actual close latches also cleans up when the pre-fix readiness assertion fails.
|
||||
await Promise.all(children);
|
||||
vi.doUnmock("node:child_process");
|
||||
vi.unstubAllEnvs();
|
||||
vi.resetModules();
|
||||
tempDirs.cleanup();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
@ -361,11 +361,16 @@ export class PowerShellCompletionRunner {
|
|||
child.stderr.setEncoding("utf8");
|
||||
this.stdoutLines = createInterface({ input: child.stdout });
|
||||
this.readyPromise = new Promise<void>((resolve, reject) => {
|
||||
const readyTimeout = setTimeout(() => {
|
||||
const error = new Error("PowerShell completion runner did not become ready");
|
||||
const readyTimeout = setTimeout(
|
||||
() => fail(new Error("PowerShell completion runner did not become ready")),
|
||||
POWERSHELL_CASE_TIMEOUT_MS,
|
||||
);
|
||||
// Before READY there are no pending requests; poisoning alone would strand the queue.
|
||||
const fail = (error: Error) => {
|
||||
clearTimeout(readyTimeout);
|
||||
reject(error);
|
||||
this.poison(error);
|
||||
}, POWERSHELL_CASE_TIMEOUT_MS);
|
||||
};
|
||||
this.stdoutLines?.on("line", (line) => {
|
||||
if (line === `${this.framePrefix}READY`) {
|
||||
clearTimeout(readyTimeout);
|
||||
|
|
@ -373,14 +378,14 @@ export class PowerShellCompletionRunner {
|
|||
return;
|
||||
}
|
||||
if (!line.startsWith(this.framePrefix)) {
|
||||
this.poison(new Error(`Unexpected PowerShell completion stdout: ${line}`));
|
||||
fail(new Error(`Unexpected PowerShell completion stdout: ${line}`));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = decodePowerShellCompletionResponse(line.slice(this.framePrefix.length));
|
||||
const pending = this.pending.get(response.id);
|
||||
if (!pending) {
|
||||
this.poison(new Error(`Unexpected PowerShell completion response id: ${response.id}`));
|
||||
fail(new Error(`Unexpected PowerShell completion response id: ${response.id}`));
|
||||
return;
|
||||
}
|
||||
clearTimeout(pending.timeout);
|
||||
|
|
@ -393,25 +398,21 @@ export class PowerShellCompletionRunner {
|
|||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.poison(error instanceof Error ? error : new Error(String(error)));
|
||||
fail(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
});
|
||||
child.once("error", (error) => {
|
||||
reject(error);
|
||||
this.poison(error);
|
||||
});
|
||||
child.once("error", fail);
|
||||
child.stderr.on("data", (chunk: string) => {
|
||||
const stderr = chunk.trim();
|
||||
if (stderr) {
|
||||
this.poison(new Error(`Unexpected PowerShell completion stderr: ${stderr}`));
|
||||
fail(new Error(`Unexpected PowerShell completion stderr: ${stderr}`));
|
||||
}
|
||||
});
|
||||
this.exitPromise = new Promise((exitResolve) => {
|
||||
child.once("exit", (code, signal) => {
|
||||
clearTimeout(readyTimeout);
|
||||
exitResolve({ code, signal });
|
||||
if (!this.closing || code !== 0 || signal !== null) {
|
||||
this.poison(
|
||||
fail(
|
||||
new Error(
|
||||
`PowerShell completion runner exited unexpectedly with code ${String(code)} signal ${String(signal)}`,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -819,13 +819,6 @@ vi.mock("./doctor/shared/active-tool-schema-warnings.js", () => ({
|
|||
collectActiveToolSchemaProjectionWarnings: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
vi.mock("./doctor/shared/plugin-dependency-cleanup.js", () => ({
|
||||
cleanupLegacyPluginDependencyState: vi.fn(async () => ({
|
||||
changes: [],
|
||||
warnings: [],
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("./doctor/shared/stale-oauth-profile-shadows.js", () => ({
|
||||
repairStaleOAuthProfileShadows: vi.fn(async () => ({
|
||||
changes: [],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,194 @@
|
|||
import { fork } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { once } from "node:events";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { runDoctorRepairSequence } from "./repair-sequencing.js";
|
||||
|
||||
export function registerSharedRuntimeReaderDoctorTests(): void {
|
||||
describe("shared plugin runtime", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.realpath(
|
||||
await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-doctor-shared-runtime-")),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ selector: "STATE_DIRECTORY", sameInstall: false },
|
||||
{ selector: "STATE_DIRECTORY", sameInstall: true },
|
||||
{ selector: "OPENCLAW_PLUGIN_STAGE_DIR", sameInstall: false },
|
||||
{ selector: "OPENCLAW_PLUGIN_STAGE_DIR", sameInstall: true },
|
||||
])(
|
||||
"preserves a live shared runtime reader through Doctor ($selector, same install: $sameInstall)",
|
||||
async ({ selector, sameInstall }) => {
|
||||
const stateA = path.join(tempDir, ".openclaw");
|
||||
const stateB = path.join(tempDir, ".openclaw-peer");
|
||||
const packageRoot = path.join(tempDir, "prefix-a", "node_modules", "openclaw");
|
||||
const readerPackageRoot = sameInstall
|
||||
? packageRoot
|
||||
: path.join(tempDir, "prefix-b", "node_modules", "openclaw");
|
||||
const sharedBase = path.join(
|
||||
tempDir,
|
||||
selector === "STATE_DIRECTORY"
|
||||
? "shared/plugin-runtime-deps"
|
||||
: ".openclaw-install-stage-shared",
|
||||
);
|
||||
// Stable v2026.4.29 buckets are keyed by version and install path, not by
|
||||
// profile/database. Doctor cannot authorize deletion for another consumer.
|
||||
const packageHash = createHash("sha256")
|
||||
.update(readerPackageRoot)
|
||||
.digest("hex")
|
||||
.slice(0, 12);
|
||||
const bucket = path.join(sharedBase, `openclaw-2026.4.29-${packageHash}`);
|
||||
const dependency = path.join(bucket, "node_modules", "fixture-runtime-dependency");
|
||||
const mirror = path.join(bucket, "dist", "extensions", "fixture-reader");
|
||||
const alias = path.join(path.dirname(readerPackageRoot), "fixture-runtime-dependency");
|
||||
const unknownFile = path.join(sharedBase, "unknown-consumer", "keep.txt");
|
||||
for (const directory of [
|
||||
packageRoot,
|
||||
readerPackageRoot,
|
||||
dependency,
|
||||
mirror,
|
||||
path.dirname(unknownFile),
|
||||
]) {
|
||||
await fs.mkdir(directory, { recursive: true });
|
||||
}
|
||||
for (const stateDir of [stateA, stateB]) {
|
||||
await fs.mkdir(path.join(stateDir, "state"), { recursive: true });
|
||||
const database = new DatabaseSync(path.join(stateDir, "state", "openclaw.sqlite"));
|
||||
database.exec(`
|
||||
PRAGMA user_version = 8;
|
||||
CREATE TABLE agent_databases (
|
||||
agent_id TEXT NOT NULL, path TEXT NOT NULL, schema_version INTEGER NOT NULL,
|
||||
last_seen_at INTEGER NOT NULL, size_bytes INTEGER,
|
||||
PRIMARY KEY (agent_id, path)
|
||||
);
|
||||
`);
|
||||
database.close();
|
||||
}
|
||||
for (const installRoot of new Set([packageRoot, readerPackageRoot])) {
|
||||
await fs.writeFile(
|
||||
path.join(installRoot, "package.json"),
|
||||
'{"name":"openclaw","version":"2026.4.29"}\n',
|
||||
);
|
||||
}
|
||||
await fs.writeFile(path.join(dependency, "package.json"), '{"main":"index.cjs"}\n');
|
||||
await fs.writeFile(
|
||||
path.join(dependency, "index.cjs"),
|
||||
'exports.value = "dependency-ready";\n',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(dependency, "late.cjs"),
|
||||
'exports.value = "dependency-survived";\n',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(mirror, "index.cjs"),
|
||||
'exports.value = "mirror-ready"; exports.readLater = () => require("./late.cjs").value;\n',
|
||||
);
|
||||
await fs.writeFile(path.join(mirror, "late.cjs"), 'exports.value = "mirror-survived";\n');
|
||||
await fs.writeFile(unknownFile, "another consumer's files\n");
|
||||
await fs.symlink(dependency, alias, "junction");
|
||||
const readerFile = path.join(tempDir, "runtime-reader.cjs");
|
||||
await fs.writeFile(
|
||||
readerFile,
|
||||
`const { DatabaseSync } = require("node:sqlite");
|
||||
const { createRequire } = require("node:module");
|
||||
const [databasePath, packageRoot, modulePath] = process.argv.slice(2);
|
||||
const database = new DatabaseSync(databasePath, { readOnly: true });
|
||||
const fromInstall = createRequire(require("node:path").join(packageRoot, "index.cjs"));
|
||||
const mirror = require(modulePath);
|
||||
const version = () => database.prepare("PRAGMA user_version").get().user_version;
|
||||
process.on("message", (message) => {
|
||||
if (message === "close") {
|
||||
database.close();
|
||||
process.disconnect();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
process.send({ kind: "read", version: version(), dependency: fromInstall("fixture-runtime-dependency/late.cjs").value, mirror: mirror.readLater() });
|
||||
} catch (error) {
|
||||
process.send({ kind: "read", version: version(), errorCode: error.code });
|
||||
}
|
||||
});
|
||||
process.send({ kind: "ready", version: version(), dependency: fromInstall("fixture-runtime-dependency").value, mirror: mirror.value });
|
||||
`,
|
||||
);
|
||||
const reader = fork(
|
||||
readerFile,
|
||||
[
|
||||
path.join(stateB, "state", "openclaw.sqlite"),
|
||||
readerPackageRoot,
|
||||
path.join(mirror, "index.cjs"),
|
||||
],
|
||||
{
|
||||
cwd: stateB,
|
||||
execArgv: [],
|
||||
env: {
|
||||
HOME: tempDir,
|
||||
USERPROFILE: tempDir,
|
||||
OPENCLAW_HOME: tempDir,
|
||||
OPENCLAW_STATE_DIR: stateB,
|
||||
[selector]: selector === "STATE_DIRECTORY" ? path.dirname(sharedBase) : sharedBase,
|
||||
},
|
||||
stdio: ["ignore", "ignore", "ignore", "ipc"],
|
||||
},
|
||||
);
|
||||
const closed = once(reader, "close");
|
||||
const nextMessage = () =>
|
||||
Promise.race([
|
||||
once(reader, "message").then((messages: unknown[]) => messages[0]),
|
||||
closed.then(([code, signal]) => {
|
||||
throw new Error(`runtime reader exited before replying: ${code}/${signal}`);
|
||||
}),
|
||||
]);
|
||||
try {
|
||||
await expect(nextMessage()).resolves.toEqual({
|
||||
kind: "ready",
|
||||
version: 8,
|
||||
dependency: "dependency-ready",
|
||||
mirror: "mirror-ready",
|
||||
});
|
||||
await fs.writeFile(
|
||||
path.join(packageRoot, "package.json"),
|
||||
'{"name":"openclaw","version":"2026.8.1"}\n',
|
||||
);
|
||||
await runDoctorRepairSequence({
|
||||
state: { cfg: {}, candidate: {}, pendingChanges: false, fixHints: [] },
|
||||
doctorFixCommand: "openclaw doctor --fix",
|
||||
env: {
|
||||
HOME: tempDir,
|
||||
USERPROFILE: tempDir,
|
||||
OPENCLAW_HOME: tempDir,
|
||||
OPENCLAW_STATE_DIR: stateA,
|
||||
[selector]: selector === "STATE_DIRECTORY" ? path.dirname(sharedBase) : sharedBase,
|
||||
},
|
||||
});
|
||||
const response = nextMessage();
|
||||
reader.send("read");
|
||||
await expect(response).resolves.toEqual({
|
||||
kind: "read",
|
||||
version: 8,
|
||||
dependency: "dependency-survived",
|
||||
mirror: "mirror-survived",
|
||||
});
|
||||
expect((await fs.lstat(alias)).isSymbolicLink()).toBe(true);
|
||||
expect(await fs.readFile(unknownFile, "utf8")).toBe("another consumer's files\n");
|
||||
} finally {
|
||||
if (reader.connected) {
|
||||
reader.send("close");
|
||||
}
|
||||
await expect(closed).resolves.toEqual([0, null]);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.js";
|
||||
import { runDoctorRepairSequence } from "./repair-sequencing.js";
|
||||
import { registerSharedRuntimeReaderDoctorTests } from "./repair-sequencing.shared-runtime.test-support.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
applyPluginAutoEnable: vi.fn(),
|
||||
|
|
@ -236,13 +237,6 @@ vi.mock("./shared/exec-safe-bins.js", () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./shared/plugin-dependency-cleanup.js", () => ({
|
||||
cleanupLegacyPluginDependencyState: async () => ({
|
||||
changes: [],
|
||||
warnings: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("doctor repair sequencing", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
|
@ -332,6 +326,8 @@ describe("doctor repair sequencing", () => {
|
|||
}));
|
||||
});
|
||||
|
||||
registerSharedRuntimeReaderDoctorTests();
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "Tailscale profile identity migration",
|
||||
|
|
|
|||
|
|
@ -45,11 +45,11 @@ import type { BlockedLegacyOpenAICodexProviderPlan } from "./shared/legacy-confi
|
|||
import { maybeRepairLegacyToolsBySenderKeys } from "./shared/legacy-tools-by-sender.js";
|
||||
import { repairMissingConfiguredPluginInstalls } from "./shared/missing-configured-plugin-install.js";
|
||||
import { maybeRepairOpenPolicyAllowFrom } from "./shared/open-policy-allowfrom.js";
|
||||
import { cleanupLegacyPluginDependencyState } from "./shared/plugin-dependency-cleanup.js";
|
||||
import {
|
||||
resolveConfigWideDoctorPluginMetadataSnapshot,
|
||||
type DoctorPluginMetadataSnapshotState,
|
||||
} from "./shared/plugin-metadata-snapshot-scope.js";
|
||||
import { removeStalePluginRuntimeSymlinks } from "./shared/plugin-runtime-symlinks.js";
|
||||
import { repairStaleAgentModelRefs } from "./shared/stale-agent-model-ref-repair.js";
|
||||
import { maybeRepairStaleConfiguredAuthOrders } from "./shared/stale-auth-order.js";
|
||||
import { repairStaleOAuthProfileShadows } from "./shared/stale-oauth-profile-shadows.js";
|
||||
|
|
@ -334,7 +334,7 @@ export async function runDoctorRepairSequence(params: {
|
|||
appendRepairNotes(await migrateLegacySkillWorkshopProposals({ config: state.candidate, env }));
|
||||
appendRepairNotes(migrateLegacyTailscaleProfileIdentities({ env }));
|
||||
appendRepairNotes(repairMergedGatewayOwnerProfile({ env, shouldRepair: true }));
|
||||
appendRepairNotes(await cleanupLegacyPluginDependencyState({ env }));
|
||||
appendRepairNotes(await removeStalePluginRuntimeSymlinks());
|
||||
const legacyOAuthSidecarRepair = await maybeRepairLegacyOAuthSidecarProfiles({
|
||||
cfg: state.candidate,
|
||||
prompter: { confirmAutoFix: async () => true },
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
import "./plugin-dependency-cleanup.js";
|
||||
|
||||
type TestApi = {
|
||||
collectLegacyPluginDependencyTargets(
|
||||
env?: NodeJS.ProcessEnv,
|
||||
options?: { packageRoot?: string | null },
|
||||
): Promise<string[]>;
|
||||
};
|
||||
|
||||
function getTestApi(): TestApi {
|
||||
return (globalThis as Record<PropertyKey, unknown>)[
|
||||
Symbol.for("openclaw.pluginDependencyCleanupTestApi")
|
||||
] as TestApi;
|
||||
}
|
||||
|
||||
export const collectLegacyPluginDependencyTargets: TestApi["collectLegacyPluginDependencyTargets"] =
|
||||
(env, options) => getTestApi().collectLegacyPluginDependencyTargets(env, options);
|
||||
|
|
@ -1,366 +0,0 @@
|
|||
// Plugin dependency cleanup tests cover doctor cleanup of stale plugin dependencies.
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
cleanupLegacyPluginDependencyState,
|
||||
detectLegacyPluginDependencyStateIssues,
|
||||
legacyPluginDependencyStateIssueToHealthFinding,
|
||||
} from "./plugin-dependency-cleanup.js";
|
||||
import { collectLegacyPluginDependencyTargets } from "./plugin-dependency-cleanup.test-support.js";
|
||||
|
||||
async function expectPathMissing(targetPath: string): Promise<void> {
|
||||
try {
|
||||
await fs.stat(targetPath);
|
||||
} catch (error) {
|
||||
expect((error as NodeJS.ErrnoException).code).toBe("ENOENT");
|
||||
return;
|
||||
}
|
||||
throw new Error(`expected path to be missing: ${targetPath}`);
|
||||
}
|
||||
|
||||
async function expectDirectoryPresent(targetPath: string): Promise<void> {
|
||||
expect((await fs.stat(targetPath)).isDirectory()).toBe(true);
|
||||
}
|
||||
|
||||
describe("cleanupLegacyPluginDependencyState", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-plugin-deps-cleanup-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("collects and removes legacy plugin dependency state roots", async () => {
|
||||
const stateDir = path.join(tempDir, "state");
|
||||
const explicitStageDir = path.join(stateDir, ".openclaw-install-stage-explicit");
|
||||
const stateDirectory = path.join(tempDir, "systemd-state");
|
||||
const packageRoot = path.join(tempDir, "package");
|
||||
const legacyRuntimeRoot = path.join(stateDir, "plugin-runtime-deps");
|
||||
const legacyLocalRoot = path.join(stateDir, ".local", "bundled-plugin-runtime-deps");
|
||||
const legacyExtensionNodeModules = path.join(
|
||||
packageRoot,
|
||||
"dist",
|
||||
"extensions",
|
||||
"demo",
|
||||
"node_modules",
|
||||
);
|
||||
const legacyExtensionStamp = path.join(
|
||||
packageRoot,
|
||||
"dist",
|
||||
"extensions",
|
||||
"demo",
|
||||
".openclaw-runtime-deps-stamp.json",
|
||||
);
|
||||
const legacyManifest = path.join(
|
||||
packageRoot,
|
||||
"extensions",
|
||||
"demo",
|
||||
".openclaw-runtime-deps.json",
|
||||
);
|
||||
const thirdPartyNodeModules = path.join(
|
||||
stateDir,
|
||||
"extensions",
|
||||
"lossless-claw",
|
||||
"node_modules",
|
||||
);
|
||||
|
||||
await fs.mkdir(legacyRuntimeRoot, { recursive: true });
|
||||
await fs.mkdir(legacyLocalRoot, { recursive: true });
|
||||
await fs.mkdir(legacyExtensionNodeModules, { recursive: true });
|
||||
await fs.writeFile(legacyExtensionStamp, "{}");
|
||||
await fs.mkdir(path.dirname(legacyManifest), { recursive: true });
|
||||
await fs.writeFile(legacyManifest, "{}");
|
||||
await fs.mkdir(thirdPartyNodeModules, { recursive: true });
|
||||
await fs.mkdir(explicitStageDir, { recursive: true });
|
||||
await fs.mkdir(path.join(stateDirectory, "plugin-runtime-deps"), { recursive: true });
|
||||
|
||||
const env = {
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
OPENCLAW_PLUGIN_STAGE_DIR: explicitStageDir,
|
||||
STATE_DIRECTORY: stateDirectory,
|
||||
};
|
||||
const targets = await collectLegacyPluginDependencyTargets(env, { packageRoot });
|
||||
expect(targets).toContain(legacyRuntimeRoot);
|
||||
expect(targets).toContain(legacyLocalRoot);
|
||||
expect(targets).toContain(legacyExtensionNodeModules);
|
||||
expect(targets).toContain(legacyExtensionStamp);
|
||||
expect(targets).toContain(legacyManifest);
|
||||
expect(targets).toContain(explicitStageDir);
|
||||
expect(targets).toContain(path.join(stateDirectory, "plugin-runtime-deps"));
|
||||
expect(targets).not.toContain(thirdPartyNodeModules);
|
||||
|
||||
const issues = await detectLegacyPluginDependencyStateIssues({ env, packageRoot });
|
||||
expect(issues).toContainEqual({
|
||||
kind: "legacy-plugin-dependency-state",
|
||||
path: legacyRuntimeRoot,
|
||||
});
|
||||
expect(issues).toContainEqual({
|
||||
kind: "legacy-plugin-dependency-state",
|
||||
path: legacyLocalRoot,
|
||||
});
|
||||
expect(issues).toContainEqual({
|
||||
kind: "legacy-plugin-dependency-state",
|
||||
path: explicitStageDir,
|
||||
});
|
||||
expect(issues.some((issue) => issue.path === thirdPartyNodeModules)).toBe(false);
|
||||
await expectDirectoryPresent(legacyRuntimeRoot);
|
||||
|
||||
const result = await cleanupLegacyPluginDependencyState({ env, packageRoot });
|
||||
|
||||
expect(result.warnings).toStrictEqual([]);
|
||||
expect(result.changes.length).toBeGreaterThanOrEqual(6);
|
||||
await expectPathMissing(legacyRuntimeRoot);
|
||||
await expectPathMissing(legacyLocalRoot);
|
||||
await expectPathMissing(legacyExtensionNodeModules);
|
||||
await expectPathMissing(legacyExtensionStamp);
|
||||
await expectPathMissing(legacyManifest);
|
||||
await expectDirectoryPresent(thirdPartyNodeModules);
|
||||
await expectPathMissing(explicitStageDir);
|
||||
await expectPathMissing(path.join(stateDirectory, "plugin-runtime-deps"));
|
||||
});
|
||||
|
||||
it("removes configured plugin stage roots outside OpenClaw roots", async () => {
|
||||
const stateDir = path.join(tempDir, "state");
|
||||
const packageRoot = path.join(tempDir, "package");
|
||||
const stageRoot = path.join(tempDir, "stage");
|
||||
|
||||
await fs.mkdir(stateDir, { recursive: true });
|
||||
await fs.mkdir(packageRoot, { recursive: true });
|
||||
await fs.mkdir(path.join(stageRoot, "node_modules", "ansi-escapes"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(stageRoot, "node_modules", "ansi-escapes", ".openclaw-rename-tmp"),
|
||||
"corrupt rename residue\n",
|
||||
);
|
||||
|
||||
const result = await cleanupLegacyPluginDependencyState({
|
||||
env: {
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
OPENCLAW_PLUGIN_STAGE_DIR: stageRoot,
|
||||
},
|
||||
packageRoot,
|
||||
});
|
||||
|
||||
expect(result.warnings).toStrictEqual([]);
|
||||
expect(result.changes).toContain(`Removed legacy plugin dependency state: ${stageRoot}`);
|
||||
await expectPathMissing(stageRoot);
|
||||
});
|
||||
|
||||
it("maps legacy dependency state issues to lint findings", async () => {
|
||||
const stateDir = path.join(tempDir, "state");
|
||||
const packageRoot = path.join(tempDir, "package");
|
||||
const legacyRuntimeRoot = path.join(stateDir, "plugin-runtime-deps");
|
||||
|
||||
await fs.mkdir(legacyRuntimeRoot, { recursive: true });
|
||||
await fs.mkdir(packageRoot, { recursive: true });
|
||||
|
||||
const [issue] = await detectLegacyPluginDependencyStateIssues({
|
||||
env: { OPENCLAW_STATE_DIR: stateDir },
|
||||
packageRoot,
|
||||
});
|
||||
|
||||
expect(issue).toEqual({
|
||||
kind: "legacy-plugin-dependency-state",
|
||||
path: legacyRuntimeRoot,
|
||||
});
|
||||
expect(
|
||||
legacyPluginDependencyStateIssueToHealthFinding(expectDefined(issue, "issue test invariant")),
|
||||
).toEqual({
|
||||
checkId: "core/doctor/legacy-plugin-dependencies",
|
||||
severity: "warning",
|
||||
message: `Legacy plugin dependency state remains at ${legacyRuntimeRoot}.`,
|
||||
target: legacyRuntimeRoot,
|
||||
path: legacyRuntimeRoot,
|
||||
requirement: "legacy-plugin-dependency-state-removed",
|
||||
fixHint: "Run `openclaw doctor --fix` to remove legacy plugin dependency state.",
|
||||
});
|
||||
await expectDirectoryPresent(legacyRuntimeRoot);
|
||||
});
|
||||
|
||||
it("refuses arbitrary explicit plugin stage roots outside OpenClaw roots", async () => {
|
||||
const stateDir = path.join(tempDir, "state");
|
||||
const packageRoot = path.join(tempDir, "package");
|
||||
const stageRoot = path.join(tempDir, "stage-without-marker");
|
||||
|
||||
await fs.mkdir(stateDir, { recursive: true });
|
||||
await fs.mkdir(packageRoot, { recursive: true });
|
||||
await fs.mkdir(path.join(stageRoot, "node_modules", "ansi-escapes"), { recursive: true });
|
||||
|
||||
const result = await cleanupLegacyPluginDependencyState({
|
||||
env: {
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
OPENCLAW_PLUGIN_STAGE_DIR: stageRoot,
|
||||
},
|
||||
packageRoot,
|
||||
});
|
||||
|
||||
expect(result.changes).toStrictEqual([]);
|
||||
expect(result.warnings).toContain(
|
||||
`Skipped legacy plugin dependency state ${stageRoot}: unexpected path name`,
|
||||
);
|
||||
await expectDirectoryPresent(stageRoot);
|
||||
});
|
||||
|
||||
it("refuses explicit plugin stage paths with parent segments", async () => {
|
||||
const stateDir = path.join(tempDir, "state");
|
||||
const packageRoot = path.join(tempDir, "package");
|
||||
const dotDotStage = `${stateDir}${path.sep}..${path.sep}.openclaw-install-stage-dotdot`;
|
||||
const resolvedDotDotStage = path.resolve(dotDotStage);
|
||||
|
||||
await fs.mkdir(stateDir, { recursive: true });
|
||||
await fs.mkdir(packageRoot, { recursive: true });
|
||||
await fs.mkdir(resolvedDotDotStage, { recursive: true });
|
||||
|
||||
const result = await cleanupLegacyPluginDependencyState({
|
||||
env: {
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
OPENCLAW_PLUGIN_STAGE_DIR: dotDotStage,
|
||||
},
|
||||
packageRoot,
|
||||
});
|
||||
|
||||
expect(result.changes).toStrictEqual([]);
|
||||
expect(result.warnings).toContain(
|
||||
`Skipped legacy plugin dependency state ${resolvedDotDotStage}: parent path segments are not allowed`,
|
||||
);
|
||||
await expectDirectoryPresent(resolvedDotDotStage);
|
||||
});
|
||||
|
||||
it("does not follow symlinked extension roots outside OpenClaw roots", async () => {
|
||||
const stateDir = path.join(tempDir, "state");
|
||||
const packageRoot = path.join(tempDir, "package");
|
||||
const extensionsRoot = path.join(packageRoot, "extensions");
|
||||
const linkedPlugin = path.join(extensionsRoot, "linked-plugin");
|
||||
const externalPlugin = path.join(tempDir, "external-plugin");
|
||||
const externalNodeModules = path.join(externalPlugin, "node_modules");
|
||||
|
||||
await fs.mkdir(stateDir, { recursive: true });
|
||||
await fs.mkdir(extensionsRoot, { recursive: true });
|
||||
await fs.mkdir(externalNodeModules, { recursive: true });
|
||||
await fs.writeFile(path.join(externalPlugin, ".openclaw-runtime-deps.json"), "{}");
|
||||
await fs.symlink(externalPlugin, linkedPlugin, "dir");
|
||||
|
||||
const targets = await collectLegacyPluginDependencyTargets(
|
||||
{ OPENCLAW_STATE_DIR: stateDir },
|
||||
{ packageRoot },
|
||||
);
|
||||
expect(targets).not.toContain(path.join(linkedPlugin, "node_modules"));
|
||||
|
||||
const result = await cleanupLegacyPluginDependencyState({
|
||||
env: { OPENCLAW_STATE_DIR: stateDir },
|
||||
packageRoot,
|
||||
});
|
||||
|
||||
expect(result.warnings).toStrictEqual([]);
|
||||
await expectDirectoryPresent(externalNodeModules);
|
||||
});
|
||||
|
||||
it("refuses legacy roots that resolve outside OpenClaw roots", async () => {
|
||||
const stateDir = path.join(tempDir, "state");
|
||||
const packageRoot = path.join(tempDir, "package");
|
||||
const legacyRuntimeRoot = path.join(stateDir, "plugin-runtime-deps");
|
||||
const externalRuntimeRoot = path.join(tempDir, "external-runtime");
|
||||
|
||||
await fs.mkdir(stateDir, { recursive: true });
|
||||
await fs.mkdir(packageRoot, { recursive: true });
|
||||
await fs.mkdir(externalRuntimeRoot, { recursive: true });
|
||||
await fs.symlink(externalRuntimeRoot, legacyRuntimeRoot, "dir");
|
||||
|
||||
const result = await cleanupLegacyPluginDependencyState({
|
||||
env: { OPENCLAW_STATE_DIR: stateDir },
|
||||
packageRoot,
|
||||
});
|
||||
|
||||
expect(result.changes).toStrictEqual([]);
|
||||
expect(result.warnings).toContain(
|
||||
`Skipped legacy plugin dependency state ${legacyRuntimeRoot}: resolved outside OpenClaw cleanup roots`,
|
||||
);
|
||||
expect((await fs.lstat(legacyRuntimeRoot)).isSymbolicLink()).toBe(true);
|
||||
await expectDirectoryPresent(externalRuntimeRoot);
|
||||
});
|
||||
|
||||
it("does not unlink global runtime symlinks through unsafe cleanup roots", async () => {
|
||||
const stateDir = path.join(tempDir, "state");
|
||||
const packageRoot = path.join(tempDir, "prefix", "lib", "node_modules", "openclaw");
|
||||
const nodeModulesRoot = path.dirname(packageRoot);
|
||||
const legacyRuntimeRoot = path.join(stateDir, "plugin-runtime-deps");
|
||||
const externalRuntimeRoot = path.join(tempDir, "external-runtime");
|
||||
const activeRuntimeTarget = path.join(
|
||||
externalRuntimeRoot,
|
||||
"openclaw-external",
|
||||
"node_modules",
|
||||
"left-pad",
|
||||
);
|
||||
const unsafeRuntimeTarget = path.join(
|
||||
legacyRuntimeRoot,
|
||||
"openclaw-external",
|
||||
"node_modules",
|
||||
"left-pad",
|
||||
);
|
||||
const leftPadLink = path.join(nodeModulesRoot, "left-pad");
|
||||
|
||||
await fs.mkdir(stateDir, { recursive: true });
|
||||
await fs.mkdir(packageRoot, { recursive: true });
|
||||
await fs.mkdir(activeRuntimeTarget, { recursive: true });
|
||||
await fs.symlink(externalRuntimeRoot, legacyRuntimeRoot, "dir");
|
||||
await fs.symlink(unsafeRuntimeTarget, leftPadLink, "dir");
|
||||
|
||||
const result = await cleanupLegacyPluginDependencyState({
|
||||
env: { OPENCLAW_STATE_DIR: stateDir },
|
||||
packageRoot,
|
||||
});
|
||||
|
||||
expect(result.changes).toStrictEqual([]);
|
||||
expect(result.warnings).toContain(
|
||||
`Skipped legacy plugin dependency state ${legacyRuntimeRoot}: resolved outside OpenClaw cleanup roots`,
|
||||
);
|
||||
expect((await fs.lstat(leftPadLink)).isSymbolicLink()).toBe(true);
|
||||
expect((await fs.lstat(legacyRuntimeRoot)).isSymbolicLink()).toBe(true);
|
||||
await expectDirectoryPresent(activeRuntimeTarget);
|
||||
});
|
||||
|
||||
it("removes dangling global plugin-runtime symlinks that point at legacy runtime deps", async () => {
|
||||
const stateDir = path.join(tempDir, "state");
|
||||
const packageRoot = path.join(tempDir, "prefix", "lib", "node_modules", "openclaw");
|
||||
const nodeModulesRoot = path.dirname(packageRoot);
|
||||
const legacyRuntimeRoot = path.join(stateDir, "plugin-runtime-deps");
|
||||
const legacyTarget = path.join(
|
||||
legacyRuntimeRoot,
|
||||
"openclaw-2026.4.29-slack",
|
||||
"node_modules",
|
||||
"@slack",
|
||||
"web-api",
|
||||
);
|
||||
const slackScope = path.join(nodeModulesRoot, "@slack");
|
||||
const slackLink = path.join(slackScope, "web-api");
|
||||
const liveTarget = path.join(tempDir, "live", "@slack", "bolt");
|
||||
const liveLink = path.join(slackScope, "bolt");
|
||||
|
||||
await fs.mkdir(legacyTarget, { recursive: true });
|
||||
await fs.writeFile(path.join(legacyTarget, "package.json"), "{}\n");
|
||||
await fs.mkdir(liveTarget, { recursive: true });
|
||||
await fs.writeFile(path.join(liveTarget, "package.json"), "{}\n");
|
||||
await fs.mkdir(slackScope, { recursive: true });
|
||||
await fs.mkdir(packageRoot, { recursive: true });
|
||||
await fs.symlink(legacyTarget, slackLink, "dir");
|
||||
await fs.symlink(liveTarget, liveLink, "dir");
|
||||
|
||||
const result = await cleanupLegacyPluginDependencyState({
|
||||
env: { OPENCLAW_STATE_DIR: stateDir },
|
||||
packageRoot,
|
||||
});
|
||||
|
||||
expect(result.warnings).toStrictEqual([]);
|
||||
expect(result.changes).toStrictEqual([
|
||||
`Removed stale plugin-runtime symlink: ${slackLink}`,
|
||||
`Removed legacy plugin dependency state: ${legacyRuntimeRoot}`,
|
||||
]);
|
||||
await expectPathMissing(slackLink);
|
||||
expect((await fs.lstat(liveLink)).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,472 +0,0 @@
|
|||
// Doctor cleanup for stale plugin dependency install state and dangling runtime symlinks.
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { resolveStateDir } from "../../../config/paths.js";
|
||||
import type { HealthFinding } from "../../../flows/health-checks.js";
|
||||
import { resolveOpenClawPackageRootSync } from "../../../infra/openclaw-root.js";
|
||||
import { isPathInside } from "../../../infra/path-safety.js";
|
||||
import { resolveConfigDir, resolveUserPath } from "../../../utils.js";
|
||||
import { removeStalePluginRuntimeSymlinks } from "./plugin-runtime-symlinks.js";
|
||||
|
||||
const LEGACY_DIRECT_CHILD_NAMES = new Set(["plugin-runtime-deps", "bundled-plugin-runtime-deps"]);
|
||||
|
||||
interface CleanupRoot {
|
||||
readonly realPath: string;
|
||||
}
|
||||
|
||||
interface CleanupTarget {
|
||||
readonly kind: "explicit-stage" | "legacy";
|
||||
readonly path: string;
|
||||
readonly rawPath?: string;
|
||||
}
|
||||
|
||||
interface LegacyPluginDependencyStateIssue {
|
||||
readonly kind: "legacy-plugin-dependency-state";
|
||||
readonly path: string;
|
||||
}
|
||||
|
||||
function uniqueSorted(values: Iterable<string | null | undefined>): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
[...values]
|
||||
.filter((value): value is string => typeof value === "string" && value.length > 0)
|
||||
.map((value) => path.resolve(value)),
|
||||
),
|
||||
].toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function splitPathList(value: string | undefined): string[] {
|
||||
return value
|
||||
? value
|
||||
.split(path.delimiter)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
}
|
||||
|
||||
function hasParentPathSegment(value: string): boolean {
|
||||
return value.split(/[\\/]+/u).includes("..");
|
||||
}
|
||||
|
||||
async function pathExists(targetPath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.lstat(targetPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isRuntimeDependencyMarkerName(name: string): boolean {
|
||||
return (
|
||||
name === ".openclaw-runtime-deps.json" ||
|
||||
name === ".openclaw-runtime-deps-stamp.json" ||
|
||||
name.startsWith(".openclaw-runtime-deps-")
|
||||
);
|
||||
}
|
||||
|
||||
function isInstallStageDebrisName(name: string): boolean {
|
||||
return /^\.openclaw-install-stage(?:-.+)?$/u.test(name);
|
||||
}
|
||||
|
||||
function isLegacyDependencyDebrisName(name: string): boolean {
|
||||
return (
|
||||
isRuntimeDependencyMarkerName(name) ||
|
||||
name === ".openclaw-pnpm-store" ||
|
||||
name === ".openclaw-install-backups" ||
|
||||
isInstallStageDebrisName(name)
|
||||
);
|
||||
}
|
||||
|
||||
function isExpectedLegacyCleanupTargetName(name: string): boolean {
|
||||
return (
|
||||
name === "node_modules" ||
|
||||
LEGACY_DIRECT_CHILD_NAMES.has(name) ||
|
||||
isLegacyDependencyDebrisName(name)
|
||||
);
|
||||
}
|
||||
|
||||
async function isFile(targetPath: string): Promise<boolean> {
|
||||
const stat = await fs.lstat(targetPath).catch(() => null);
|
||||
return stat?.isFile() === true;
|
||||
}
|
||||
|
||||
async function collectDirectChildren(root: string): Promise<string[]> {
|
||||
const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
|
||||
return entries.map((entry) => path.join(root, entry.name));
|
||||
}
|
||||
|
||||
async function isDirectoryInCleanupRoot(
|
||||
candidate: string,
|
||||
cleanupRootRealPath: string,
|
||||
): Promise<boolean> {
|
||||
const stat = await fs.lstat(candidate).catch(() => null);
|
||||
if (!stat?.isDirectory() && !stat?.isSymbolicLink()) {
|
||||
return false;
|
||||
}
|
||||
const realPath = await fs.realpath(candidate).catch(() => null);
|
||||
return realPath !== null && isPathInside(cleanupRootRealPath, realPath);
|
||||
}
|
||||
|
||||
async function collectLegacyExtensionDebris(
|
||||
extensionsRoot: string,
|
||||
cleanupRootRealPath: string,
|
||||
): Promise<string[]> {
|
||||
if (!(await isDirectoryInCleanupRoot(extensionsRoot, cleanupRootRealPath))) {
|
||||
return [];
|
||||
}
|
||||
const pluginDirs = await fs.readdir(extensionsRoot, { withFileTypes: true }).catch(() => []);
|
||||
const targets: string[] = [];
|
||||
for (const entry of pluginDirs) {
|
||||
if (!entry.isDirectory() && !entry.isSymbolicLink()) {
|
||||
continue;
|
||||
}
|
||||
const pluginRoot = path.join(extensionsRoot, entry.name);
|
||||
if (!(await isDirectoryInCleanupRoot(pluginRoot, cleanupRootRealPath))) {
|
||||
continue;
|
||||
}
|
||||
const children = await collectDirectChildren(pluginRoot);
|
||||
const hasRuntimeDepsMarker = children.some((childPath) =>
|
||||
isRuntimeDependencyMarkerName(path.basename(childPath)),
|
||||
);
|
||||
for (const childPath of children) {
|
||||
const basename = path.basename(childPath);
|
||||
if (basename === "node_modules" && hasRuntimeDepsMarker) {
|
||||
targets.push(childPath);
|
||||
continue;
|
||||
}
|
||||
if (isLegacyDependencyDebrisName(basename)) {
|
||||
targets.push(childPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
|
||||
function collectCleanupRootPaths(
|
||||
env: NodeJS.ProcessEnv,
|
||||
packageRoot: string | null | undefined,
|
||||
): string[] {
|
||||
const stateDirectoryRoots = splitPathList(env.STATE_DIRECTORY).map((entry) =>
|
||||
resolveUserPath(entry, env),
|
||||
);
|
||||
return uniqueSorted([
|
||||
resolveStateDir(env),
|
||||
resolveConfigDir(env),
|
||||
packageRoot,
|
||||
...stateDirectoryRoots,
|
||||
]);
|
||||
}
|
||||
|
||||
async function collectExistingCleanupRoots(
|
||||
cleanupRootPaths: readonly string[],
|
||||
): Promise<CleanupRoot[]> {
|
||||
const roots: CleanupRoot[] = [];
|
||||
for (const rootPath of cleanupRootPaths) {
|
||||
const stat = await fs.stat(rootPath).catch(() => null);
|
||||
if (!stat?.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
const realPath = await fs.realpath(rootPath).catch(() => null);
|
||||
if (realPath === null) {
|
||||
continue;
|
||||
}
|
||||
roots.push({ realPath });
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
function collectExplicitStageTargets(env: NodeJS.ProcessEnv): CleanupTarget[] {
|
||||
return splitPathList(env.OPENCLAW_PLUGIN_STAGE_DIR).map((entry) => ({
|
||||
kind: "explicit-stage",
|
||||
path: resolveUserPath(entry, env),
|
||||
rawPath: entry,
|
||||
}));
|
||||
}
|
||||
|
||||
async function hasOpenClawRenameResidue(root: string): Promise<boolean> {
|
||||
const nodeModulesRoot = path.join(root, "node_modules");
|
||||
if (await isFile(path.join(nodeModulesRoot, ".openclaw-rename-tmp"))) {
|
||||
return true;
|
||||
}
|
||||
const entries = await fs.readdir(nodeModulesRoot, { withFileTypes: true }).catch(() => []);
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.isSymbolicLink()) {
|
||||
continue;
|
||||
}
|
||||
const entryPath = path.join(nodeModulesRoot, entry.name);
|
||||
if (await isFile(path.join(entryPath, ".openclaw-rename-tmp"))) {
|
||||
return true;
|
||||
}
|
||||
if (!entry.name.startsWith("@")) {
|
||||
continue;
|
||||
}
|
||||
const scopedEntries = await fs.readdir(entryPath, { withFileTypes: true }).catch(() => []);
|
||||
for (const scopedEntry of scopedEntries) {
|
||||
if (!scopedEntry.isDirectory() || scopedEntry.isSymbolicLink()) {
|
||||
continue;
|
||||
}
|
||||
if (await isFile(path.join(entryPath, scopedEntry.name, ".openclaw-rename-tmp"))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function hasExplicitStageDebrisProof(root: string): Promise<boolean> {
|
||||
const children = await collectDirectChildren(root);
|
||||
if (children.some((childPath) => isRuntimeDependencyMarkerName(path.basename(childPath)))) {
|
||||
return true;
|
||||
}
|
||||
return await hasOpenClawRenameResidue(root);
|
||||
}
|
||||
|
||||
function filterLegacyStaleRootCandidates(
|
||||
targets: readonly CleanupTarget[],
|
||||
cleanupRootPaths: readonly string[],
|
||||
): { targets: CleanupTarget[]; warnings: string[] } {
|
||||
const safeTargets: CleanupTarget[] = [];
|
||||
const warnings: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const target of targets) {
|
||||
const targetPath = path.resolve(target.path);
|
||||
if (seen.has(targetPath)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(targetPath);
|
||||
if (target.kind === "explicit-stage") {
|
||||
if (target.rawPath && hasParentPathSegment(target.rawPath)) {
|
||||
warnings.push(
|
||||
`Skipped legacy plugin dependency state ${targetPath}: parent path segments are not allowed`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
safeTargets.push({ ...target, path: targetPath });
|
||||
continue;
|
||||
}
|
||||
if (!isExpectedLegacyCleanupTargetName(path.basename(targetPath))) {
|
||||
warnings.push(`Skipped legacy plugin dependency state ${targetPath}: unexpected path name`);
|
||||
continue;
|
||||
}
|
||||
if (!cleanupRootPaths.some((rootPath) => isPathInside(rootPath, targetPath))) {
|
||||
warnings.push(
|
||||
`Skipped legacy plugin dependency state ${targetPath}: outside OpenClaw cleanup roots`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
safeTargets.push({ ...target, path: targetPath });
|
||||
}
|
||||
return {
|
||||
targets: safeTargets.toSorted((left, right) => left.path.localeCompare(right.path)),
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveSafeRemovalTarget(
|
||||
target: CleanupTarget,
|
||||
cleanupRoots: readonly CleanupRoot[],
|
||||
): Promise<{ target: string } | { warning: string }> {
|
||||
const targetPath = path.resolve(target.path);
|
||||
const stat = await fs.lstat(targetPath).catch(() => null);
|
||||
if (target.kind === "explicit-stage" && stat?.isSymbolicLink()) {
|
||||
return {
|
||||
warning: `Skipped legacy plugin dependency state ${targetPath}: symbolic link roots are not removed`,
|
||||
};
|
||||
}
|
||||
const realPath = await fs.realpath(targetPath).catch(() => null);
|
||||
if (realPath === null) {
|
||||
return {
|
||||
warning: `Skipped legacy plugin dependency state ${targetPath}: could not resolve path`,
|
||||
};
|
||||
}
|
||||
if (target.kind === "explicit-stage") {
|
||||
if (
|
||||
!isInstallStageDebrisName(path.basename(targetPath)) &&
|
||||
!(await hasExplicitStageDebrisProof(targetPath))
|
||||
) {
|
||||
return {
|
||||
warning: `Skipped legacy plugin dependency state ${targetPath}: unexpected path name`,
|
||||
};
|
||||
}
|
||||
return { target: targetPath };
|
||||
}
|
||||
if (!cleanupRoots.some((root) => isPathInside(root.realPath, realPath))) {
|
||||
return {
|
||||
warning: `Skipped legacy plugin dependency state ${targetPath}: resolved outside OpenClaw cleanup roots`,
|
||||
};
|
||||
}
|
||||
return { target: targetPath };
|
||||
}
|
||||
|
||||
async function prepareCleanupTargets(
|
||||
targets: readonly CleanupTarget[],
|
||||
cleanupRoots: readonly CleanupRoot[],
|
||||
): Promise<{ removalTargets: string[]; staleRoots: string[]; warnings: string[] }> {
|
||||
const removalTargets: string[] = [];
|
||||
const staleRoots: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
for (const target of targets) {
|
||||
if (!(await pathExists(target.path))) {
|
||||
continue;
|
||||
}
|
||||
const safeTarget = await resolveSafeRemovalTarget(target, cleanupRoots);
|
||||
if ("warning" in safeTarget) {
|
||||
warnings.push(safeTarget.warning);
|
||||
continue;
|
||||
}
|
||||
removalTargets.push(safeTarget.target);
|
||||
staleRoots.push(safeTarget.target);
|
||||
}
|
||||
return {
|
||||
removalTargets: uniqueSorted(removalTargets),
|
||||
staleRoots: uniqueSorted(staleRoots),
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
async function collectLegacyPluginDependencyTargetEntries(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
options: { packageRoot?: string | null } = {},
|
||||
): Promise<CleanupTarget[]> {
|
||||
const packageRoot =
|
||||
options.packageRoot ??
|
||||
resolveOpenClawPackageRootSync({
|
||||
argv1: process.argv[1],
|
||||
moduleUrl: import.meta.url,
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
const roots = uniqueSorted([resolveStateDir(env), resolveConfigDir(env), packageRoot]);
|
||||
const stateDirectoryRoots = splitPathList(env.STATE_DIRECTORY).map((entry): CleanupTarget => ({
|
||||
kind: "legacy",
|
||||
path: path.join(resolveUserPath(entry, env), "plugin-runtime-deps"),
|
||||
}));
|
||||
const targets: CleanupTarget[] = [
|
||||
...collectExplicitStageTargets(env),
|
||||
...stateDirectoryRoots,
|
||||
...roots.flatMap((root) => [
|
||||
...[...LEGACY_DIRECT_CHILD_NAMES].map((name): CleanupTarget => ({
|
||||
kind: "legacy",
|
||||
path: path.join(root, name),
|
||||
})),
|
||||
{
|
||||
kind: "legacy",
|
||||
path: path.join(root, ".local", "bundled-plugin-runtime-deps"),
|
||||
} satisfies CleanupTarget,
|
||||
]),
|
||||
];
|
||||
for (const root of roots) {
|
||||
const rootRealPath = await fs.realpath(root).catch(() => null);
|
||||
if (rootRealPath === null) {
|
||||
continue;
|
||||
}
|
||||
targets.push(
|
||||
...(await collectLegacyExtensionDebris(path.join(root, "extensions"), rootRealPath)).map(
|
||||
(targetPath): CleanupTarget => ({ kind: "legacy", path: targetPath }),
|
||||
),
|
||||
);
|
||||
targets.push(
|
||||
...(
|
||||
await collectLegacyExtensionDebris(path.join(root, "dist", "extensions"), rootRealPath)
|
||||
).map((targetPath): CleanupTarget => ({ kind: "legacy", path: targetPath })),
|
||||
);
|
||||
}
|
||||
return targets.toSorted((left, right) => left.path.localeCompare(right.path));
|
||||
}
|
||||
|
||||
async function collectLegacyPluginDependencyTargets(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
options: { packageRoot?: string | null } = {},
|
||||
): Promise<string[]> {
|
||||
return uniqueSorted(
|
||||
(await collectLegacyPluginDependencyTargetEntries(env, options)).map((target) => target.path),
|
||||
);
|
||||
}
|
||||
|
||||
/** Find stale legacy plugin dependency state that doctor --fix can remove. */
|
||||
export async function detectLegacyPluginDependencyStateIssues(
|
||||
params: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
packageRoot?: string | null;
|
||||
} = {},
|
||||
): Promise<LegacyPluginDependencyStateIssue[]> {
|
||||
const env = params.env ?? process.env;
|
||||
const packageRoot =
|
||||
params.packageRoot ??
|
||||
resolveOpenClawPackageRootSync({
|
||||
argv1: process.argv[1],
|
||||
moduleUrl: import.meta.url,
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
const targets = await collectLegacyPluginDependencyTargetEntries(env, {
|
||||
packageRoot,
|
||||
});
|
||||
const cleanupRootPaths = collectCleanupRootPaths(env, packageRoot);
|
||||
const cleanupRoots = await collectExistingCleanupRoots(cleanupRootPaths);
|
||||
const staleRootCandidates = filterLegacyStaleRootCandidates(targets, cleanupRootPaths);
|
||||
const preparedTargets = await prepareCleanupTargets(staleRootCandidates.targets, cleanupRoots);
|
||||
return preparedTargets.removalTargets.map((target): LegacyPluginDependencyStateIssue => ({
|
||||
kind: "legacy-plugin-dependency-state",
|
||||
path: target,
|
||||
}));
|
||||
}
|
||||
|
||||
export function legacyPluginDependencyStateIssueToHealthFinding(
|
||||
issue: LegacyPluginDependencyStateIssue,
|
||||
): HealthFinding {
|
||||
return {
|
||||
checkId: "core/doctor/legacy-plugin-dependencies",
|
||||
severity: "warning",
|
||||
message: `Legacy plugin dependency state remains at ${issue.path}.`,
|
||||
target: issue.path,
|
||||
path: issue.path,
|
||||
requirement: "legacy-plugin-dependency-state-removed",
|
||||
fixHint: "Run `openclaw doctor --fix` to remove legacy plugin dependency state.",
|
||||
};
|
||||
}
|
||||
|
||||
/** Remove legacy plugin dependency state under trusted OpenClaw cleanup roots. */
|
||||
export async function cleanupLegacyPluginDependencyState(params: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
packageRoot?: string | null;
|
||||
}): Promise<{ changes: string[]; warnings: string[] }> {
|
||||
const env = params.env ?? process.env;
|
||||
const changes: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
const packageRoot =
|
||||
params.packageRoot ??
|
||||
resolveOpenClawPackageRootSync({
|
||||
argv1: process.argv[1],
|
||||
moduleUrl: import.meta.url,
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
const targets = await collectLegacyPluginDependencyTargetEntries(env, {
|
||||
packageRoot,
|
||||
});
|
||||
const cleanupRootPaths = collectCleanupRootPaths(env, packageRoot);
|
||||
const cleanupRoots = await collectExistingCleanupRoots(cleanupRootPaths);
|
||||
const staleRootCandidates = filterLegacyStaleRootCandidates(targets, cleanupRootPaths);
|
||||
warnings.push(...staleRootCandidates.warnings);
|
||||
const preparedTargets = await prepareCleanupTargets(staleRootCandidates.targets, cleanupRoots);
|
||||
warnings.push(...preparedTargets.warnings);
|
||||
const staleSymlinks = await removeStalePluginRuntimeSymlinks(packageRoot, {
|
||||
staleRoots: preparedTargets.staleRoots,
|
||||
});
|
||||
changes.push(...staleSymlinks.changes);
|
||||
warnings.push(...staleSymlinks.warnings);
|
||||
for (const target of preparedTargets.removalTargets) {
|
||||
try {
|
||||
await fs.rm(target, { recursive: true, force: true });
|
||||
changes.push(`Removed legacy plugin dependency state: ${target}`);
|
||||
} catch (error) {
|
||||
warnings.push(`Failed to remove legacy plugin dependency state ${target}: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
return { changes, warnings };
|
||||
}
|
||||
|
||||
if (process.env.VITEST || process.env.NODE_ENV === "test") {
|
||||
(globalThis as Record<PropertyKey, unknown>)[
|
||||
Symbol.for("openclaw.pluginDependencyCleanupTestApi")
|
||||
] = { collectLegacyPluginDependencyTargets };
|
||||
}
|
||||
|
|
@ -1,9 +1,12 @@
|
|||
// Plugin runtime symlink tests cover doctor detection of stale global symlinks.
|
||||
// Plugin runtime symlink tests cover doctor detection and repair of dangling global links.
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { collectStalePluginRuntimeSymlinkHealthFindings } from "./plugin-runtime-symlinks.js";
|
||||
import {
|
||||
collectStalePluginRuntimeSymlinkHealthFindings,
|
||||
removeStalePluginRuntimeSymlinks,
|
||||
} from "./plugin-runtime-symlinks.js";
|
||||
|
||||
async function expectSymlinkPresent(targetPath: string): Promise<void> {
|
||||
expect((await fs.lstat(targetPath)).isSymbolicLink()).toBe(true);
|
||||
|
|
@ -32,84 +35,100 @@ describe("plugin runtime symlink health findings", () => {
|
|||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-plugin-runtime-symlinks-"));
|
||||
tempDir = await fs.realpath(
|
||||
await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-plugin-runtime-symlinks-")),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("maps dangling plugin-runtime symlinks to read-only lint findings", async () => {
|
||||
if (!(await canCreateDirectorySymlink(tempDir))) {
|
||||
return;
|
||||
}
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"preserves POSIX relative runtime links across directory symlinks and ..",
|
||||
async () => {
|
||||
const packageRoot = path.join(tempDir, "global", "node_modules", "openclaw");
|
||||
const runtimeRoot = path.join(tempDir, "shared", "plugin-runtime-deps");
|
||||
const physicalRoot = path.join(tempDir, "physical");
|
||||
const dependency = path.join(physicalRoot, "dep");
|
||||
const link = path.join(path.dirname(packageRoot), "relative-runtime");
|
||||
const contents = '{"name":"live-relative-runtime"}\n';
|
||||
await fs.mkdir(packageRoot, { recursive: true });
|
||||
await fs.mkdir(runtimeRoot, { recursive: true });
|
||||
await fs.mkdir(path.join(physicalRoot, "current"), { recursive: true });
|
||||
await fs.mkdir(dependency);
|
||||
await fs.writeFile(path.join(dependency, "package.json"), contents);
|
||||
await fs.symlink(
|
||||
path.join(physicalRoot, "current"),
|
||||
path.join(runtimeRoot, "current"),
|
||||
"dir",
|
||||
);
|
||||
// Keep .. in the stored link: POSIX follows the directory symlink before ascending.
|
||||
const target = `${path.relative(path.dirname(link), runtimeRoot)}/current/../dep`;
|
||||
await fs.symlink(target, link, "dir");
|
||||
expect(await fs.readFile(path.join(link, "package.json"), "utf8")).toBe(contents);
|
||||
|
||||
const packageRoot = path.join(tempDir, "prefix", "lib", "node_modules", "openclaw");
|
||||
const nodeModulesRoot = path.dirname(packageRoot);
|
||||
const legacyRoot = path.join(tempDir, "state", "plugin-runtime-deps");
|
||||
const missingTarget = path.join(
|
||||
legacyRoot,
|
||||
"openclaw-slack",
|
||||
"node_modules",
|
||||
"@slack",
|
||||
"web-api",
|
||||
);
|
||||
const scopeRoot = path.join(nodeModulesRoot, "@slack");
|
||||
const staleLink = path.join(scopeRoot, "web-api");
|
||||
const liveTarget = path.join(tempDir, "live", "@slack", "bolt");
|
||||
const liveLink = path.join(scopeRoot, "bolt");
|
||||
const findings = await collectStalePluginRuntimeSymlinkHealthFindings({ packageRoot });
|
||||
const repair = await removeStalePluginRuntimeSymlinks(packageRoot);
|
||||
expect({ findings, ...repair }).toEqual({ findings: [], changes: [], warnings: [] });
|
||||
await expectSymlinkPresent(link);
|
||||
expect(await fs.readlink(link)).toBe(target);
|
||||
expect(await fs.readFile(path.join(link, "package.json"), "utf8")).toBe(contents);
|
||||
},
|
||||
);
|
||||
|
||||
await fs.mkdir(packageRoot, { recursive: true });
|
||||
await fs.mkdir(scopeRoot, { recursive: true });
|
||||
await fs.mkdir(liveTarget, { recursive: true });
|
||||
await fs.symlink(missingTarget, staleLink, "dir");
|
||||
await fs.symlink(liveTarget, liveLink, "dir");
|
||||
it.each(["ENOENT", "ENOTDIR"])(
|
||||
"reports and removes dangling links while preserving live shared-cache links (%s)",
|
||||
async (code) => {
|
||||
if (!(await canCreateDirectorySymlink(tempDir))) {
|
||||
return;
|
||||
}
|
||||
const packageRoot = path.join(tempDir, "prefix", "lib", "node_modules", "openclaw");
|
||||
const legacyRoot = path.join(tempDir, "state", "plugin-runtime-deps");
|
||||
const missingTarget = path.join(
|
||||
legacyRoot,
|
||||
"openclaw-slack",
|
||||
"node_modules",
|
||||
"@slack",
|
||||
"web-api",
|
||||
);
|
||||
const scopeRoot = path.join(path.dirname(packageRoot), "@slack");
|
||||
const staleLink = path.join(scopeRoot, "web-api");
|
||||
const liveTarget = path.join(legacyRoot, "openclaw-live", "node_modules", "@slack", "bolt");
|
||||
const liveLink = path.join(scopeRoot, "bolt");
|
||||
|
||||
expect(await collectStalePluginRuntimeSymlinkHealthFindings({ packageRoot })).toEqual([
|
||||
{
|
||||
checkId: "core/doctor/stale-plugin-runtime-symlinks",
|
||||
severity: "warning",
|
||||
message: `Stale plugin-runtime symlink @slack/web-api points at ${missingTarget}.`,
|
||||
path: staleLink,
|
||||
target: staleLink,
|
||||
requirement: "stale-plugin-runtime-symlink-removed",
|
||||
fixHint: "Run `openclaw doctor --fix` to remove stale plugin-runtime symlinks.",
|
||||
},
|
||||
]);
|
||||
await expectSymlinkPresent(staleLink);
|
||||
await expectSymlinkPresent(liveLink);
|
||||
});
|
||||
await fs.mkdir(packageRoot, { recursive: true });
|
||||
await fs.mkdir(scopeRoot, { recursive: true });
|
||||
await fs.mkdir(liveTarget, { recursive: true });
|
||||
await fs.writeFile(path.join(liveTarget, "package.json"), '{"name":"live-runtime"}\n');
|
||||
if (code === "ENOTDIR") {
|
||||
await fs.writeFile(path.join(legacyRoot, "openclaw-slack"), "not a directory\n");
|
||||
}
|
||||
await fs.symlink(missingTarget, staleLink, "dir");
|
||||
await fs.symlink(liveTarget, liveLink, "dir");
|
||||
|
||||
it("reports symlinks that point inside classified stale roots", async () => {
|
||||
if (!(await canCreateDirectorySymlink(tempDir))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const packageRoot = path.join(tempDir, "prefix", "lib", "node_modules", "openclaw");
|
||||
const nodeModulesRoot = path.dirname(packageRoot);
|
||||
const legacyRoot = path.join(tempDir, "state", "plugin-runtime-deps");
|
||||
const existingTarget = path.join(legacyRoot, "openclaw-demo", "node_modules", "left-pad");
|
||||
const staleLink = path.join(nodeModulesRoot, "left-pad");
|
||||
|
||||
await fs.mkdir(packageRoot, { recursive: true });
|
||||
await fs.mkdir(existingTarget, { recursive: true });
|
||||
await fs.symlink(existingTarget, staleLink, "dir");
|
||||
|
||||
await expect(collectStalePluginRuntimeSymlinkHealthFindings({ packageRoot })).resolves.toEqual(
|
||||
[],
|
||||
);
|
||||
await expect(
|
||||
collectStalePluginRuntimeSymlinkHealthFindings({
|
||||
packageRoot,
|
||||
staleRoots: [legacyRoot],
|
||||
}),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
checkId: "core/doctor/stale-plugin-runtime-symlinks",
|
||||
path: staleLink,
|
||||
target: staleLink,
|
||||
}),
|
||||
]);
|
||||
await expectSymlinkPresent(staleLink);
|
||||
});
|
||||
expect(await collectStalePluginRuntimeSymlinkHealthFindings({ packageRoot })).toEqual([
|
||||
{
|
||||
checkId: "core/doctor/stale-plugin-runtime-symlinks",
|
||||
severity: "warning",
|
||||
message: `Stale plugin-runtime symlink @slack/web-api points at ${missingTarget}.`,
|
||||
path: staleLink,
|
||||
target: staleLink,
|
||||
requirement: "stale-plugin-runtime-symlink-removed",
|
||||
fixHint: "Run `openclaw doctor --fix` to remove stale plugin-runtime symlinks.",
|
||||
},
|
||||
]);
|
||||
await expectSymlinkPresent(staleLink);
|
||||
await expectSymlinkPresent(liveLink);
|
||||
expect(await removeStalePluginRuntimeSymlinks(packageRoot)).toEqual({
|
||||
changes: [`Removed stale plugin-runtime symlink: ${staleLink}`],
|
||||
warnings: [],
|
||||
});
|
||||
await expect(fs.lstat(staleLink)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expectSymlinkPresent(liveLink);
|
||||
expect(await fs.readFile(path.join(liveLink, "package.json"), "utf8")).toBe(
|
||||
'{"name":"live-runtime"}\n',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
// Doctor detection and cleanup for stale global plugin-runtime symlinks.
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { sortUniqueStrings } from "@openclaw/normalization-core/string-normalization";
|
||||
import { note } from "../../../../packages/terminal-core/src/note.js";
|
||||
import type { HealthFinding } from "../../../flows/health-checks.js";
|
||||
import { resolveOpenClawPackageRootSync } from "../../../infra/openclaw-root.js";
|
||||
import { isPathInside } from "../../../infra/path-safety.js";
|
||||
import { shortenHomePath } from "../../../utils.js";
|
||||
|
||||
const PLUGIN_RUNTIME_DEPS_MARKER = "plugin-runtime-deps";
|
||||
|
|
@ -35,15 +33,13 @@ interface StalePluginRuntimeSymlink {
|
|||
readonly name: string;
|
||||
/** Symlink path under the containing node_modules directory. */
|
||||
readonly path: string;
|
||||
/** Resolved target that is missing or belongs to stale cleanup roots. */
|
||||
/** Target recorded by the symlink, for diagnostic output. */
|
||||
readonly target: string;
|
||||
}
|
||||
|
||||
interface PluginRuntimeSymlinkOptions {
|
||||
/** Filesystem adapter for tests and doctor cleanup callers. */
|
||||
readonly fs?: FsLike;
|
||||
/** Roots already classified as stale by plugin dependency cleanup. */
|
||||
readonly staleRoots?: readonly string[];
|
||||
}
|
||||
|
||||
const DEFAULT_FS: FsLike = {
|
||||
|
|
@ -57,7 +53,11 @@ const DEFAULT_FS: FsLike = {
|
|||
|
||||
/** Find global node_modules symlinks that still point at stale plugin-runtime deps. */
|
||||
async function collectStalePluginRuntimeSymlinks(
|
||||
packageRoot: string | null | undefined,
|
||||
packageRoot: string | null = resolveOpenClawPackageRootSync({
|
||||
argv1: process.argv[1],
|
||||
moduleUrl: import.meta.url,
|
||||
cwd: process.cwd(),
|
||||
}),
|
||||
options: PluginRuntimeSymlinkOptions = {},
|
||||
): Promise<StalePluginRuntimeSymlink[]> {
|
||||
if (!packageRoot) {
|
||||
|
|
@ -69,7 +69,6 @@ async function collectStalePluginRuntimeSymlinks(
|
|||
}
|
||||
|
||||
const fsApi = options.fs ?? DEFAULT_FS;
|
||||
const staleRoots = uniqueResolvedRoots(options.staleRoots ?? []);
|
||||
const stale: StalePluginRuntimeSymlink[] = [];
|
||||
const entries = await fsApi
|
||||
.readdir(containingNodeModules, { withFileTypes: true })
|
||||
|
|
@ -82,7 +81,7 @@ async function collectStalePluginRuntimeSymlinks(
|
|||
.catch(() => [] as DirentLike[]);
|
||||
for (const scopeEntry of scopeEntries) {
|
||||
const fullPath = path.join(scopeDir, scopeEntry.name);
|
||||
const target = await inspectCandidate(fullPath, fsApi, staleRoots);
|
||||
const target = await inspectCandidate(fullPath, fsApi);
|
||||
if (target) {
|
||||
stale.push({ name: `${entry.name}/${scopeEntry.name}`, path: fullPath, target });
|
||||
}
|
||||
|
|
@ -93,7 +92,7 @@ async function collectStalePluginRuntimeSymlinks(
|
|||
continue;
|
||||
}
|
||||
const fullPath = path.join(containingNodeModules, entry.name);
|
||||
const target = await inspectCandidate(fullPath, fsApi, staleRoots);
|
||||
const target = await inspectCandidate(fullPath, fsApi);
|
||||
if (target) {
|
||||
stale.push({ name: entry.name, path: fullPath, target });
|
||||
}
|
||||
|
|
@ -117,14 +116,7 @@ function stalePluginRuntimeSymlinkToHealthFinding(item: StalePluginRuntimeSymlin
|
|||
export async function collectStalePluginRuntimeSymlinkHealthFindings(
|
||||
params: { packageRoot?: string | null } & PluginRuntimeSymlinkOptions = {},
|
||||
): Promise<HealthFinding[]> {
|
||||
const packageRoot =
|
||||
params.packageRoot ??
|
||||
resolveOpenClawPackageRootSync({
|
||||
argv1: process.argv[1],
|
||||
moduleUrl: import.meta.url,
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
return (await collectStalePluginRuntimeSymlinks(packageRoot, params)).map(
|
||||
return (await collectStalePluginRuntimeSymlinks(params.packageRoot, params)).map(
|
||||
stalePluginRuntimeSymlinkToHealthFinding,
|
||||
);
|
||||
}
|
||||
|
|
@ -160,7 +152,7 @@ export async function noteStalePluginRuntimeSymlinks(
|
|||
|
||||
/** Remove stale plugin-runtime symlinks and report changes/warnings. */
|
||||
export async function removeStalePluginRuntimeSymlinks(
|
||||
packageRoot: string | null | undefined,
|
||||
packageRoot?: string | null,
|
||||
options: PluginRuntimeSymlinkOptions = {},
|
||||
): Promise<{ changes: string[]; warnings: string[] }> {
|
||||
const fsApi = options.fs ?? DEFAULT_FS;
|
||||
|
|
@ -181,15 +173,7 @@ export async function removeStalePluginRuntimeSymlinks(
|
|||
return { changes, warnings };
|
||||
}
|
||||
|
||||
function uniqueResolvedRoots(values: readonly string[]): string[] {
|
||||
return sortUniqueStrings(values.map((value) => path.resolve(value)));
|
||||
}
|
||||
|
||||
async function inspectCandidate(
|
||||
fullPath: string,
|
||||
fsApi: FsLike,
|
||||
staleRoots: readonly string[],
|
||||
): Promise<string | null> {
|
||||
async function inspectCandidate(fullPath: string, fsApi: FsLike): Promise<string | null> {
|
||||
const stat = await fsApi.lstat(fullPath).catch(() => null);
|
||||
if (!stat?.isSymbolicLink()) {
|
||||
return null;
|
||||
|
|
@ -198,17 +182,14 @@ async function inspectCandidate(
|
|||
if (!target || !target.includes(PLUGIN_RUNTIME_DEPS_MARKER)) {
|
||||
return null;
|
||||
}
|
||||
const resolvedTarget = path.isAbsolute(target)
|
||||
? target
|
||||
: path.resolve(path.dirname(fullPath), target);
|
||||
if (staleRoots.some((root) => isPathInside(root, resolvedTarget))) {
|
||||
return resolvedTarget;
|
||||
}
|
||||
// Paths and cache markers cannot authorize removal. Check the alias itself:
|
||||
// lexical ".." normalization can erase an intermediate directory symlink
|
||||
// and make a live shared-cache target appear missing.
|
||||
try {
|
||||
await fsApi.stat(resolvedTarget);
|
||||
await fsApi.stat(fullPath);
|
||||
return null;
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
||||
return code === "ENOENT" || code === "ENOTDIR" ? resolvedTarget : null;
|
||||
return code === "ENOENT" || code === "ENOTDIR" ? target : null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -170,19 +170,22 @@ export function resolveInitialDoctorHealthContributions(params: {
|
|||
run: runLegacyPluginManifestHealth,
|
||||
}),
|
||||
createDoctorHealthContribution({
|
||||
// Stable v2026.8.1 exposed this --only selector. Retain its public identity,
|
||||
// not the unsupported shared-root scan or its destructive repair advice.
|
||||
id: "doctor:legacy-plugin-dependencies",
|
||||
label: "Legacy plugin dependencies",
|
||||
healthChecks: {
|
||||
description: "Legacy plugin dependency state roots are represented as findings.",
|
||||
description: "Deprecated shared plugin dependency cleanup check.",
|
||||
defaultEnabled: false,
|
||||
async detect() {
|
||||
const {
|
||||
detectLegacyPluginDependencyStateIssues,
|
||||
legacyPluginDependencyStateIssueToHealthFinding,
|
||||
} = await import("../commands/doctor/shared/plugin-dependency-cleanup.js");
|
||||
return (await detectLegacyPluginDependencyStateIssues({ env: process.env })).map(
|
||||
legacyPluginDependencyStateIssueToHealthFinding,
|
||||
);
|
||||
return [
|
||||
{
|
||||
checkId: "core/doctor/legacy-plugin-dependencies",
|
||||
severity: "info",
|
||||
message:
|
||||
"Deprecated check: Doctor preserves shared plugin runtime caches and no longer scans them for removal.",
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
run: async () => {},
|
||||
|
|
|
|||
|
|
@ -2884,7 +2884,7 @@ describe("doctor health contributions", () => {
|
|||
expect(mocks.collectStalePluginRuntimeSymlinkHealthFindings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps legacy plugin dependency lint opt-in and read-only", async () => {
|
||||
it("preserves the shipped legacy dependency selector as a non-destructive deprecation", async () => {
|
||||
const openClawState = await createOpenClawTestState({
|
||||
layout: "state-only",
|
||||
prefix: "openclaw-legacy-plugin-deps-lint-",
|
||||
|
|
@ -2917,8 +2917,9 @@ describe("doctor health contributions", () => {
|
|||
findings: [
|
||||
expect.objectContaining({
|
||||
checkId: "core/doctor/legacy-plugin-dependencies",
|
||||
severity: "warning",
|
||||
path: legacyRuntimeRoot,
|
||||
severity: "info",
|
||||
message:
|
||||
"Deprecated check: Doctor preserves shared plugin runtime caches and no longer scans them for removal.",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -99,9 +99,6 @@ const publications: Record<string, string | symbol> = {
|
|||
"src/commands/doctor/shared/codex-route-session-repair.ts": Symbol.for(
|
||||
"openclaw.codexRouteSessionRepairTestApi",
|
||||
),
|
||||
"src/commands/doctor/shared/plugin-dependency-cleanup.ts": Symbol.for(
|
||||
"openclaw.pluginDependencyCleanupTestApi",
|
||||
),
|
||||
"src/commands/doctor/shared/stale-auth-order.ts": Symbol.for("openclaw.staleAuthOrderTestApi"),
|
||||
"src/commands/doctor/shared/stale-oauth-profile-shadows.ts": Symbol.for(
|
||||
"openclaw.staleOAuthProfileShadowsTestApi",
|
||||
|
|
|
|||
|
|
@ -3475,7 +3475,6 @@ process.on("SIGTERM", () => {
|
|||
: `source ${shellQuote(OPENCLAW_E2E_INSTANCE_HELPER_PATH)}\nsource ${shellQuote(UPGRADE_SURVIVOR_UPDATE_RESTART_AUTH_PATH)}`;
|
||||
const script = `${setup}
|
||||
trap - EXIT ERR INT TERM
|
||||
seed_update_restart_probe_device_auth() { :; }
|
||||
assert_prepublish_fixture_idle() { :; }
|
||||
assert_baseline_state() { :; }
|
||||
check_gateway_status() { :; }
|
||||
|
|
@ -3585,7 +3584,7 @@ exit "$start_status"
|
|||
expect(result.status, result.stdout + result.stderr).toBe(1);
|
||||
});
|
||||
|
||||
it("scopes candidate device identity doctor markers to the doctor process", () => {
|
||||
it("scopes candidate setup Doctor markers without creating legacy device identities", () => {
|
||||
const workDir = tempDirs.make("openclaw-upgrade-survivor-doctor-env-");
|
||||
writeExecutables(join(workDir, "bin"), {
|
||||
openclaw: `#!/usr/bin/env bash
|
||||
|
|
@ -3603,8 +3602,10 @@ exit 23
|
|||
const script = repoShell(workDir)`
|
||||
export PATH="$TMPDIR/bin:$PATH"
|
||||
export CAPTURE_DIR="$TMPDIR"
|
||||
export OPENCLAW_CONFIG_PATH="$TMPDIR/openclaw.json"
|
||||
export OPENCLAW_STATE_DIR="$TMPDIR/state"
|
||||
export OPENCLAW_CONFIG_PATH="$OPENCLAW_STATE_DIR/openclaw.json"
|
||||
export OPENCLAW_UPGRADE_SURVIVOR_CONFIG_PARKING_HELPER="$ROOT_DIR/${UPGRADE_SURVIVOR_CONFIG_PARKING_PATH}"
|
||||
mkdir -p "$OPENCLAW_STATE_DIR"
|
||||
printf '%s\n' '{"gateway":{"mode":"local"}}' >"$OPENCLAW_CONFIG_PATH"
|
||||
unset OPENCLAW_UPDATE_IN_PROGRESS
|
||||
unset OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR
|
||||
|
|
@ -3612,7 +3613,6 @@ unset OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE
|
|||
source "$ROOT_DIR/${OPENCLAW_E2E_INSTANCE_HELPER_PATH}"
|
||||
source "$ROOT_DIR/${UPGRADE_SURVIVOR_UPDATE_RESTART_AUTH_PATH}"
|
||||
install_update_restart_systemctl_shim() { :; }
|
||||
seed_update_restart_probe_device_auth() { :; }
|
||||
openclaw_e2e_maybe_timeout() {
|
||||
shift
|
||||
"$@"
|
||||
|
|
@ -3632,6 +3632,14 @@ fi
|
|||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe("");
|
||||
for (const file of [
|
||||
"identity/device.json",
|
||||
"identity/device-auth.json",
|
||||
"devices/paired.json",
|
||||
"devices/pending.json",
|
||||
]) {
|
||||
expect(existsSync(join(workDir, "state", file)), file).toBe(false);
|
||||
}
|
||||
expect(readFileSync(join(workDir, "doctor-argv"), "utf8").trimEnd().split("\n")).toEqual([
|
||||
"doctor",
|
||||
"--fix",
|
||||
|
|
@ -3700,7 +3708,6 @@ printf '%s\n' "$authored_config" >"$OPENCLAW_CONFIG_PATH"
|
|||
source "$ROOT_DIR/${OPENCLAW_E2E_INSTANCE_HELPER_PATH}"
|
||||
source "$ROOT_DIR/${UPGRADE_SURVIVOR_UPDATE_RESTART_AUTH_PATH}"
|
||||
install_update_restart_systemctl_shim() { :; }
|
||||
seed_update_restart_probe_device_auth() { :; }
|
||||
openclaw_e2e_maybe_timeout() {
|
||||
shift
|
||||
"$@"
|
||||
|
|
@ -3765,7 +3772,6 @@ printf '%s\n' '{"channels":{"discord":{"dm":{"policy":"allowlist"}}}}' >"$OPENCL
|
|||
source "$ROOT_DIR/${OPENCLAW_E2E_INSTANCE_HELPER_PATH}"
|
||||
source "$ROOT_DIR/${UPGRADE_SURVIVOR_UPDATE_RESTART_AUTH_PATH}"
|
||||
install_update_restart_systemctl_shim() { :; }
|
||||
seed_update_restart_probe_device_auth() { :; }
|
||||
openclaw_e2e_maybe_timeout() {
|
||||
shift
|
||||
"$@"
|
||||
|
|
|
|||
|
|
@ -6052,9 +6052,6 @@ describe("package artifact reuse", () => {
|
|||
);
|
||||
expect(publishedUpgradeSurvivor).toContain("phase prepare-update-restart-probe");
|
||||
expect(publishedUpgradeSurvivor).toContain("openclaw@(alpha|beta|latest|");
|
||||
expect(publishedUpgradeSurvivor).toContain("plugin_deps_cleanup_plugin_dirs");
|
||||
expect(publishedUpgradeSurvivor).toContain('"$(package_root)/extensions/$plugin"');
|
||||
expect(publishedUpgradeSurvivor).toContain("probe_gateway_endpoint");
|
||||
expect(publishedUpgradeSurvivor).toContain("configure_watchos_tls_fixture");
|
||||
expect(publishedUpgradeSurvivor).toContain('"publicUrl":"wss://localhost:18789"');
|
||||
expect(publishedUpgradeSurvivor).toContain('export NODE_EXTRA_CA_CERTS="$WATCH_TLS_CA_CERT"');
|
||||
|
|
@ -6062,16 +6059,24 @@ describe("package artifact reuse", () => {
|
|||
"--base-url http://127.0.0.1:18789/api/nodes/watch",
|
||||
);
|
||||
expect(publishedUpgradeSurvivor).toContain(
|
||||
"assert_legacy_plugin_dependency_debris_before_doctor",
|
||||
"source scripts/e2e/lib/upgrade-survivor/plugin-dependency-fixtures.sh",
|
||||
);
|
||||
expect(publishedUpgradeSurvivor).toContain("probe_gateway_endpoint");
|
||||
const preDoctorCleanupIndex = publishedUpgradeSurvivor.indexOf(
|
||||
"run_plugin_fixture_phase assert-package-local-dependency-cleanup assert_legacy_plugin_dependency_debris_cleaned",
|
||||
);
|
||||
const doctorIndex = publishedUpgradeSurvivor.indexOf("phase doctor run_doctor");
|
||||
const postDoctorCleanupIndex = publishedUpgradeSurvivor.indexOf(
|
||||
"run_plugin_fixture_phase assert-legacy-plugin-dependency-debris-cleaned assert_legacy_plugin_dependency_debris_cleaned",
|
||||
);
|
||||
expect(preDoctorCleanupIndex).toBeGreaterThan(-1);
|
||||
expect(doctorIndex).toBeGreaterThan(preDoctorCleanupIndex);
|
||||
expect(postDoctorCleanupIndex).toBeGreaterThan(doctorIndex);
|
||||
expect(publishedUpgradeSurvivor.indexOf("phase seed-source-only-plugin-shadow")).toBeLessThan(
|
||||
publishedUpgradeSurvivor.indexOf("phase assert-baseline"),
|
||||
);
|
||||
expect(publishedUpgradeSurvivor).toContain('"id": "opik-openclaw"');
|
||||
expect(publishedUpgradeSurvivor).toContain('"configSchema": {');
|
||||
expect(publishedUpgradeSurvivor).toContain(
|
||||
"Legacy plugin dependency debris was already removed before doctor",
|
||||
);
|
||||
expect(
|
||||
publishedUpgradeSurvivor.indexOf('validate_baseline_package_spec "$baseline_spec"'),
|
||||
).toBeLessThan(
|
||||
|
|
|
|||
|
|
@ -9,13 +9,11 @@ import { describe, expect, it, vi } from "vitest";
|
|||
import { writePackageDistInventory } from "../../scripts/lib/package-dist-inventory.ts";
|
||||
import { PACKAGE_LIFECYCLE_PENDING_RELATIVE_PATH } from "../../scripts/lib/package-lifecycle-marker.mjs";
|
||||
import {
|
||||
collectLegacyPluginRuntimeDepsStateRoots,
|
||||
completePackageLifecycle,
|
||||
isSourceCheckoutRoot,
|
||||
isDirectPostinstallInvocation,
|
||||
MAX_INSTALLED_DIST_SCAN_ENTRIES,
|
||||
pruneInstalledPackageDist,
|
||||
pruneLegacyPluginRuntimeDepsState,
|
||||
runBundledPluginPostinstall,
|
||||
} from "../../scripts/postinstall-bundled-plugins.mjs";
|
||||
import { createSourcePluginDependenciesFixture } from "./source-plugin-dependencies-fixture.js";
|
||||
|
|
@ -232,101 +230,77 @@ describe("bundled plugin postinstall", () => {
|
|||
await expectPathExists(staleFile);
|
||||
});
|
||||
|
||||
it("does not run plugin registry migration during packaged postinstall", async () => {
|
||||
const packageRoot = await createTempDirAsync("openclaw-postinstall-registry-skip-");
|
||||
const scriptRoot = path.join(packageRoot, "scripts");
|
||||
const migrationModule = path.join(
|
||||
packageRoot,
|
||||
"dist",
|
||||
"commands",
|
||||
"doctor",
|
||||
"shared",
|
||||
"plugin-registry-migration.js",
|
||||
);
|
||||
const stateDir = path.join(packageRoot, "state-root");
|
||||
const databasePath = path.join(stateDir, "state", "openclaw.sqlite");
|
||||
await fs.mkdir(path.join(scriptRoot, "lib"), { recursive: true });
|
||||
await fs.mkdir(path.dirname(migrationModule), { recursive: true });
|
||||
await fs.mkdir(path.dirname(databasePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(packageRoot, "package.json"),
|
||||
'{"name":"openclaw","type":"module","version":"2026.8.1-beta.3"}\n',
|
||||
);
|
||||
await fs.copyFile(
|
||||
fileURLToPath(new URL("../../scripts/postinstall-bundled-plugins.mjs", import.meta.url)),
|
||||
path.join(scriptRoot, "postinstall-bundled-plugins.mjs"),
|
||||
);
|
||||
await fs.copyFile(
|
||||
fileURLToPath(new URL("../../scripts/lib/package-lifecycle-marker.mjs", import.meta.url)),
|
||||
path.join(scriptRoot, "lib", "package-lifecycle-marker.mjs"),
|
||||
);
|
||||
const database = new DatabaseSync(databasePath);
|
||||
try {
|
||||
database.exec(`
|
||||
CREATE TABLE schema_meta (
|
||||
meta_key TEXT NOT NULL PRIMARY KEY,
|
||||
role TEXT NOT NULL,
|
||||
schema_version INTEGER NOT NULL,
|
||||
agent_id TEXT,
|
||||
app_version TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
) STRICT;
|
||||
PRAGMA user_version = 5;
|
||||
INSERT INTO schema_meta (
|
||||
meta_key, role, schema_version, agent_id, app_version, created_at, updated_at
|
||||
) VALUES ('primary', 'global', 5, NULL, '2026.8.1-beta.2', 0, 0);
|
||||
`);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
await fs.writeFile(
|
||||
migrationModule,
|
||||
[
|
||||
"import { DatabaseSync } from 'node:sqlite';",
|
||||
"import { join } from 'node:path';",
|
||||
"export async function migratePluginRegistryForInstall({ env }) {",
|
||||
" const db = new DatabaseSync(join(env.OPENCLAW_STATE_DIR, 'state', 'openclaw.sqlite'));",
|
||||
" try {",
|
||||
" db.exec(\"PRAGMA user_version = 9; UPDATE schema_meta SET schema_version = 9 WHERE meta_key = 'primary';\");",
|
||||
" } finally {",
|
||||
" db.close();",
|
||||
" }",
|
||||
" return { status: 'migrated', migrated: true, current: { plugins: [] } };",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[path.join(scriptRoot, "postinstall-bundled-plugins.mjs")],
|
||||
{
|
||||
it.each([undefined, "1"])(
|
||||
"completes packaged lifecycle without changing operator databases (disabled=%s)",
|
||||
async (disabled) => {
|
||||
const fixtureRoot = await createTempDirAsync("openclaw-postinstall-state-");
|
||||
const packageRoot = path.join(fixtureRoot, "node_modules", "openclaw");
|
||||
const home = path.join(fixtureRoot, "home");
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
const databasePath = path.join(stateDir, "state", "openclaw.sqlite");
|
||||
const scriptPath = path.join(packageRoot, "scripts", "postinstall-bundled-plugins.mjs");
|
||||
const markerPath = path.join(packageRoot, PACKAGE_LIFECYCLE_PENDING_RELATIVE_PATH);
|
||||
const migrationPath = path.join(
|
||||
packageRoot,
|
||||
"dist",
|
||||
"commands",
|
||||
"doctor",
|
||||
"shared",
|
||||
"plugin-registry-migration.js",
|
||||
);
|
||||
await fs.mkdir(path.join(packageRoot, "scripts", "lib"), { recursive: true });
|
||||
await fs.mkdir(path.dirname(migrationPath), { recursive: true });
|
||||
await fs.mkdir(path.dirname(databasePath), { recursive: true });
|
||||
await fs.writeFile(path.join(packageRoot, "package.json"), '{"type":"module"}\n');
|
||||
await fs.copyFile(
|
||||
fileURLToPath(new URL("../../scripts/postinstall-bundled-plugins.mjs", import.meta.url)),
|
||||
scriptPath,
|
||||
);
|
||||
await fs.copyFile(
|
||||
fileURLToPath(new URL("../../scripts/lib/package-lifecycle-marker.mjs", import.meta.url)),
|
||||
path.join(packageRoot, "scripts", "lib", "package-lifecycle-marker.mjs"),
|
||||
);
|
||||
const database = new DatabaseSync(databasePath);
|
||||
try {
|
||||
database.exec("PRAGMA user_version = 5; CREATE TABLE operator_state (value TEXT);");
|
||||
database.prepare("INSERT INTO operator_state VALUES (?)").run("preserve me");
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
const before = await fs.readFile(databasePath);
|
||||
await fs.writeFile(
|
||||
migrationPath,
|
||||
[
|
||||
"import { DatabaseSync } from 'node:sqlite';",
|
||||
"import { join } from 'node:path';",
|
||||
"export function migratePluginRegistryForInstall({ env }) {",
|
||||
" const db = new DatabaseSync(join(env.OPENCLAW_STATE_DIR, 'state', 'openclaw.sqlite'));",
|
||||
" try { db.exec('PRAGMA user_version = 9; DROP TABLE operator_state;'); }",
|
||||
" finally { db.close(); }",
|
||||
" return { migrated: true, current: { plugins: [] } };",
|
||||
"}",
|
||||
].join("\n"),
|
||||
);
|
||||
await writePackageDistInventory(packageRoot);
|
||||
await fs.writeFile(markerPath, "pending\n");
|
||||
const result = spawnSync(process.execPath, [scriptPath], {
|
||||
cwd: packageRoot,
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: path.join(packageRoot, "home"),
|
||||
OPENCLAW_CONFIG_PATH: undefined,
|
||||
OPENCLAW_DISABLE_BUNDLED_PLUGIN_POSTINSTALL: undefined,
|
||||
OPENCLAW_HOME: path.join(packageRoot, "home"),
|
||||
HOME: home,
|
||||
OPENCLAW_HOME: home,
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
OPENCLAW_CONFIG_PATH: undefined,
|
||||
STATE_DIRECTORY: undefined,
|
||||
OPENCLAW_DISABLE_BUNDLED_PLUGIN_POSTINSTALL: disabled,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
const after = new DatabaseSync(databasePath, { readOnly: true });
|
||||
try {
|
||||
expect(after.prepare("PRAGMA user_version").get()).toEqual({ user_version: 5 });
|
||||
expect(
|
||||
after.prepare("SELECT schema_version FROM schema_meta WHERE meta_key = 'primary'").get(),
|
||||
).toEqual({ schema_version: 5 });
|
||||
} finally {
|
||||
after.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
await expectPathMissing(markerPath);
|
||||
expect(await fs.readFile(databasePath)).toEqual(before);
|
||||
},
|
||||
);
|
||||
|
||||
it("prunes stale dist files from packaged installs", async () => {
|
||||
const packageRoot = await createTempDirAsync("openclaw-packaged-install-");
|
||||
|
|
@ -406,7 +380,7 @@ describe("bundled plugin postinstall", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("prunes legacy plugin runtime deps state during packaged postinstall", async () => {
|
||||
it("preserves other installs' runtime dependencies and sibling symlinks during packaged postinstall", async () => {
|
||||
const prefix = await createTempDirAsync("openclaw-packaged-prefix-");
|
||||
const packageRoot = path.join(prefix, "lib", "node_modules", "openclaw");
|
||||
const nodeModulesRoot = path.dirname(packageRoot);
|
||||
|
|
@ -463,105 +437,14 @@ describe("bundled plugin postinstall", () => {
|
|||
log,
|
||||
});
|
||||
|
||||
await expectPathMissing(defaultLegacyRoot);
|
||||
await expectPathMissing(oldBrandLegacyRoot);
|
||||
await expectPathMissing(overrideLegacyRoot);
|
||||
await expectPathMissing(systemLegacyRoot);
|
||||
await expectPathMissing(legacySymlink);
|
||||
await expectPathExists(defaultLegacyRoot);
|
||||
await expectPathExists(oldBrandLegacyRoot);
|
||||
await expectPathExists(overrideLegacyRoot);
|
||||
await expectPathExists(systemLegacyRoot);
|
||||
await expectPathExists(legacySymlink);
|
||||
await expectPathExists(thirdPartyNodeModules);
|
||||
expect(log.warn).not.toHaveBeenCalled();
|
||||
expect(log.log).toHaveBeenCalledWith(
|
||||
`[postinstall] pruned legacy plugin runtime deps: ${[
|
||||
oldBrandLegacyRoot,
|
||||
defaultLegacyRoot,
|
||||
overrideLegacyRoot,
|
||||
systemLegacyRoot,
|
||||
].join(", ")}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("prunes global plugin-runtime symlinks before deleting their legacy targets", async () => {
|
||||
const prefix = await createTempDirAsync("openclaw-packaged-prefix-");
|
||||
const home = await createTempDirAsync("openclaw-packaged-home-");
|
||||
const packageRoot = path.join(prefix, "lib", "node_modules", "openclaw");
|
||||
const nodeModulesRoot = path.dirname(packageRoot);
|
||||
const legacyRuntimeRoot = path.join(home, ".openclaw", "plugin-runtime-deps");
|
||||
const legacyTarget = path.join(
|
||||
legacyRuntimeRoot,
|
||||
"openclaw-2026.4.29-slack",
|
||||
"node_modules",
|
||||
"@slack",
|
||||
"web-api",
|
||||
);
|
||||
const slackScope = path.join(nodeModulesRoot, "@slack");
|
||||
const slackLink = path.join(slackScope, "web-api");
|
||||
|
||||
await fs.mkdir(legacyTarget, { recursive: true });
|
||||
await fs.writeFile(path.join(legacyTarget, "package.json"), "{}\n");
|
||||
await fs.mkdir(slackScope, { recursive: true });
|
||||
await fs.mkdir(packageRoot, { recursive: true });
|
||||
await fs.symlink(legacyTarget, slackLink, "dir");
|
||||
|
||||
const log = { log: vi.fn(), warn: vi.fn() };
|
||||
pruneLegacyPluginRuntimeDepsState({
|
||||
env: { HOME: home },
|
||||
packageRoot,
|
||||
log,
|
||||
});
|
||||
|
||||
await expectPathMissing(slackLink);
|
||||
await expectPathMissing(legacyRuntimeRoot);
|
||||
expect(log.warn).not.toHaveBeenCalled();
|
||||
expect(log.log).toHaveBeenCalledWith(
|
||||
`[postinstall] pruned legacy plugin runtime deps symlinks: ${slackLink}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps legacy plugin runtime deps cleanup non-fatal", () => {
|
||||
const warn = vi.fn();
|
||||
|
||||
expect(
|
||||
pruneLegacyPluginRuntimeDepsState({
|
||||
env: { HOME: "/home/alice" },
|
||||
existsSync: vi.fn(() => true),
|
||||
rmSync: vi.fn(() => {
|
||||
throw new Error("locked");
|
||||
}),
|
||||
log: { log: vi.fn(), warn },
|
||||
homedir: () => "/home/alice",
|
||||
}),
|
||||
).toStrictEqual([]);
|
||||
|
||||
expect(warn).toHaveBeenCalledTimes(2);
|
||||
expect(warn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"[postinstall] could not prune legacy plugin runtime deps /home/alice/.clawdbot/plugin-runtime-deps: Error: locked",
|
||||
);
|
||||
expect(warn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"[postinstall] could not prune legacy plugin runtime deps /home/alice/.openclaw/plugin-runtime-deps: Error: locked",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves legacy plugin runtime deps roots from OpenClaw state env", () => {
|
||||
expect(
|
||||
collectLegacyPluginRuntimeDepsStateRoots({
|
||||
env: {
|
||||
HOME: "/users/alice",
|
||||
OPENCLAW_HOME: "/srv/openclaw-home",
|
||||
OPENCLAW_CONFIG_PATH: "~/profile/openclaw.json",
|
||||
OPENCLAW_STATE_DIR: "~/state",
|
||||
STATE_DIRECTORY: "/var/lib/openclaw",
|
||||
},
|
||||
homedir: () => "/users/alice",
|
||||
}),
|
||||
).toEqual([
|
||||
"/srv/openclaw-home/.clawdbot/plugin-runtime-deps",
|
||||
"/srv/openclaw-home/.openclaw/plugin-runtime-deps",
|
||||
"/srv/openclaw-home/profile/plugin-runtime-deps",
|
||||
"/srv/openclaw-home/state/plugin-runtime-deps",
|
||||
"/var/lib/openclaw/plugin-runtime-deps",
|
||||
]);
|
||||
expect(log.log).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prunes stale private QA files without restoring compat sidecars", async () => {
|
||||
|
|
|
|||
|
|
@ -330,7 +330,7 @@ describe("upgrade recovery result assertions", () => {
|
|||
});
|
||||
});
|
||||
|
||||
function writeMigratedSessionState(stateDir: string): void {
|
||||
function writeMigratedSessionState(stateDir: string): undefined {
|
||||
const agentSessionsDir = join(stateDir, "agents", "main", "sessions");
|
||||
const agentDbDir = join(stateDir, "agents", "main", "agent");
|
||||
mkdirSync(agentSessionsDir, { recursive: true });
|
||||
|
|
@ -503,6 +503,24 @@ function writeLegacySessionEntriesState(stateDir: string): void {
|
|||
}
|
||||
}
|
||||
|
||||
function writeSharedRuntimeCaches(stateDir: string, versioned = false): void {
|
||||
const roots = ["discord", "telegram", "whatsapp"].map((plugin) =>
|
||||
join(plugin, ".openclaw-runtime-deps-copy-stale"),
|
||||
);
|
||||
if (versioned) {
|
||||
roots.push(
|
||||
...["discord", "feishu", "telegram", "whatsapp"].map(
|
||||
(plugin) => `openclaw-2026.4.24-${plugin}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
for (const root of roots) {
|
||||
const dir = join(stateDir, "plugin-runtime-deps", root, "node_modules", "stale-sentinel");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeJson(join(dir, "package.json"), { name: "stale-sentinel", version: "0.0.0" });
|
||||
}
|
||||
}
|
||||
|
||||
function runSessionStateAssertion(
|
||||
setup: (stateDir: string) => NodeJS.ProcessEnv | undefined,
|
||||
options: { scenario?: string; commands?: string[] } = {},
|
||||
|
|
@ -517,6 +535,7 @@ function runSessionStateAssertion(
|
|||
writeJson(join(stateDir, "agents", "main", "sessions", "legacy-session.json"), {
|
||||
id: "legacy-session",
|
||||
});
|
||||
writeSharedRuntimeCaches(stateDir, options.scenario === "versioned-runtime-deps");
|
||||
const fixtureEnv = setup(stateDir);
|
||||
for (const command of options.commands ?? ["assert-state"]) {
|
||||
execFileSync(process.execPath, [ASSERTIONS_PATH, command], {
|
||||
|
|
@ -526,6 +545,7 @@ function runSessionStateAssertion(
|
|||
OPENCLAW_STATE_DIR: stateDir,
|
||||
OPENCLAW_TEST_WORKSPACE_DIR: workspace,
|
||||
OPENCLAW_UPGRADE_SURVIVOR_SCENARIO: options.scenario ?? "base",
|
||||
OPENCLAW_UPGRADE_SURVIVOR_BASELINE_VERSION: "2026.4.24",
|
||||
},
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
|
@ -550,6 +570,7 @@ function assertConfiguredPluginState(params: { installPath?: string } = {}): voi
|
|||
id: "legacy-session",
|
||||
});
|
||||
writeMigratedSessionState(stateDir);
|
||||
writeSharedRuntimeCaches(stateDir);
|
||||
writeJson(join(matrixInstallDir, "package.json"), {
|
||||
name: "@openclaw/matrix",
|
||||
});
|
||||
|
|
@ -1745,6 +1766,40 @@ process.stdout.write(sessionDir + "\\n");
|
|||
expect(() => assertConfiguredPluginState()).not.toThrow();
|
||||
});
|
||||
|
||||
it.each(["base", "versioned-runtime-deps"])(
|
||||
"requires intact shared runtime cache contents for %s",
|
||||
(scenario) => {
|
||||
expect(() => runSessionStateAssertion(writeMigratedSessionState, { scenario })).not.toThrow();
|
||||
for (const mutation of ["remove", "corrupt"]) {
|
||||
expect(() =>
|
||||
runSessionStateAssertion(
|
||||
(stateDir) => {
|
||||
writeMigratedSessionState(stateDir);
|
||||
const root =
|
||||
scenario === "base"
|
||||
? join("discord", ".openclaw-runtime-deps-copy-stale")
|
||||
: "openclaw-2026.4.24-feishu";
|
||||
const sentinel = join(
|
||||
stateDir,
|
||||
"plugin-runtime-deps",
|
||||
root,
|
||||
"node_modules",
|
||||
"stale-sentinel",
|
||||
"package.json",
|
||||
);
|
||||
if (mutation === "remove") {
|
||||
rmSync(sentinel);
|
||||
} else {
|
||||
writeJson(sentinel, { name: "stale-sentinel", version: "changed" });
|
||||
}
|
||||
},
|
||||
{ scenario },
|
||||
),
|
||||
).toThrow(/stale-sentinel/);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("prefers session_nodes over stale file and cache session stores", () => {
|
||||
expect(() =>
|
||||
runSessionStateAssertion((stateDir) => {
|
||||
|
|
|
|||
|
|
@ -773,7 +773,7 @@ run_plugin_fixture_phase fixture-phase true
|
|||
"seed-legacy-runtime-deps-symlink",
|
||||
"configure-plugin-registry",
|
||||
"assert-prepublish-requests",
|
||||
"assert-legacy-plugin-dependency-debris-before-doctor",
|
||||
"assert-package-local-dependency-cleanup",
|
||||
"assert-legacy-plugin-dependency-debris-cleaned",
|
||||
"assert-legacy-runtime-deps-symlink-repaired",
|
||||
"fixture-plugin-consent",
|
||||
|
|
|
|||
88
test/scripts/upgrade-survivor-plugin-dependencies.test.ts
Normal file
88
test/scripts/upgrade-survivor-plugin-dependencies.test.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { spawnSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
function runDependencyFixture(
|
||||
requested: string,
|
||||
packagedDir?: string,
|
||||
mutation?: "remove-shared" | "keep-package",
|
||||
) {
|
||||
const root = realpathSync(mkdtempSync(join(tmpdir(), "openclaw-upgrade-dependencies-")));
|
||||
const prefix = join(root, "prefix");
|
||||
if (packagedDir) {
|
||||
mkdirSync(join(prefix, "lib/node_modules/openclaw", packagedDir, "discord"), {
|
||||
recursive: true,
|
||||
});
|
||||
}
|
||||
try {
|
||||
return spawnSync(
|
||||
"bash",
|
||||
[
|
||||
"-c",
|
||||
`set -euo pipefail
|
||||
source "$1"
|
||||
package_root() { printf '%s/lib/node_modules/openclaw\\n' "$npm_config_prefix"; }
|
||||
SCENARIO=plugin-deps-cleanup
|
||||
seed_legacy_plugin_dependency_debris
|
||||
assert_legacy_plugin_dependency_debris_present
|
||||
if [ "$2" != keep-package ]; then
|
||||
rm -rf "$(package_root)"
|
||||
mkdir -p "$(package_root)"
|
||||
fi
|
||||
if [ "$2" = remove-shared ]; then
|
||||
rm "$OPENCLAW_STATE_DIR/plugin-runtime-deps/discord-upgrade-survivor/node_modules/openclaw-upgrade-survivor-dep/package.json"
|
||||
fi
|
||||
# Later phases use the recorded seed, not the request or replaced package inventory.
|
||||
OPENCLAW_UPGRADE_SURVIVOR_PLUGIN_DEPS_CLEANUP_PLUGINS=absent-after-update
|
||||
assert_legacy_plugin_dependency_debris_cleaned
|
||||
assert_legacy_plugin_dependency_debris_cleaned
|
||||
`,
|
||||
"bash",
|
||||
resolve("scripts/e2e/lib/upgrade-survivor/plugin-dependency-fixtures.sh"),
|
||||
mutation ?? "",
|
||||
],
|
||||
{
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
npm_config_prefix: prefix,
|
||||
OPENCLAW_STATE_DIR: join(root, "state"),
|
||||
OPENCLAW_UPGRADE_SURVIVOR_PLUGIN_DEPS_CLEANUP_PLUGINS: requested,
|
||||
},
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(process.platform === "win32")("upgrade dependency fixture ownership", () => {
|
||||
it.each([
|
||||
["discord", "dist/extensions"],
|
||||
["absent-fixture discord another-absent", "dist/extensions"],
|
||||
["discord absent-fixture", "extensions"],
|
||||
])("preserves the actual seeded set for %s in %s", (requested, packagedDir) => {
|
||||
const result = runDependencyFixture(requested, packagedDir);
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(result.stdout).toContain(
|
||||
"Seeded legacy plugin dependency debris for configured plugin: discord",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a request with no packaged plugins to seed", () => {
|
||||
const result = runDependencyFixture("absent-fixture");
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain("could not find a requested packaged plugin directory");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["remove-shared", "shared plugin dependency state was removed"],
|
||||
["keep-package", "legacy plugin dependency debris survived"],
|
||||
] as const)("rejects %s after a mixed-domain seed", (mutation, message) => {
|
||||
const result = runDependencyFixture("discord absent-fixture", "dist/extensions", mutation);
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain(message);
|
||||
});
|
||||
});
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
export const cliProcessTestFiles = [
|
||||
"src/cli/acp-cli-exit.process.test.ts",
|
||||
"src/cli/cli-process-child.test-helpers.test.ts",
|
||||
"src/cli/completion-cli.runner.process.test.ts",
|
||||
"src/cli/cron-output.process.test.ts",
|
||||
"src/cli/gateway-backed-exit-health.process.test.ts",
|
||||
"src/cli/gateway-backed-exit.process.test.ts",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue