From 250376f8857709054a511934f863ceabc7ac6781 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 1 May 2026 08:49:02 +0100 Subject: [PATCH] fix: simplify bundled runtime dependency repair (#75183) Summary: - Merged fix: simplify bundled runtime dependency repair after ClawSweeper review. ClawSweeper fixups: - Included follow-up commit: fix: verify cached bundled runtime roots - Included follow-up commit: refactor: simplify plugin runtime startup paths - Included follow-up commit: refactor: trim plugin startup policy helpers - Included follow-up commit: refactor: trust package manager runtime deps materialization - Included follow-up commit: fix: narrow channel runtime deps skip policy - Included follow-up commit: refactor: defer startup plugin runtime deps - Ran the ClawSweeper repair loop before final review. Validation: - ClawSweeper review passed for head 04dc566534f232984370c9cba2c96099eb34c9ef. - Required merge gates passed before the squash merge. Prepared head SHA: 04dc566534f232984370c9cba2c96099eb34c9ef Review: https://github.com/openclaw/openclaw/pull/75183#issuecomment-4358383786 Co-authored-by: Peter Steinberger Co-authored-by: Shakker Co-authored-by: clawsweeper-repair --- CHANGELOG.md | 6 + .../OpenClaw/MenuSessionsInjector.swift | 3 +- docs/cli/channels.md | 3 + docs/cli/configure.md | 1 + docs/cli/gateway.md | 2 +- docs/cli/onboard.md | 2 + docs/cli/plugins.md | 8 +- docs/docs.json | 1 + docs/gateway/doctor.md | 2 +- docs/plugins/dependency-resolution.md | 214 ++++ docs/tools/acp-agents.md | 3 +- docs/tools/plugin.md | 9 +- scripts/lib/bundled-runtime-deps-install.mjs | 21 +- scripts/postinstall-bundled-plugins.mjs | 150 +-- scripts/release-check.ts | 2 +- .../plugins/bundled.shape-guard.test.ts | 4 +- src/channels/plugins/read-only.test.ts | 28 +- src/channels/plugins/read-only.ts | 6 +- src/cli/command-bootstrap.test.ts | 31 +- src/cli/command-bootstrap.ts | 17 +- src/cli/command-catalog.ts | 32 +- src/cli/command-execution-startup.test.ts | 35 + src/cli/command-execution-startup.ts | 1 + src/cli/command-path-policy.test.ts | 127 +- src/cli/command-path-policy.ts | 1 + src/cli/command-startup-policy.test.ts | 31 + src/cli/command-startup-policy.ts | 31 +- .../gateway-cli/run.option-collisions.test.ts | 8 + src/cli/plugin-registry-loader.test.ts | 18 +- src/cli/plugin-registry-loader.ts | 17 +- src/cli/plugins-cli.list.test.ts | 44 +- src/cli/plugins-cli.ts | 464 +------- src/cli/plugins-command-helpers.ts | 8 + src/cli/plugins-deps-command.test.ts | 79 +- src/cli/plugins-deps-command.ts | 82 +- src/cli/plugins-inspect-command.ts | 361 ++++++ src/cli/plugins-list-command.ts | 114 ++ src/cli/program/preaction.test.ts | 12 +- src/cli/route.test.ts | 10 +- src/commands/agents.providers.test.ts | 2 +- src/commands/agents.providers.ts | 4 +- .../channels.list.auth-profiles.test.ts | 4 +- src/commands/channels.remove.test.ts | 67 +- src/commands/channels.resolve.test.ts | 54 +- src/commands/channels/capabilities.ts | 2 +- src/commands/channels/list.ts | 2 +- src/commands/channels/remove.ts | 11 +- src/commands/channels/resolve.ts | 7 +- src/commands/channels/status-config-format.ts | 2 +- src/commands/configure.wizard.test.ts | 32 +- src/commands/configure.wizard.ts | 2 + ...doctor-bundled-plugin-runtime-deps.test.ts | 68 +- .../doctor-bundled-plugin-runtime-deps.ts | 49 +- src/commands/doctor-security.test.ts | 4 +- src/commands/doctor-security.ts | 2 +- .../doctor/shared/channel-doctor.test.ts | 2 +- src/commands/doctor/shared/channel-doctor.ts | 2 +- src/commands/health.snapshot.test.ts | 4 +- src/commands/health.ts | 4 +- .../onboard-non-interactive.gateway.test.ts | 15 + src/commands/onboard-non-interactive/local.ts | 2 + src/commands/post-config-runtime-deps.test.ts | 164 +++ src/commands/post-config-runtime-deps.ts | 133 +++ src/commands/status-all/channels.ts | 6 +- src/commands/status-runtime-shared.test.ts | 2 +- src/commands/status-runtime-shared.ts | 2 +- src/commands/status.link-channel.ts | 2 +- src/commands/status.scan-overview.test.ts | 4 +- src/commands/status.scan-overview.ts | 2 +- src/commands/status.scan.test.ts | 6 +- src/gateway/config-reload-plan.ts | 29 + src/gateway/config-reload.test.ts | 6 + src/gateway/config-reload.ts | 1 + src/gateway/server-aux-handlers.test.ts | 1 + src/gateway/server-plugin-bootstrap.ts | 6 +- src/gateway/server-plugins.ts | 6 +- src/gateway/server-reload-handlers.ts | 52 + src/gateway/server-runtime-state.test.ts | 73 ++ src/gateway/server-runtime-state.ts | 7 +- src/gateway/server-startup-early.test.ts | 61 +- src/gateway/server-startup-early.ts | 51 +- src/gateway/server-startup-plugins.test.ts | 185 ++- src/gateway/server-startup-plugins.ts | 238 ++-- .../server-startup-post-attach.test.ts | 107 ++ src/gateway/server-startup-post-attach.ts | 32 +- src/gateway/server-startup.ts | 2 +- src/gateway/server.impl.ts | 104 +- src/gateway/server.reload.test.ts | 92 ++ src/gateway/server/readiness.test.ts | 18 + src/gateway/server/readiness.ts | 4 +- src/infra/channel-summary.ts | 2 +- src/infra/npm-install-env.ts | 4 + src/infra/safe-package-install.test.ts | 10 + src/infra/safe-package-install.ts | 5 + src/plugin-sdk/facade-loader.test.ts | 2 +- src/plugins/bundled-runtime-deps-install.ts | 56 +- src/plugins/bundled-runtime-deps-lock.ts | 7 + .../bundled-runtime-deps-materialization.ts | 235 +++- .../bundled-runtime-deps-package-manager.ts | 8 +- src/plugins/bundled-runtime-deps-roots.ts | 113 +- src/plugins/bundled-runtime-deps-selection.ts | 10 +- src/plugins/bundled-runtime-deps.test.ts | 1038 +++++++++++++++-- src/plugins/bundled-runtime-deps.ts | 351 ++++-- src/plugins/bundled-runtime-root.test.ts | 112 +- src/plugins/bundled-runtime-root.ts | 165 ++- src/plugins/bundled-runtime-staging.test.ts | 103 ++ src/plugins/bundled-runtime-staging.ts | 92 ++ src/plugins/doctor-contract-registry.ts | 10 +- src/plugins/loader.test.ts | 6 +- src/plugins/loader.ts | 85 +- src/plugins/public-surface-loader.test.ts | 6 +- .../runtime/runtime-registry-loader.ts | 2 + src/plugins/setup-registry.ts | 6 +- src/plugins/status.ts | 1 + .../bundled-runtime-deps-fixtures.ts | 1 + ...it-channel-readonly-setup-fallback.test.ts | 2 +- src/security/audit.ts | 2 +- src/wizard/setup.test.ts | 45 + src/wizard/setup.ts | 2 + .../postinstall-bundled-plugins.test.ts | 194 +-- 120 files changed, 4642 insertions(+), 1758 deletions(-) create mode 100644 docs/plugins/dependency-resolution.md create mode 100644 src/cli/plugins-inspect-command.ts create mode 100644 src/cli/plugins-list-command.ts create mode 100644 src/commands/post-config-runtime-deps.test.ts create mode 100644 src/commands/post-config-runtime-deps.ts create mode 100644 src/gateway/server-runtime-state.test.ts create mode 100644 src/plugins/bundled-runtime-staging.test.ts create mode 100644 src/plugins/bundled-runtime-staging.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e3664c9f372..56b729e615c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai - Google Meet/Voice Call: play Twilio Meet DTMF before opening the realtime media stream and carry the intro as the initial Voice Call message, so the greeting is generated after Meet admits the phone participant instead of racing a live-call TwiML update. Thanks @donkeykong91 and @PfanP. - Google Meet/Voice Call: make Twilio setup preflight honor explicit `--transport twilio` and fail local/private Voice Call webhook URLs, including IPv6 loopback and unique-local forms, before joins. Thanks @donkeykong91 and @PfanP. - Voice Call/Twilio: retry transient 21220 live-call TwiML updates and catch answered-path initial-greeting failures, so a fast answered callback no longer crashes the Gateway or drops the Twilio greeting/listen transition. (#74606) Thanks @Sivan22. +- CLI/startup: preserve `OPENCLAW_HIDE_BANNER` banner suppression for route-first startup callers that rely on the default process environment while keeping read-only status/channel paths from repairing bundled plugin runtime dependencies. Refs #75183. - Voice Call/Twilio: register accepted media streams immediately but wait for realtime transcription readiness before speaking the initial greeting, so reconnect grace handling stays live while OpenAI STT startup is no longer starved by TTS. Fixes #75197. (#75257) Thanks @donkeykong91 and @PfanP. - Voice Call CLI: run gateway-delegated `voicecall continue` through operation-id polling and protocol-shaped errors, so long conversational turns keep their transcript result without blocking a single Gateway RPC. (#75459) Thanks @serrurco and @DougButdorf. - Voice Call CLI: delegate operational `voicecall` commands to the running Gateway runtime and skip webhook startup during CLI-only plugin loading, preventing webhook port conflicts and `setup --json` hangs. Fixes #72345. Thanks @serrurco and @DougButdorf. @@ -41,6 +42,11 @@ Docs: https://docs.openclaw.ai - Discord: report native slash-command deploy aborts as REST timeouts with method, path, timeout budget, and observed duration, so startup logs explain slow Discord API calls instead of showing a generic aborted operation. Thanks @discord. - Security/logging: redact payment credential field names such as card number, CVC/CVV, shared payment token, and payment credential across default log and tool-payload redaction patterns so wallet-style MCP tools do not expose raw payment credentials in UI events or transcripts. Thanks @stainlu. - Providers/OpenAI Codex: preserve existing wrapped Codex streams during OpenAI attribution so PI OAuth bearer injection reaches ChatGPT/Codex Responses, and strip native Codex-only unsupported payload fields without touching custom compatible endpoints. (#75111) Thanks @keshavbotagent. +- Plugins/runtime-deps: materialize newly required bundled plugin packages after local `openclaw onboard` and `openclaw configure` config writes, while keeping remote setup read-only, so first Gateway startup no longer discovers missing channel/provider deps after setup claimed success. Fixes #75309; refs #75069. Thanks @scottgl9 and @xiaohuaxi. +- Plugins/runtime-deps: expire stale legacy install locks whose live PID cannot be tied to the current process incarnation, so Docker PID reuse no longer leaves bundled dependency repair stuck behind old `.openclaw-runtime-deps.lock` directories. Fixes #74948; refs #74950 and #74346. Thanks @dchekmarev. +- Plugins/runtime-deps: recover interrupted bundled runtime-dependency installs whose package sentinels exist but generated materialization is incomplete, forcing npm/pnpm repair in Gateway startup, doctor, and lazy plugin loads instead of leaving channels crash-looping on missing packages. Fixes #75309; refs #75310, #75296, and #75304. Thanks @scottgl9. +- Plugins/runtime-deps: treat no-main and export-map package sentinels without reachable entry files as incomplete, so Gateway startup, doctor, and lazy plugin loads repair interrupted bundled dependency installs instead of accepting package.json-only partial installs. Fixes #75309; refs #75183. Thanks @shakkernerd. +- Plugins/runtime-deps: keep runtime inspection and channel maintenance commands from downloading bundled plugin dependencies, route explicit repairs through `openclaw plugins deps --repair`, and still allow Gateway/DO paths to repair missing deps before import. Refs #75069. Thanks @xiaohuaxi. - Agents/tool-result guard: use the resolved runtime context token budget for non-context-engine tool-result overflow checks, so long tool-heavy sessions no longer compact early when `contextTokens` is larger than native `contextWindow`. Fixes #74917. Thanks @kAIborg24. - Gateway/systemd: exit with sysexits 78 for supervised lock and `EADDRINUSE` conflicts so `RestartPreventExitStatus=78` stops `Restart=always` restart loops instead of repeatedly reloading plugins against an occupied port. Fixes #75115. Thanks @yhyatt. - Agents/runtime: skip blank visible user prompts at the embedded-runner boundary before provider submission while still allowing internal runtime-only turns and media-only prompts, so Telegram/group sessions no longer leak raw empty-input provider errors when replay history exists. Fixes #74137. Thanks @yelog, @Gracker, and @nhaener. diff --git a/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift b/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift index 2dd31b69676..7c7afedb999 100644 --- a/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift +++ b/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift @@ -300,7 +300,8 @@ extension MenuSessionsInjector { let headerItem = NSMenuItem() headerItem.tag = self.tag headerItem.isEnabled = false - let statusText = self.cachedErrorText ?? (isConnected ? nil : self.controlChannelStatusText(for: channelState)) + let statusText = self.cachedErrorText + ?? (isConnected ? nil : self.controlChannelStatusText(for: channelState)) headerItem.view = self.makeHostedView( rootView: AnyView(MenuSessionsHeaderView( count: rows.count, diff --git a/docs/cli/channels.md b/docs/cli/channels.md index 3af98b8be85..438cb3ee949 100644 --- a/docs/cli/channels.md +++ b/docs/cli/channels.md @@ -51,6 +51,8 @@ openclaw channels remove --channel telegram --delete `openclaw channels add --help` shows per-channel flags (token, private key, app token, signal-cli paths, etc). +`channels remove` only operates on installed/configured channel plugins. Use `channels add` first for installable catalog channels. + Common non-interactive add surfaces include: - bot-token channels: `--token`, `--bot-token`, `--app-token`, `--token-file` @@ -132,6 +134,7 @@ Notes: - Use `--kind user|group|auto` to force the target type. - Resolution prefers active matches when multiple entries share the same name. - `channels resolve` is read-only. If a selected account is configured via SecretRef but that credential is unavailable in the current command path, the command returns degraded unresolved results with notes instead of aborting the entire run. +- `channels resolve` does not install channel plugins. Use `channels add --channel ` before resolving names for an installable catalog channel. ## Related diff --git a/docs/cli/configure.md b/docs/cli/configure.md index 207bc526c09..69daa2b198a 100644 --- a/docs/cli/configure.md +++ b/docs/cli/configure.md @@ -52,6 +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. - 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/gateway.md b/docs/cli/gateway.md index 01d66251610..e88f859c1cd 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 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 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. ### `gateway usage-cost` diff --git a/docs/cli/onboard.md b/docs/cli/onboard.md index 718eb1e327c..01c9f29e19b 100644 --- a/docs/cli/onboard.md +++ b/docs/cli/onboard.md @@ -119,6 +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. - `--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 43b76d256d3..493b45ed1ec 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -33,6 +33,7 @@ openclaw plugins list --verbose openclaw plugins list --json openclaw plugins install openclaw plugins inspect +openclaw plugins inspect --runtime openclaw plugins inspect --json openclaw plugins inspect --all openclaw plugins info @@ -234,7 +235,7 @@ directory remains inert so normal packaged installs still use compiled dist. For runtime hook debugging: -- `openclaw plugins inspect --json` shows registered hooks and diagnostics from a module-loaded inspection pass. +- `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 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`. @@ -269,6 +270,8 @@ openclaw plugins deps --json 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 @@ -319,10 +322,11 @@ Updates apply to tracked plugin installs in the managed plugin index and tracked ```bash openclaw plugins inspect +openclaw plugins inspect --runtime openclaw plugins inspect --json ``` -Deep introspection for a single plugin. Shows identity, load status, source, registered capabilities, hooks, tools, commands, services, gateway methods, HTTP routes, policy flags, diagnostics, install metadata, bundle capabilities, and any detected MCP or LSP server support. +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. Each plugin is classified by what it actually registers at runtime: diff --git a/docs/docs.json b/docs/docs.json index abfbfd02896..ece2b4d5fd5 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1182,6 +1182,7 @@ "tools/plugin", "plugins/community", "plugins/bundles", + "plugins/dependency-resolution", "plugins/codex-harness", "plugins/codex-computer-use", "plugins/google-meet", diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index 71659e9eeca..63fe501cc74 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -342,7 +342,7 @@ That stages grounded durable candidates into the short-term dreaming store while 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. - During doctor repair, bundled runtime-dependency npm installs report spinner progress in TTY sessions and periodic line progress in piped/headless output. The Gateway and local CLI can also repair active bundled plugin runtime dependencies on demand before importing a bundled plugin. 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. + During doctor repair, bundled runtime-dependency npm installs report spinner progress in TTY sessions and periodic line progress in piped/headless output. The Gateway and local CLI can also repair active bundled plugin runtime dependencies on demand before importing a bundled plugin. 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. diff --git a/docs/plugins/dependency-resolution.md b/docs/plugins/dependency-resolution.md new file mode 100644 index 00000000000..2f77f9be901 --- /dev/null +++ b/docs/plugins/dependency-resolution.md @@ -0,0 +1,214 @@ +--- +summary: "How OpenClaw plans, stages, and repairs bundled plugin runtime dependencies" +read_when: + - You are debugging bundled plugin runtime dependency repair + - 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. + +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`. + +## Responsibility split + +OpenClaw owns the plan and policy: + +- 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 + +The package manager owns dependency convergence: + +- 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. + +## 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_PLUGIN_STAGE_DIR` +- `$STATE_DIRECTORY` +- `~/.openclaw/plugin-runtime-deps` +- `/var/lib/openclaw/plugin-runtime-deps` in container-style installs + +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: + +```bash +openclaw plugins deps +openclaw plugins deps --json +openclaw plugins deps --repair +openclaw plugins deps --prune +``` + +Use doctor when the dependency state is part of broader install health: + +```bash +openclaw doctor +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. + +## Troubleshooting + +If a packaged install reports missing bundled runtime dependencies: + +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. + +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. diff --git a/docs/tools/acp-agents.md b/docs/tools/acp-agents.md index 34111c2ffb4..9c076687ca3 100644 --- a/docs/tools/acp-agents.md +++ b/docs/tools/acp-agents.md @@ -41,7 +41,8 @@ directly to existing OpenClaw channel conversations, use Usually yes. Fresh installs ship the bundled `acpx` runtime plugin enabled by default with a plugin-local pinned `acpx` binary that OpenClaw probes -and self-repairs on startup. Run `/acp doctor` for a readiness check. +and self-repairs immediately after the Gateway HTTP listener is live. Run +`/acp doctor` for a readiness check. OpenClaw only teaches agents about ACP spawning when ACP is **truly usable**: ACP must be enabled, dispatch must not be disabled, the current diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index c0dfbbe437b..31ff50d7129 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -93,6 +93,8 @@ 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 types @@ -309,7 +311,7 @@ do not run in live chat traffic, check these first: - Restart the live Gateway after plugin install/config/code changes. In wrapper containers, PID 1 may only be a supervisor; restart or signal the child `openclaw gateway run` process. -- Use `openclaw plugins inspect --json` to confirm hook registrations and +- Use `openclaw plugins inspect --runtime --json` to confirm hook registrations and diagnostics. Non-bundled conversation hooks such as `llm_input`, `llm_output`, `before_agent_finalize`, and `agent_end` need `plugins.entries..hooks.allowConversationAccess=true`. @@ -336,7 +338,7 @@ Debug steps: - Run `openclaw plugins list --enabled --verbose` to see every enabled plugin and origin. -- Run `openclaw plugins inspect --json` for each suspected plugin and +- Run `openclaw plugins inspect --runtime --json` for each suspected plugin and compare `channels`, `channelConfigs`, `tools`, and diagnostics. - Run `openclaw plugins registry --refresh` after installing or removing plugin packages so persisted metadata reflects the current install. @@ -381,7 +383,8 @@ openclaw plugins list # compact inventory openclaw plugins list --enabled # only enabled plugins openclaw plugins list --verbose # per-plugin detail lines openclaw plugins list --json # machine-readable inventory -openclaw plugins inspect # deep detail +openclaw plugins inspect # static detail +openclaw plugins inspect --runtime # registered hooks/tools/diagnostics openclaw plugins inspect --json # machine-readable openclaw plugins inspect --all # fleet-wide table openclaw plugins info # inspect alias diff --git a/scripts/lib/bundled-runtime-deps-install.mjs b/scripts/lib/bundled-runtime-deps-install.mjs index 730bc727bd8..3fb5ca47a6f 100644 --- a/scripts/lib/bundled-runtime-deps-install.mjs +++ b/scripts/lib/bundled-runtime-deps-install.mjs @@ -1,10 +1,22 @@ 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 }; - delete nextEnv.npm_config_global; - delete nextEnv.npm_config_location; - delete nextEnv.npm_config_prefix; + for (const key of Object.keys(nextEnv)) { + if (NPM_CONFIG_KEYS_TO_RESET.has(key.toLowerCase())) { + delete nextEnv[key]; + } + } return nextEnv; } @@ -16,9 +28,11 @@ export function createBundledRuntimeDependencyInstallEnv(env = process.env, opti 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"; @@ -41,6 +55,7 @@ export function createBundledRuntimeDependencyInstallArgs(specs = [], options = ...(options.noAudit ? ["--no-audit"] : []), ...(options.noFund ? ["--no-fund"] : []), "--ignore-scripts", + "--workspaces=false", ...(options.silent ? ["--silent"] : []), ...specs, ]; diff --git a/scripts/postinstall-bundled-plugins.mjs b/scripts/postinstall-bundled-plugins.mjs index a4a1ad50527..6be66047e43 100644 --- a/scripts/postinstall-bundled-plugins.mjs +++ b/scripts/postinstall-bundled-plugins.mjs @@ -1,10 +1,8 @@ #!/usr/bin/env node // Runs after install to keep packaged dist safe and compatible. -// Bundled extension runtime dependencies are extension-owned. Do not install -// every bundled extension dependency during core package install unless the -// legacy eager-install escape hatch is explicitly enabled; `openclaw doctor -// --fix` owns the repair path for extensions that are actually used. -import { spawnSync } from "node:child_process"; +// 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. import { randomUUID } from "node:crypto"; import { chmodSync, @@ -24,28 +22,12 @@ import { import { tmpdir } from "node:os"; import { basename, dirname, isAbsolute, join, posix, relative } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { - createBundledRuntimeDependencyInstallArgs, - createBundledRuntimeDependencyInstallEnv, - createNestedNpmInstallEnv, - runBundledRuntimeDependencyNpmInstall, -} from "./lib/bundled-runtime-deps-install.mjs"; -import { resolveNpmRunner } from "./npm-runner.mjs"; - -export { - createBundledRuntimeDependencyInstallArgs, - createBundledRuntimeDependencyInstallEnv, - createNestedNpmInstallEnv, -}; - -export const BUNDLED_PLUGIN_INSTALL_TARGETS = []; 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"; -const EAGER_BUNDLED_PLUGIN_DEPS_ENV = "OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS"; const DIST_INVENTORY_PATH = "dist/postinstall-inventory.json"; const BAILEYS_MEDIA_FILE = join( "node_modules", @@ -475,54 +457,6 @@ function dependencySentinelPath(depName) { return join("node_modules", ...depName.split("/"), "package.json"); } -const KNOWN_NATIVE_PLATFORMS = new Set([ - "aix", - "android", - "darwin", - "freebsd", - "linux", - "openbsd", - "sunos", - "win32", -]); -const KNOWN_NATIVE_ARCHES = new Set(["arm", "arm64", "ia32", "ppc64", "riscv64", "s390x", "x64"]); - -function packageNameTokens(name) { - return name - .toLowerCase() - .split(/[/@._-]+/u) - .filter(Boolean); -} - -function optionalDependencyTargetsRuntime(name, params = {}) { - const platform = params.platform ?? process.platform; - const arch = params.arch ?? process.arch; - const tokens = new Set(packageNameTokens(name)); - const hasNativePlatformToken = [...tokens].some((token) => KNOWN_NATIVE_PLATFORMS.has(token)); - const hasNativeArchToken = [...tokens].some((token) => KNOWN_NATIVE_ARCHES.has(token)); - return hasNativePlatformToken && hasNativeArchToken && tokens.has(platform) && tokens.has(arch); -} - -function runtimeDepNeedsInstall(params) { - const packageJsonPath = join(params.packageRoot, params.dep.sentinelPath); - if (!params.existsSync(packageJsonPath)) { - return true; - } - - try { - const packageJson = params.readJson(packageJsonPath); - return Object.keys(packageJson.optionalDependencies ?? {}).some( - (childName) => - optionalDependencyTargetsRuntime(childName, { - arch: params.arch, - platform: params.platform, - }) && !params.existsSync(join(params.packageRoot, dependencySentinelPath(childName))), - ); - } catch { - return true; - } -} - function collectRuntimeDeps(packageJson) { return { ...packageJson.dependencies, @@ -535,17 +469,7 @@ export function discoverBundledPluginRuntimeDeps(params = {}) { const pathExists = params.existsSync ?? existsSync; const readDir = params.readdirSync ?? readdirSync; const readJsonFile = params.readJson ?? readJson; - const deps = new Map( - BUNDLED_PLUGIN_INSTALL_TARGETS.map((target) => [ - target.name, - { - name: target.name, - version: target.version, - sentinelPath: dependencySentinelPath(target.name), - pluginIds: [...(target.pluginIds ?? [])], - }, - ]), - ); + const deps = new Map(); if (!pathExists(extensionsDir)) { return [...deps.values()].toSorted((a, b) => a.name.localeCompare(b.name)); @@ -594,10 +518,6 @@ export function discoverBundledPluginRuntimeDeps(params = {}) { .toSorted((a, b) => a.name.localeCompare(b.name)); } -function shouldEagerInstallBundledPluginDeps(env = process.env) { - return env?.[EAGER_BUNDLED_PLUGIN_DEPS_ENV]?.trim() === "1"; -} - export function applyBaileysEncryptedStreamFinishHotfix(params = {}) { const packageRoot = params.packageRoot ?? DEFAULT_PACKAGE_ROOT; const pathExists = params.existsSync ?? existsSync; @@ -890,7 +810,6 @@ export function runBundledPluginPostinstall(params = {}) { const env = params.env ?? process.env; const packageRoot = params.packageRoot ?? DEFAULT_PACKAGE_ROOT; const extensionsDir = params.extensionsDir ?? join(packageRoot, "dist", "extensions"); - const spawn = params.spawnSync ?? spawnSync; const pathExists = params.existsSync ?? existsSync; const log = params.log ?? console; if (env?.[DISABLE_POSTINSTALL_ENV]?.trim()) { @@ -940,67 +859,6 @@ export function runBundledPluginPostinstall(params = {}) { ) { return; } - if (!shouldEagerInstallBundledPluginDeps(env)) { - applyBundledPluginRuntimeHotfixes({ - packageRoot, - existsSync: pathExists, - readFileSync: params.readFileSync, - writeFileSync: params.writeFileSync, - log, - }); - return; - } - const runtimeDeps = - params.runtimeDeps ?? - discoverBundledPluginRuntimeDeps({ extensionsDir, existsSync: pathExists }); - const missingSpecs = runtimeDeps - .filter((dep) => - runtimeDepNeedsInstall({ - dep, - existsSync: pathExists, - packageRoot, - arch: params.arch, - platform: params.platform, - readJson: params.readJson ?? readJson, - }), - ) - .map((dep) => `${dep.name}@${dep.version}`); - - if (missingSpecs.length === 0) { - applyBundledPluginRuntimeHotfixes({ - packageRoot, - existsSync: pathExists, - readFileSync: params.readFileSync, - writeFileSync: params.writeFileSync, - log, - }); - return; - } - - try { - const installEnv = createBundledRuntimeDependencyInstallEnv(env); - const npmRunner = - params.npmRunner ?? - resolveNpmRunner({ - env: installEnv, - execPath: params.execPath, - existsSync: pathExists, - platform: params.platform, - comSpec: params.comSpec, - npmArgs: createBundledRuntimeDependencyInstallArgs(missingSpecs), - }); - runBundledRuntimeDependencyNpmInstall({ - cwd: packageRoot, - npmRunner, - env: npmRunner.env ?? installEnv, - spawnSyncImpl: spawn, - }); - log.log(`[postinstall] installed bundled plugin deps: ${missingSpecs.join(", ")}`); - } catch (e) { - // Non-fatal: gateway will surface the missing dep via doctor. - log.warn(`[postinstall] could not install bundled plugin deps: ${String(e)}`); - } - applyBundledPluginRuntimeHotfixes({ packageRoot, existsSync: pathExists, diff --git a/scripts/release-check.ts b/scripts/release-check.ts index f8cdcc4fc0b..903120a6040 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -25,7 +25,7 @@ import { import { resolveBundledRuntimeDependencyInstallRoot, resolveBundledRuntimeDependencyPackageInstallRoot, -} from "../src/plugins/bundled-runtime-deps.ts"; +} from "../src/plugins/bundled-runtime-deps-roots.ts"; import { checkCliBootstrapExternalImports } from "./check-cli-bootstrap-imports.mjs"; import { collectBundledExtensionManifestErrors, diff --git a/src/channels/plugins/bundled.shape-guard.test.ts b/src/channels/plugins/bundled.shape-guard.test.ts index 009d34c3645..88c1b648b88 100644 --- a/src/channels/plugins/bundled.shape-guard.test.ts +++ b/src/channels/plugins/bundled.shape-guard.test.ts @@ -611,7 +611,7 @@ describe("bundled channel entry shape guards", () => { process.env.OPENCLAW_PLUGIN_STAGE_DIR = stageRoot; const { resolveBundledRuntimeDependencyInstallRoot } = - await import("../../plugins/bundled-runtime-deps.js"); + 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 }); @@ -708,7 +708,7 @@ describe("bundled channel entry shape guards", () => { process.env.OPENCLAW_PLUGIN_STAGE_DIR = stageRoot; const { resolveBundledRuntimeDependencyInstallRoot } = - await import("../../plugins/bundled-runtime-deps.js"); + 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 }); diff --git a/src/channels/plugins/read-only.test.ts b/src/channels/plugins/read-only.test.ts index 957c13f8a9d..dadbf6614c5 100644 --- a/src/channels/plugins/read-only.test.ts +++ b/src/channels/plugins/read-only.test.ts @@ -479,7 +479,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => { { env: { ...process.env }, includePersistedAuthState: false, - includeSetupRuntimeFallback: true, + includeSetupFallbackPlugins: true, }, ); @@ -505,7 +505,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => { { env: { ...process.env }, includePersistedAuthState: false, - includeSetupRuntimeFallback: true, + includeSetupFallbackPlugins: true, }, ); @@ -537,7 +537,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => { { env: { ...process.env }, includePersistedAuthState: false, - includeSetupRuntimeFallback: true, + includeSetupFallbackPlugins: true, }, ); @@ -584,7 +584,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => { { env: { ...process.env }, includePersistedAuthState: false, - includeSetupRuntimeFallback: true, + includeSetupFallbackPlugins: true, }, ); @@ -624,7 +624,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => { { env: { ...process.env }, includePersistedAuthState: false, - includeSetupRuntimeFallback: true, + includeSetupFallbackPlugins: true, }, ); @@ -654,7 +654,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => { { env: { ...process.env }, includePersistedAuthState: false, - includeSetupRuntimeFallback: true, + includeSetupFallbackPlugins: true, }, ); @@ -832,7 +832,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => { { env: { ...process.env }, includePersistedAuthState: false, - includeSetupRuntimeFallback: true, + includeSetupFallbackPlugins: true, }, ); @@ -857,7 +857,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => { { env: { ...process.env, EXTERNAL_CHAT_TOKEN: "configured" }, includePersistedAuthState: false, - includeSetupRuntimeFallback: true, + includeSetupFallbackPlugins: true, }, ); @@ -901,7 +901,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => { { env: { ...process.env, [envVar]: "configured" }, includePersistedAuthState: false, - includeSetupRuntimeFallback: true, + includeSetupFallbackPlugins: true, }, ); @@ -927,7 +927,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => { { env: { ...process.env, [envVar]: "configured" }, includePersistedAuthState: false, - includeSetupRuntimeFallback: true, + includeSetupFallbackPlugins: true, }, ); @@ -975,7 +975,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => { { env: { ...process.env, [envVar]: "configured" }, includePersistedAuthState: false, - includeSetupRuntimeFallback: true, + includeSetupFallbackPlugins: true, }, ); @@ -1005,7 +1005,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => { EXTERNAL_CHAT_TOKEN: "configured", workspaceDir: "workspace-env-value", }, - includeSetupRuntimeFallback: true, + includeSetupFallbackPlugins: true, }, ); @@ -1041,7 +1041,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => { { env: { ...process.env }, includePersistedAuthState: false, - includeSetupRuntimeFallback: true, + includeSetupFallbackPlugins: true, }, ); @@ -1070,7 +1070,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => { } as never, { env: { ...process.env }, - includeSetupRuntimeFallback: true, + includeSetupFallbackPlugins: true, }, ); diff --git a/src/channels/plugins/read-only.ts b/src/channels/plugins/read-only.ts index 8278595160b..02edda47325 100644 --- a/src/channels/plugins/read-only.ts +++ b/src/channels/plugins/read-only.ts @@ -115,7 +115,7 @@ type ReadOnlyChannelPluginOptions = { workspaceDir?: string; activationSourceConfig?: OpenClawConfig; includePersistedAuthState?: boolean; - includeSetupRuntimeFallback?: boolean; + includeSetupFallbackPlugins?: boolean; }; type ReadOnlyChannelPluginResolution = { @@ -676,7 +676,7 @@ export function resolveReadOnlyChannelPluginsForConfig( addChannelPlugins(byId, listChannelPlugins()); - if (options.includeSetupRuntimeFallback === true) { + if (options.includeSetupFallbackPlugins === true) { for (const channelId of configuredChannelIds) { if (byId.has(channelId)) { continue; @@ -713,7 +713,7 @@ export function resolveReadOnlyChannelPluginsForConfig( .filter((record) => externalPluginIdSet.has(record.id)) .map((record) => [record.id, record.channels] as const), ); - if (missingConfiguredChannelIds.length > 0 && options.includeSetupRuntimeFallback === true) { + if (missingConfiguredChannelIds.length > 0 && options.includeSetupFallbackPlugins === true) { const missingChannelIdSet = new Set(missingConfiguredChannelIds); const ownedMissingChannelIdsByPluginId = new Map( [...ownedChannelIdsByPluginId].map( diff --git a/src/cli/command-bootstrap.test.ts b/src/cli/command-bootstrap.test.ts index 40d3d4e0fd0..8f99f92f094 100644 --- a/src/cli/command-bootstrap.test.ts +++ b/src/cli/command-bootstrap.test.ts @@ -9,9 +9,6 @@ vi.mock("./program/config-guard.js", () => ({ vi.mock("./plugin-registry-loader.js", () => ({ ensureCliPluginRegistryLoaded: ensureCliPluginRegistryLoadedMock, - resolvePluginRegistryScopeForCommandPath: vi.fn((commandPath: string[]) => - commandPath[0] === "status" || commandPath[0] === "health" ? "channels" : "all", - ), })); describe("ensureCliCommandBootstrap", () => { @@ -59,6 +56,34 @@ describe("ensureCliCommandBootstrap", () => { expect(ensureCliPluginRegistryLoadedMock).toHaveBeenCalledWith({ scope: "channels", routeLogsToStderr: true, + installBundledRuntimeDeps: false, + }); + }); + + it("loads configured channel plugins with repair enabled for operational channel commands", async () => { + await ensureCliCommandBootstrap({ + runtime: {} as never, + commandPath: ["channels", "send"], + loadPlugins: true, + }); + + expect(ensureCliPluginRegistryLoadedMock).toHaveBeenCalledWith({ + scope: "configured-channels", + routeLogsToStderr: undefined, + }); + }); + + it("loads configured channel plugins without repairing runtime deps for read-only channel commands", async () => { + await ensureCliCommandBootstrap({ + runtime: {} as never, + commandPath: ["channels", "resolve"], + loadPlugins: true, + }); + + expect(ensureCliPluginRegistryLoadedMock).toHaveBeenCalledWith({ + scope: "configured-channels", + routeLogsToStderr: undefined, + installBundledRuntimeDeps: false, }); }); diff --git a/src/cli/command-bootstrap.ts b/src/cli/command-bootstrap.ts index 8ce9e7cfd66..f48dc37bda6 100644 --- a/src/cli/command-bootstrap.ts +++ b/src/cli/command-bootstrap.ts @@ -1,8 +1,7 @@ import type { RuntimeEnv } from "../runtime.js"; -import { - ensureCliPluginRegistryLoaded, - resolvePluginRegistryScopeForCommandPath, -} from "./plugin-registry-loader.js"; +import type { CliPluginRegistryPolicy } from "./command-catalog.js"; +import { resolveCliCommandPathPolicy } from "./command-path-policy.js"; +import { ensureCliPluginRegistryLoaded } from "./plugin-registry-loader.js"; let configGuardModulePromise: Promise | undefined; @@ -18,6 +17,7 @@ export async function ensureCliCommandBootstrap(params: { skipConfigGuard?: boolean; allowInvalid?: boolean; loadPlugins?: boolean; + pluginRegistry?: CliPluginRegistryPolicy; }) { if (!params.skipConfigGuard) { const { ensureConfigReady } = await loadConfigGuardModule(); @@ -31,8 +31,15 @@ export async function ensureCliCommandBootstrap(params: { if (!params.loadPlugins) { return; } + const pluginRegistryLoadPolicy = + params.pluginRegistry ?? resolveCliCommandPathPolicy(params.commandPath).pluginRegistry; await ensureCliPluginRegistryLoaded({ - scope: resolvePluginRegistryScopeForCommandPath(params.commandPath), + 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 c6451a4f7cc..589f55be8cc 100644 --- a/src/cli/command-catalog.ts +++ b/src/cli/command-catalog.ts @@ -6,6 +6,11 @@ export type CliCommandPluginLoadPolicy = | "text-only" | ((ctx: { argv: string[]; commandPath: string[]; jsonOutputMode: boolean }) => boolean); 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 = | CliNetworkProxyPolicy @@ -29,6 +34,7 @@ export type CliCommandPathPolicy = { bypassConfigGuard: boolean; routeConfigGuard: CliRouteConfigGuardPolicy; loadPlugins: CliCommandPluginLoadPolicy; + pluginRegistry: CliPluginRegistryPolicy; hideBanner: boolean; ensureCliPath: boolean; networkProxy: CliNetworkProxyPolicyResolver; @@ -57,7 +63,13 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [ }, }, { commandPath: ["message"], policy: { loadPlugins: "never" } }, - { commandPath: ["channels"], policy: { loadPlugins: "always" } }, + { + commandPath: ["channels"], + policy: { + loadPlugins: "always", + pluginRegistry: { scope: "configured-channels" }, + }, + }, { commandPath: ["directory"], policy: { loadPlugins: "always" } }, { commandPath: ["agents"], policy: { loadPlugins: "always", networkProxy: "bypass" } }, { @@ -100,6 +112,7 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [ commandPath: ["status"], policy: { loadPlugins: "never", + pluginRegistry: { scope: "channels", installBundledRuntimeDeps: false }, routeConfigGuard: "when-suppressed", ensureCliPath: false, networkProxy: "bypass", @@ -108,7 +121,12 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [ }, { commandPath: ["health"], - policy: { loadPlugins: "never", ensureCliPath: false, networkProxy: "bypass" }, + policy: { + loadPlugins: "never", + pluginRegistry: { scope: "channels", installBundledRuntimeDeps: false }, + ensureCliPath: false, + networkProxy: "bypass", + }, route: { id: "health" }, }, { @@ -303,12 +321,18 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [ { commandPath: ["channels", "remove"], exact: true, - policy: { networkProxy: "bypass" }, + policy: { + pluginRegistry: { scope: "configured-channels", installBundledRuntimeDeps: false }, + networkProxy: "bypass", + }, }, { commandPath: ["channels", "resolve"], exact: true, - policy: { networkProxy: "bypass" }, + policy: { + pluginRegistry: { scope: "configured-channels", installBundledRuntimeDeps: false }, + networkProxy: "bypass", + }, }, { commandPath: ["channels", "status"], diff --git a/src/cli/command-execution-startup.test.ts b/src/cli/command-execution-startup.test.ts index 263953cdb62..851ce614c84 100644 --- a/src/cli/command-execution-startup.test.ts +++ b/src/cli/command-execution-startup.test.ts @@ -30,6 +30,7 @@ describe("command-execution-startup", () => { mod.resolveCliExecutionStartupContext({ argv: ["node", "openclaw", "status", "--json"], jsonOutputMode: true, + env: {}, routeMode: true, }), ).toEqual({ @@ -46,10 +47,38 @@ describe("command-execution-startup", () => { hideBanner: false, skipConfigGuard: true, loadPlugins: false, + pluginRegistry: { scope: "channels", installBundledRuntimeDeps: false }, }, }); }); + it("uses process env banner suppression when startup env is omitted", () => { + const originalHideBanner = process.env.OPENCLAW_HIDE_BANNER; + try { + process.env.OPENCLAW_HIDE_BANNER = "1"; + + expect( + mod.resolveCliExecutionStartupContext({ + argv: ["node", "openclaw", "status"], + jsonOutputMode: false, + }).startupPolicy.hideBanner, + ).toBe(true); + expect( + mod.resolveCliExecutionStartupContext({ + argv: ["node", "openclaw", "status"], + jsonOutputMode: false, + env: {}, + }).startupPolicy.hideBanner, + ).toBe(false); + } finally { + if (originalHideBanner === undefined) { + delete process.env.OPENCLAW_HIDE_BANNER; + } else { + process.env.OPENCLAW_HIDE_BANNER = originalHideBanner; + } + } + }); + it("skips local plugin bootstrap for JSON gateway agent calls", () => { expect( mod.resolveCliExecutionStartupContext({ @@ -88,6 +117,7 @@ describe("command-execution-startup", () => { hideBanner: false, skipConfigGuard: false, loadPlugins: true, + pluginRegistry: { scope: "all" }, }, version: "1.2.3", argv: ["node", "openclaw", "status"], @@ -104,6 +134,7 @@ describe("command-execution-startup", () => { hideBanner: true, skipConfigGuard: false, loadPlugins: true, + pluginRegistry: { scope: "all" }, }, version: "1.2.3", showBanner: true, @@ -122,6 +153,7 @@ describe("command-execution-startup", () => { hideBanner: false, skipConfigGuard: true, loadPlugins: false, + pluginRegistry: { scope: "channels", installBundledRuntimeDeps: false }, }, }); @@ -131,6 +163,7 @@ describe("command-execution-startup", () => { suppressDoctorStdout: true, allowInvalid: undefined, loadPlugins: false, + pluginRegistry: { scope: "channels", installBundledRuntimeDeps: false }, skipConfigGuard: true, }); @@ -143,6 +176,7 @@ describe("command-execution-startup", () => { hideBanner: false, skipConfigGuard: false, loadPlugins: false, + pluginRegistry: { scope: "all" }, }, allowInvalid: true, loadPlugins: true, @@ -154,6 +188,7 @@ describe("command-execution-startup", () => { suppressDoctorStdout: false, allowInvalid: true, loadPlugins: true, + pluginRegistry: { scope: "all" }, skipConfigGuard: false, }); }); diff --git a/src/cli/command-execution-startup.ts b/src/cli/command-execution-startup.ts index ffe8107f11c..94223e43ab2 100644 --- a/src/cli/command-execution-startup.ts +++ b/src/cli/command-execution-startup.ts @@ -62,6 +62,7 @@ export async function ensureCliExecutionBootstrap(params: { suppressDoctorStdout: params.startupPolicy.suppressDoctorStdout, allowInvalid: params.allowInvalid, loadPlugins: params.loadPlugins ?? params.startupPolicy.loadPlugins, + pluginRegistry: params.startupPolicy.pluginRegistry, skipConfigGuard: params.skipConfigGuard ?? params.startupPolicy.skipConfigGuard, }); } diff --git a/src/cli/command-path-policy.test.ts b/src/cli/command-path-policy.test.ts index ae9b08dbb1d..c4cb4a023d9 100644 --- a/src/cli/command-path-policy.test.ts +++ b/src/cli/command-path-policy.test.ts @@ -1,11 +1,31 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import type { CliCommandCatalogEntry } from "./command-catalog.js"; +import type { CliCommandCatalogEntry, CliCommandPathPolicy } from "./command-catalog.js"; import { resolveCliCatalogCommandPath, resolveCliCommandPathPolicy, resolveCliNetworkProxyPolicy, } from "./command-path-policy.js"; +const DEFAULT_EXPECTED_POLICY: CliCommandPathPolicy = { + bypassConfigGuard: false, + routeConfigGuard: "never", + loadPlugins: "never", + pluginRegistry: { scope: "all" }, + hideBanner: false, + ensureCliPath: true, + networkProxy: "default", +}; + +function expectResolvedPolicy( + commandPath: string[], + expected: Partial, +): void { + expect(resolveCliCommandPathPolicy(commandPath)).toEqual({ + ...DEFAULT_EXPECTED_POLICY, + ...expected, + }); +} + describe("command-path-policy", () => { afterEach(() => { vi.doUnmock("./command-catalog.js"); @@ -13,66 +33,63 @@ describe("command-path-policy", () => { }); it("resolves status policy with shared startup semantics", () => { - expect(resolveCliCommandPathPolicy(["status"])).toEqual({ - bypassConfigGuard: false, + expectResolvedPolicy(["status"], { routeConfigGuard: "when-suppressed", loadPlugins: "never", - hideBanner: false, + pluginRegistry: { scope: "channels", installBundledRuntimeDeps: false }, ensureCliPath: false, networkProxy: "bypass", }); }); it("applies exact overrides after broader channel plugin rules", () => { - expect(resolveCliCommandPathPolicy(["channels", "send"])).toEqual({ - bypassConfigGuard: false, - routeConfigGuard: "never", + expectResolvedPolicy(["channels", "send"], { loadPlugins: "always", - hideBanner: false, - ensureCliPath: true, - networkProxy: "default", + pluginRegistry: { scope: "configured-channels" }, }); - expect(resolveCliCommandPathPolicy(["channels", "add"])).toEqual({ - bypassConfigGuard: false, - routeConfigGuard: "never", + expectResolvedPolicy(["channels", "login"], { + loadPlugins: "always", + pluginRegistry: { scope: "configured-channels" }, + }); + expectResolvedPolicy(["channels", "capabilities"], { + loadPlugins: "always", + pluginRegistry: { scope: "configured-channels" }, + }); + expectResolvedPolicy(["channels", "add"], { loadPlugins: "never", - hideBanner: false, - ensureCliPath: true, + pluginRegistry: { scope: "configured-channels" }, networkProxy: "bypass", }); - expect(resolveCliCommandPathPolicy(["channels", "status"])).toEqual({ - bypassConfigGuard: false, - routeConfigGuard: "never", + expectResolvedPolicy(["channels", "status"], { loadPlugins: "never", - hideBanner: false, - ensureCliPath: true, + pluginRegistry: { scope: "configured-channels" }, networkProxy: expect.any(Function), }); - expect(resolveCliCommandPathPolicy(["channels", "list"])).toEqual({ - bypassConfigGuard: false, - routeConfigGuard: "never", + expectResolvedPolicy(["channels", "list"], { loadPlugins: "never", - hideBanner: false, - ensureCliPath: true, + pluginRegistry: { scope: "configured-channels" }, networkProxy: "bypass", }); - expect(resolveCliCommandPathPolicy(["channels", "logs"])).toEqual({ - bypassConfigGuard: false, - routeConfigGuard: "never", + expectResolvedPolicy(["channels", "logs"], { loadPlugins: "never", - hideBanner: false, - ensureCliPath: true, + pluginRegistry: { scope: "configured-channels" }, + networkProxy: "bypass", + }); + expectResolvedPolicy(["channels", "remove"], { + loadPlugins: "always", + pluginRegistry: { scope: "configured-channels", installBundledRuntimeDeps: false }, + networkProxy: "bypass", + }); + expectResolvedPolicy(["channels", "resolve"], { + loadPlugins: "always", + pluginRegistry: { scope: "configured-channels", installBundledRuntimeDeps: false }, networkProxy: "bypass", }); }); it("keeps config-only agent commands on config-only startup", () => { - expect(resolveCliCommandPathPolicy(["agent"])).toEqual({ - bypassConfigGuard: false, - routeConfigGuard: "never", + expectResolvedPolicy(["agent"], { loadPlugins: expect.any(Function), - hideBanner: false, - ensureCliPath: true, networkProxy: expect.any(Function), }); @@ -85,49 +102,31 @@ describe("command-path-policy", () => { ["agents", "set-identity"], ["agents", "delete"], ]) { - expect(resolveCliCommandPathPolicy(commandPath)).toEqual({ - bypassConfigGuard: false, - routeConfigGuard: "never", + expectResolvedPolicy(commandPath, { loadPlugins: "never", - hideBanner: false, - ensureCliPath: true, networkProxy: "bypass", }); } }); it("resolves mixed startup-only rules", () => { - expect(resolveCliCommandPathPolicy(["configure"])).toEqual({ + expectResolvedPolicy(["configure"], { bypassConfigGuard: true, - routeConfigGuard: "never", loadPlugins: "never", - hideBanner: false, - ensureCliPath: true, - networkProxy: "default", }); - expect(resolveCliCommandPathPolicy(["config", "validate"])).toEqual({ + expectResolvedPolicy(["config", "validate"], { bypassConfigGuard: true, - routeConfigGuard: "never", loadPlugins: "never", - hideBanner: false, - ensureCliPath: true, networkProxy: "bypass", }); - expect(resolveCliCommandPathPolicy(["gateway", "status"])).toEqual({ - bypassConfigGuard: false, + expectResolvedPolicy(["gateway", "status"], { routeConfigGuard: "always", loadPlugins: "never", - hideBanner: false, - ensureCliPath: true, networkProxy: "bypass", }); - expect(resolveCliCommandPathPolicy(["plugins", "update"])).toEqual({ - bypassConfigGuard: false, - routeConfigGuard: "never", + expectResolvedPolicy(["plugins", "update"], { loadPlugins: "never", hideBanner: true, - ensureCliPath: true, - networkProxy: "default", }); for (const commandPath of [ ["plugins", "install"], @@ -136,21 +135,13 @@ describe("command-path-policy", () => { ["plugins", "registry"], ["plugins", "doctor"], ]) { - expect(resolveCliCommandPathPolicy(commandPath)).toEqual({ - bypassConfigGuard: false, - routeConfigGuard: "never", + expectResolvedPolicy(commandPath, { loadPlugins: "never", - hideBanner: false, - ensureCliPath: true, - networkProxy: "default", }); } - expect(resolveCliCommandPathPolicy(["cron", "list"])).toEqual({ + expectResolvedPolicy(["cron", "list"], { bypassConfigGuard: true, - routeConfigGuard: "never", loadPlugins: "never", - hideBanner: false, - ensureCliPath: true, networkProxy: "bypass", }); }); diff --git a/src/cli/command-path-policy.ts b/src/cli/command-path-policy.ts index 47c61c13671..8c14859d76c 100644 --- a/src/cli/command-path-policy.ts +++ b/src/cli/command-path-policy.ts @@ -12,6 +12,7 @@ const DEFAULT_CLI_COMMAND_PATH_POLICY: CliCommandPathPolicy = { bypassConfigGuard: false, routeConfigGuard: "never", loadPlugins: "never", + pluginRegistry: { scope: "all" }, hideBanner: false, ensureCliPath: true, networkProxy: "default", diff --git a/src/cli/command-startup-policy.test.ts b/src/cli/command-startup-policy.test.ts index 4cc99384f78..d34ad294398 100644 --- a/src/cli/command-startup-policy.test.ts +++ b/src/cli/command-startup-policy.test.ts @@ -175,6 +175,33 @@ describe("command-startup-policy", () => { expect(shouldHideCliBannerForCommandPath(["status"], {})).toBe(false); }); + it("uses process env banner suppression when startup env is omitted", () => { + const originalHideBanner = process.env.OPENCLAW_HIDE_BANNER; + try { + process.env.OPENCLAW_HIDE_BANNER = "1"; + + expect( + resolveCliStartupPolicy({ + commandPath: ["status"], + jsonOutputMode: false, + }).hideBanner, + ).toBe(true); + expect( + resolveCliStartupPolicy({ + commandPath: ["status"], + jsonOutputMode: false, + env: {}, + }).hideBanner, + ).toBe(false); + } finally { + if (originalHideBanner === undefined) { + delete process.env.OPENCLAW_HIDE_BANNER; + } else { + process.env.OPENCLAW_HIDE_BANNER = originalHideBanner; + } + } + }); + it("matches CLI PATH bootstrap policy", () => { expect(shouldEnsureCliPathForCommandPath(["status"])).toBe(false); expect(shouldEnsureCliPathForCommandPath(["sessions"])).toBe(false); @@ -190,18 +217,21 @@ describe("command-startup-policy", () => { resolveCliStartupPolicy({ commandPath: ["status"], jsonOutputMode: true, + env: {}, }), ).toEqual({ suppressDoctorStdout: true, hideBanner: false, skipConfigGuard: false, loadPlugins: false, + pluginRegistry: { scope: "channels", installBundledRuntimeDeps: false }, }); expect( resolveCliStartupPolicy({ commandPath: ["status"], jsonOutputMode: true, + env: {}, routeMode: true, }), ).toEqual({ @@ -209,6 +239,7 @@ describe("command-startup-policy", () => { hideBanner: false, skipConfigGuard: true, loadPlugins: false, + pluginRegistry: { scope: "channels", installBundledRuntimeDeps: false }, }); }); }); diff --git a/src/cli/command-startup-policy.ts b/src/cli/command-startup-policy.ts index 634ef53b95f..6fa305911c7 100644 --- a/src/cli/command-startup-policy.ts +++ b/src/cli/command-startup-policy.ts @@ -1,4 +1,5 @@ import { isTruthyEnvValue } from "../infra/env.js"; +import type { CliCommandPluginLoadPolicy } from "./command-catalog.js"; import { resolveCliCommandPathPolicy } from "./command-path-policy.js"; export function shouldBypassConfigGuardForCommandPath(commandPath: string[]): boolean { @@ -21,7 +22,21 @@ export function shouldLoadPluginsForCommandPath(params: { commandPath: string[]; jsonOutputMode: boolean; }): boolean { - const loadPlugins = resolveCliCommandPathPolicy(params.commandPath).loadPlugins; + return shouldLoadPlugins({ + loadPlugins: resolveCliCommandPathPolicy(params.commandPath).loadPlugins, + argv: params.argv, + commandPath: params.commandPath, + jsonOutputMode: params.jsonOutputMode, + }); +} + +function shouldLoadPlugins(params: { + argv?: string[]; + commandPath: string[]; + jsonOutputMode: boolean; + loadPlugins: CliCommandPluginLoadPolicy; +}): boolean { + const loadPlugins = params.loadPlugins; if (typeof loadPlugins === "function") { return loadPlugins({ argv: params.argv ?? [], @@ -54,19 +69,21 @@ export function resolveCliStartupPolicy(params: { routeMode?: boolean; }) { const suppressDoctorStdout = params.jsonOutputMode; + const commandPolicy = resolveCliCommandPathPolicy(params.commandPath); + const env = params.env ?? process.env; return { suppressDoctorStdout, - hideBanner: shouldHideCliBannerForCommandPath(params.commandPath, params.env), + hideBanner: isTruthyEnvValue(env.OPENCLAW_HIDE_BANNER) || commandPolicy.hideBanner, skipConfigGuard: params.routeMode - ? shouldSkipRouteConfigGuardForCommandPath({ - commandPath: params.commandPath, - suppressDoctorStdout, - }) + ? commandPolicy.routeConfigGuard === "always" || + (commandPolicy.routeConfigGuard === "when-suppressed" && suppressDoctorStdout) : false, - loadPlugins: shouldLoadPluginsForCommandPath({ + loadPlugins: shouldLoadPlugins({ argv: params.argv, commandPath: params.commandPath, jsonOutputMode: params.jsonOutputMode, + loadPlugins: commandPolicy.loadPlugins, }), + pluginRegistry: commandPolicy.pluginRegistry, }; } diff --git a/src/cli/gateway-cli/run.option-collisions.test.ts b/src/cli/gateway-cli/run.option-collisions.test.ts index 65ce27817e4..b9d3490b706 100644 --- a/src/cli/gateway-cli/run.option-collisions.test.ts +++ b/src/cli/gateway-cli/run.option-collisions.test.ts @@ -119,6 +119,14 @@ vi.mock("../../infra/restart-sentinel.js", () => ({ writeRestartSentinel: (payload: unknown) => writeRestartSentinel(payload), })); +vi.mock("../../infra/supervisor-markers.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + detectRespawnSupervisor: () => null, + }; +}); + vi.mock("../../logging/console.js", () => ({ setConsoleSubsystemFilter: (filters: string[]) => setConsoleSubsystemFilter(filters), setConsoleTimestampPrefix: () => undefined, diff --git a/src/cli/plugin-registry-loader.test.ts b/src/cli/plugin-registry-loader.test.ts index f377f9ab4f8..3689bfe7078 100644 --- a/src/cli/plugin-registry-loader.test.ts +++ b/src/cli/plugin-registry-loader.test.ts @@ -9,12 +9,10 @@ vi.mock("./plugin-registry.js", () => ({ describe("plugin-registry-loader", () => { let originalForceStderr: boolean; let ensureCliPluginRegistryLoaded: typeof import("./plugin-registry-loader.js").ensureCliPluginRegistryLoaded; - let resolvePluginRegistryScopeForCommandPath: typeof import("./plugin-registry-loader.js").resolvePluginRegistryScopeForCommandPath; let loggingState: typeof import("../logging/state.js").loggingState; beforeAll(async () => { - ({ ensureCliPluginRegistryLoaded, resolvePluginRegistryScopeForCommandPath } = - await import("./plugin-registry-loader.js")); + ({ ensureCliPluginRegistryLoaded } = await import("./plugin-registry-loader.js")); ({ loggingState } = await import("../logging/state.js")); }); @@ -77,9 +75,15 @@ describe("plugin-registry-loader", () => { }); }); - it("maps command paths to plugin registry scopes", () => { - expect(resolvePluginRegistryScopeForCommandPath(["status"])).toBe("channels"); - expect(resolvePluginRegistryScopeForCommandPath(["health"])).toBe("channels"); - expect(resolvePluginRegistryScopeForCommandPath(["agents"])).toBe("all"); + it("forwards explicit runtime dependency install policy", 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 53f374417af..480f2552029 100644 --- a/src/cli/plugin-registry-loader.ts +++ b/src/cli/plugin-registry-loader.ts @@ -1,6 +1,6 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { loggingState } from "../logging/state.js"; -import type { PluginRegistryScope } from "./plugin-registry.js"; +import type { CliPluginRegistryScope } from "./command-catalog.js"; let pluginRegistryModulePromise: Promise | undefined; @@ -9,17 +9,17 @@ function loadPluginRegistryModule() { return pluginRegistryModulePromise; } -export function resolvePluginRegistryScopeForCommandPath( - commandPath: string[], -): Exclude { - return commandPath[0] === "status" || commandPath[0] === "health" ? "channels" : "all"; -} +export type CliPluginRegistryLoadPolicy = { + scope: CliPluginRegistryScope; + installBundledRuntimeDeps?: boolean; +}; export async function ensureCliPluginRegistryLoaded(params: { - scope: PluginRegistryScope; + scope: CliPluginRegistryScope; routeLogsToStderr?: boolean; config?: OpenClawConfig; activationSourceConfig?: OpenClawConfig; + installBundledRuntimeDeps?: boolean; }) { const { ensurePluginRegistryLoaded } = await loadPluginRegistryModule(); const previousForceStderr = loggingState.forceConsoleToStderr; @@ -33,6 +33,9 @@ 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.list.test.ts b/src/cli/plugins-cli.list.test.ts index 2807fef86bc..2519198c8de 100644 --- a/src/cli/plugins-cli.list.test.ts +++ b/src/cli/plugins-cli.list.test.ts @@ -120,7 +120,7 @@ describe("plugins cli list", () => { expect(runtimeLogs.join("\n")).toContain("Plugin registry refreshed: 1/2 enabled"); }); - it("shows conversation-access hook policy in inspect output", async () => { + it("keeps inspect on the static snapshot by default", async () => { buildPluginSnapshotReport.mockReturnValue({ plugins: [createPluginRecord({ id: "openclaw-mem0", name: "Mem0" })], diagnostics: [], @@ -156,12 +156,50 @@ describe("plugins cli list", () => { await runPluginsCommand(["plugins", "inspect", "openclaw-mem0"]); + expect(buildPluginDiagnosticsReport).not.toHaveBeenCalled(); + expect(runtimeLogs.join("\n")).toContain("Policy"); + expect(runtimeLogs.join("\n")).toContain("allowConversationAccess: true"); + }); + + it("runtime-inspects without repairing deps", async () => { + buildPluginSnapshotReport.mockReturnValue({ + plugins: [createPluginRecord({ id: "openclaw-mem0", name: "Mem0" })], + diagnostics: [], + }); + buildPluginInspectReport.mockReturnValue({ + workspaceDir: "/workspace", + plugin: createPluginRecord({ id: "openclaw-mem0", name: "Mem0" }), + shape: "hook-only", + capabilityMode: "plain", + capabilityCount: 1, + capabilities: [], + typedHooks: [], + customHooks: [], + tools: [], + commands: [], + cliCommands: [], + services: [], + gatewayDiscoveryServices: [], + gatewayMethods: [], + mcpServers: [], + lspServers: [], + httpRouteCount: 0, + bundleCapabilities: [], + diagnostics: [], + policy: { + allowedModels: [], + hasAllowedModelsConfig: false, + }, + usesLegacyBeforeAgentStart: false, + compatibility: [], + }); + + await runPluginsCommand(["plugins", "inspect", "openclaw-mem0", "--runtime"]); + expect(buildPluginDiagnosticsReport).toHaveBeenCalledWith({ config: {}, onlyPluginIds: ["openclaw-mem0"], }); - expect(runtimeLogs.join("\n")).toContain("Policy"); - expect(runtimeLogs.join("\n")).toContain("allowConversationAccess: true"); }); it("does not runtime-load plugins when inspect target is missing", async () => { diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 420862d79c1..ba3cf52a312 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -4,32 +4,18 @@ import type { Command } from "commander"; import { getRuntimeConfig, readConfigFileSnapshot, replaceConfigFile } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import type { PluginInstallRecord } from "../config/types.plugins.js"; import { tracePluginLifecyclePhase, tracePluginLifecyclePhaseAsync, } from "../plugins/plugin-lifecycle-trace.js"; -import { formatPluginSourceForTable, resolvePluginSourceRoots } from "../plugins/source-display.js"; -import type { PluginLogger } from "../plugins/types.js"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; -import { getTerminalTableWidth, renderTable } from "../terminal/table.js"; import { theme } from "../terminal/theme.js"; -import { shortenHomeInString, shortenHomePath } from "../utils.js"; -import { formatPluginLine } from "./plugins-list-format.js"; +import { shortenHomePath } from "../utils.js"; +import type { PluginInspectOptions } from "./plugins-inspect-command.js"; +import type { PluginsListOptions } from "./plugins-list-command.js"; import { applyParentDefaultHelpAction } from "./program/parent-default-help.js"; -export type PluginsListOptions = { - json?: boolean; - enabled?: boolean; - verbose?: boolean; -}; - -export type PluginInspectOptions = { - json?: boolean; - all?: boolean; -}; - export type PluginUpdateOptions = { all?: boolean; dryRun?: boolean; @@ -60,74 +46,6 @@ export type PluginsDepsCliOptions = { repair?: boolean; }; -const quietPluginJsonLogger: PluginLogger = { - debug: () => undefined, - info: () => undefined, - warn: () => undefined, - error: () => undefined, -}; - -function formatInspectSection(title: string, lines: string[]): string[] { - if (lines.length === 0) { - return []; - } - return ["", theme.muted(`${title}:`), ...lines]; -} - -function formatCapabilityKinds( - capabilities: Array<{ - kind: string; - }>, -): string { - if (capabilities.length === 0) { - return "-"; - } - return capabilities.map((entry) => entry.kind).join(", "); -} - -function formatHookSummary(params: { - usesLegacyBeforeAgentStart: boolean; - typedHookCount: number; - customHookCount: number; -}): string { - const parts: string[] = []; - if (params.usesLegacyBeforeAgentStart) { - parts.push("before_agent_start"); - } - const nonLegacyTypedHookCount = - params.typedHookCount - (params.usesLegacyBeforeAgentStart ? 1 : 0); - if (nonLegacyTypedHookCount > 0) { - parts.push(`${nonLegacyTypedHookCount} typed`); - } - if (params.customHookCount > 0) { - parts.push(`${params.customHookCount} custom`); - } - return parts.length > 0 ? parts.join(", ") : "-"; -} - -function formatInstallLines(install: PluginInstallRecord | undefined): string[] { - if (!install) { - return []; - } - const lines = [`Source: ${install.source}`]; - if (install.spec) { - lines.push(`Spec: ${install.spec}`); - } - if (install.sourcePath) { - lines.push(`Source path: ${shortenHomePath(install.sourcePath)}`); - } - if (install.installPath) { - lines.push(`Install path: ${shortenHomePath(install.installPath)}`); - } - if (install.version) { - lines.push(`Recorded version: ${install.version}`); - } - if (install.installedAt) { - lines.push(`Installed at: ${install.installedAt}`); - } - return lines; -} - function countEnabledPlugins(plugins: readonly { enabled: boolean }[]): number { return plugins.filter((plugin) => plugin.enabled).length; } @@ -159,104 +77,8 @@ export function registerPluginsCli(program: Command) { .option("--enabled", "Only show enabled plugins", false) .option("--verbose", "Show detailed entries", false) .action(async (opts: PluginsListOptions) => { - const { buildPluginRegistrySnapshotReport } = await import("../plugins/status.js"); - const cfg = getRuntimeConfig(); - const report = buildPluginRegistrySnapshotReport({ - config: cfg, - ...(opts.json ? { logger: quietPluginJsonLogger } : {}), - }); - const list = opts.enabled ? report.plugins.filter((p) => p.enabled) : report.plugins; - - if (opts.json) { - const payload = { - workspaceDir: report.workspaceDir, - registry: { - source: report.registrySource, - diagnostics: report.registryDiagnostics, - }, - plugins: list, - diagnostics: report.diagnostics, - }; - defaultRuntime.writeJson(payload); - return; - } - - if (list.length === 0) { - defaultRuntime.log(theme.muted("No plugins found.")); - return; - } - - const enabled = list.filter((p) => p.enabled).length; - defaultRuntime.log( - `${theme.heading("Plugins")} ${theme.muted(`(${enabled}/${list.length} enabled)`)}`, - ); - - if (!opts.verbose) { - const tableWidth = getTerminalTableWidth(); - const sourceRoots = resolvePluginSourceRoots({ - workspaceDir: report.workspaceDir, - }); - const usedRoots = new Set(); - const rows = list.map((plugin) => { - const desc = plugin.description ? theme.muted(plugin.description) : ""; - const formattedSource = formatPluginSourceForTable(plugin, sourceRoots); - if (formattedSource.rootKey) { - usedRoots.add(formattedSource.rootKey); - } - const sourceLine = desc ? `${formattedSource.value}\n${desc}` : formattedSource.value; - return { - Name: plugin.name || plugin.id, - ID: plugin.name && plugin.name !== plugin.id ? plugin.id : "", - Format: plugin.format ?? "openclaw", - Status: - plugin.status === "error" - ? theme.error("error") - : plugin.enabled - ? theme.success("enabled") - : theme.warn("disabled"), - Source: sourceLine, - Version: plugin.version ?? "", - }; - }); - - if (usedRoots.size > 0) { - defaultRuntime.log(theme.muted("Source roots:")); - for (const key of ["stock", "workspace", "global"] as const) { - if (!usedRoots.has(key)) { - continue; - } - const dir = sourceRoots[key]; - if (!dir) { - continue; - } - defaultRuntime.log(` ${theme.command(`${key}:`)} ${theme.muted(dir)}`); - } - defaultRuntime.log(""); - } - - defaultRuntime.log( - renderTable({ - width: tableWidth, - columns: [ - { key: "Name", header: "Name", minWidth: 14, flex: true }, - { key: "ID", header: "ID", minWidth: 10, flex: true }, - { key: "Format", header: "Format", minWidth: 9 }, - { key: "Status", header: "Status", minWidth: 10 }, - { key: "Source", header: "Source", minWidth: 26, flex: true }, - { key: "Version", header: "Version", minWidth: 8 }, - ], - rows, - }).trimEnd(), - ); - return; - } - - const lines: string[] = []; - for (const plugin of list) { - lines.push(formatPluginLine(plugin, true)); - lines.push(""); - } - defaultRuntime.log(lines.join("\n").trim()); + const { runPluginsListCommand } = await import("./plugins-list-command.js"); + await runPluginsListCommand(opts); }); plugins @@ -280,281 +102,11 @@ export function registerPluginsCli(program: Command) { .description("Inspect plugin details") .argument("[id]", "Plugin id") .option("--all", "Inspect all plugins") + .option("--runtime", "Load plugin runtime for hooks/tools/diagnostics") .option("--json", "Print JSON") .action(async (id: string | undefined, opts: PluginInspectOptions) => { - const { - buildAllPluginInspectReports, - buildPluginDiagnosticsReport, - buildPluginInspectReport, - buildPluginSnapshotReport, - formatPluginCompatibilityNotice, - } = await import("../plugins/status.js"); - const { loadInstalledPluginIndexInstallRecords } = - await import("../plugins/installed-plugin-index-records.js"); - const cfg = tracePluginLifecyclePhase("config read", () => getRuntimeConfig(), { - command: "inspect", - }); - const installRecords = await tracePluginLifecyclePhaseAsync( - "install records load", - () => loadInstalledPluginIndexInstallRecords(), - { command: "inspect" }, - ); - const loggerParams = opts.json ? { logger: quietPluginJsonLogger } : {}; - if (opts.all) { - if (id) { - defaultRuntime.error("Pass either a plugin id or --all, not both."); - return defaultRuntime.exit(1); - } - const report = tracePluginLifecyclePhase( - "runtime plugin registry load", - () => - buildPluginDiagnosticsReport({ - config: cfg, - ...loggerParams, - }), - { command: "inspect", all: true }, - ); - const inspectAll = buildAllPluginInspectReports({ - config: cfg, - ...loggerParams, - report, - }); - const inspectAllWithInstall = inspectAll.map((inspect) => ({ - ...inspect, - install: installRecords[inspect.plugin.id], - })); - - if (opts.json) { - defaultRuntime.writeJson(inspectAllWithInstall); - return; - } - - const tableWidth = getTerminalTableWidth(); - const rows = inspectAll.map((inspect) => ({ - Name: inspect.plugin.name || inspect.plugin.id, - ID: - inspect.plugin.name && inspect.plugin.name !== inspect.plugin.id - ? inspect.plugin.id - : "", - Status: - inspect.plugin.status === "loaded" - ? theme.success("loaded") - : inspect.plugin.status === "disabled" - ? theme.warn("disabled") - : theme.error("error"), - Shape: inspect.shape, - Capabilities: formatCapabilityKinds(inspect.capabilities), - Compatibility: - inspect.compatibility.length > 0 - ? inspect.compatibility - .map((entry) => (entry.severity === "warn" ? `warn:${entry.code}` : entry.code)) - .join(", ") - : "none", - Bundle: - inspect.bundleCapabilities.length > 0 ? inspect.bundleCapabilities.join(", ") : "-", - Hooks: formatHookSummary({ - usesLegacyBeforeAgentStart: inspect.usesLegacyBeforeAgentStart, - typedHookCount: inspect.typedHooks.length, - customHookCount: inspect.customHooks.length, - }), - })); - defaultRuntime.log( - renderTable({ - width: tableWidth, - columns: [ - { key: "Name", header: "Name", minWidth: 14, flex: true }, - { key: "ID", header: "ID", minWidth: 10, flex: true }, - { key: "Status", header: "Status", minWidth: 10 }, - { key: "Shape", header: "Shape", minWidth: 18 }, - { key: "Capabilities", header: "Capabilities", minWidth: 28, flex: true }, - { key: "Compatibility", header: "Compatibility", minWidth: 24, flex: true }, - { key: "Bundle", header: "Bundle", minWidth: 14, flex: true }, - { key: "Hooks", header: "Hooks", minWidth: 20, flex: true }, - ], - rows, - }).trimEnd(), - ); - return; - } - - if (!id) { - defaultRuntime.error("Provide a plugin id or use --all."); - return defaultRuntime.exit(1); - } - - const snapshotReport = tracePluginLifecyclePhase( - "plugin registry snapshot", - () => - buildPluginSnapshotReport({ - config: cfg, - ...loggerParams, - }), - { command: "inspect" }, - ); - const targetPlugin = snapshotReport.plugins.find( - (entry) => entry.id === id || entry.name === id, - ); - if (!targetPlugin) { - defaultRuntime.error(`Plugin not found: ${id}`); - return defaultRuntime.exit(1); - } - const report = tracePluginLifecyclePhase( - "runtime plugin registry load", - () => - buildPluginDiagnosticsReport({ - config: cfg, - ...loggerParams, - onlyPluginIds: [targetPlugin.id], - }), - { command: "inspect", pluginId: targetPlugin.id }, - ); - const inspect = buildPluginInspectReport({ - id: targetPlugin.id, - config: cfg, - ...loggerParams, - report, - }); - if (!inspect) { - defaultRuntime.error(`Plugin not found: ${id}`); - return defaultRuntime.exit(1); - } - const install = installRecords[inspect.plugin.id]; - - if (opts.json) { - defaultRuntime.writeJson({ - ...inspect, - install, - }); - return; - } - - const lines: string[] = []; - lines.push(theme.heading(inspect.plugin.name || inspect.plugin.id)); - if (inspect.plugin.name && inspect.plugin.name !== inspect.plugin.id) { - lines.push(theme.muted(`id: ${inspect.plugin.id}`)); - } - if (inspect.plugin.description) { - lines.push(inspect.plugin.description); - } - lines.push(""); - lines.push(`${theme.muted("Status:")} ${inspect.plugin.status}`); - if (inspect.plugin.failurePhase) { - lines.push(`${theme.muted("Failure phase:")} ${inspect.plugin.failurePhase}`); - } - if (inspect.plugin.failedAt) { - lines.push(`${theme.muted("Failed at:")} ${inspect.plugin.failedAt.toISOString()}`); - } - lines.push(`${theme.muted("Format:")} ${inspect.plugin.format ?? "openclaw"}`); - if (inspect.plugin.bundleFormat) { - lines.push(`${theme.muted("Bundle format:")} ${inspect.plugin.bundleFormat}`); - } - lines.push(`${theme.muted("Source:")} ${shortenHomeInString(inspect.plugin.source)}`); - lines.push(`${theme.muted("Origin:")} ${inspect.plugin.origin}`); - if (inspect.plugin.version) { - lines.push(`${theme.muted("Version:")} ${inspect.plugin.version}`); - } - lines.push(`${theme.muted("Shape:")} ${inspect.shape}`); - lines.push(`${theme.muted("Capability mode:")} ${inspect.capabilityMode}`); - lines.push( - `${theme.muted("Legacy before_agent_start:")} ${inspect.usesLegacyBeforeAgentStart ? "yes" : "no"}`, - ); - if (inspect.bundleCapabilities.length > 0) { - lines.push( - `${theme.muted("Bundle capabilities:")} ${inspect.bundleCapabilities.join(", ")}`, - ); - } - lines.push( - ...formatInspectSection( - "Capabilities", - inspect.capabilities.map( - (entry) => - `${entry.kind}: ${entry.ids.length > 0 ? entry.ids.join(", ") : "(registered)"}`, - ), - ), - ); - lines.push( - ...formatInspectSection( - "Typed hooks", - inspect.typedHooks.map((entry) => - entry.priority == null ? entry.name : `${entry.name} (priority ${entry.priority})`, - ), - ), - ); - lines.push( - ...formatInspectSection( - "Compatibility warnings", - inspect.compatibility.map(formatPluginCompatibilityNotice), - ), - ); - lines.push( - ...formatInspectSection( - "Custom hooks", - inspect.customHooks.map((entry) => `${entry.name}: ${entry.events.join(", ")}`), - ), - ); - lines.push( - ...formatInspectSection( - "Tools", - inspect.tools.map((entry) => { - const names = entry.names.length > 0 ? entry.names.join(", ") : "(anonymous)"; - return entry.optional ? `${names} [optional]` : names; - }), - ), - ); - lines.push(...formatInspectSection("Commands", inspect.commands)); - lines.push(...formatInspectSection("CLI commands", inspect.cliCommands)); - lines.push(...formatInspectSection("Services", inspect.services)); - lines.push(...formatInspectSection("Gateway methods", inspect.gatewayMethods)); - lines.push( - ...formatInspectSection( - "MCP servers", - inspect.mcpServers.map((entry) => - entry.hasStdioTransport ? entry.name : `${entry.name} (unsupported transport)`, - ), - ), - ); - lines.push( - ...formatInspectSection( - "LSP servers", - inspect.lspServers.map((entry) => - entry.hasStdioTransport ? entry.name : `${entry.name} (unsupported transport)`, - ), - ), - ); - if (inspect.httpRouteCount > 0) { - lines.push(...formatInspectSection("HTTP routes", [String(inspect.httpRouteCount)])); - } - const policyLines: string[] = []; - if (typeof inspect.policy.allowPromptInjection === "boolean") { - policyLines.push(`allowPromptInjection: ${inspect.policy.allowPromptInjection}`); - } - if (typeof inspect.policy.allowConversationAccess === "boolean") { - policyLines.push(`allowConversationAccess: ${inspect.policy.allowConversationAccess}`); - } - if (typeof inspect.policy.allowModelOverride === "boolean") { - policyLines.push(`allowModelOverride: ${inspect.policy.allowModelOverride}`); - } - if (inspect.policy.hasAllowedModelsConfig) { - policyLines.push( - `allowedModels: ${ - inspect.policy.allowedModels.length > 0 - ? inspect.policy.allowedModels.join(", ") - : "(configured but empty)" - }`, - ); - } - lines.push(...formatInspectSection("Policy", policyLines)); - lines.push( - ...formatInspectSection( - "Diagnostics", - inspect.diagnostics.map((entry) => `${entry.level.toUpperCase()}: ${entry.message}`), - ), - ); - lines.push(...formatInspectSection("Install", formatInstallLines(install))); - if (inspect.plugin.error) { - lines.push("", `${theme.error("Error:")} ${inspect.plugin.error}`); - } - defaultRuntime.log(lines.join("\n")); + const { runPluginsInspectCommand } = await import("./plugins-inspect-command.js"); + await runPluginsInspectCommand(id, opts); }); plugins diff --git a/src/cli/plugins-command-helpers.ts b/src/cli/plugins-command-helpers.ts index e5d62119173..583b498f3e4 100644 --- a/src/cli/plugins-command-helpers.ts +++ b/src/cli/plugins-command-helpers.ts @@ -3,12 +3,20 @@ import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js"; import { CLAWHUB_INSTALL_ERROR_CODE } from "../plugins/clawhub.js"; import { applyExclusiveSlotSelection, slotKeysForPluginKind } from "../plugins/slots.js"; import { buildPluginDiagnosticsReport, buildPluginSnapshotReport } from "../plugins/status.js"; +import type { PluginLogger } from "../plugins/types.js"; import { defaultRuntime } from "../runtime.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { theme } from "../terminal/theme.js"; type HookInternalEntryLike = Record & { enabled?: boolean }; +export const quietPluginJsonLogger: PluginLogger = { + debug: () => undefined, + info: () => undefined, + warn: () => undefined, + error: () => undefined, +}; + export function resolveFileNpmSpecToLocalPath( raw: string, ): { ok: true; path: string } | { ok: false; error: string } | null { diff --git a/src/cli/plugins-deps-command.test.ts b/src/cli/plugins-deps-command.test.ts index 8c25185bfb7..640507d29e3 100644 --- a/src/cli/plugins-deps-command.test.ts +++ b/src/cli/plugins-deps-command.test.ts @@ -28,14 +28,26 @@ const mocks = vi.hoisted(() => { throw new Error(`__exit__:${code}`); }), }, - createBundledRuntimeDepsInstallSpecs: vi.fn((params: { deps: readonly RuntimeDepFixture[] }) => - params.deps.map((dep) => `${dep.name}@${dep.version}`), - ), + 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(), - repairBundledRuntimeDepsInstallRootAsync: vi.fn(), + repairBundledRuntimeDepsPackagePlanAsync: vi.fn(), resolveBundledRuntimeDependencyPackageInstallRootPlan: vi.fn(), resolveOpenClawPackageRootSync: vi.fn(), - scanBundledPluginRuntimeDeps: vi.fn(), + runtimeDepsPlan: vi.fn(), }; }); @@ -48,12 +60,14 @@ vi.mock("../infra/openclaw-root.js", () => ({ })); vi.mock("../plugins/bundled-runtime-deps.js", () => ({ - createBundledRuntimeDepsInstallSpecs: mocks.createBundledRuntimeDepsInstallSpecs, + createBundledRuntimeDepsPackagePlan: mocks.createBundledRuntimeDepsPackagePlan, + repairBundledRuntimeDepsPackagePlanAsync: mocks.repairBundledRuntimeDepsPackagePlanAsync, +})); + +vi.mock("../plugins/bundled-runtime-deps-roots.js", () => ({ pruneUnknownBundledRuntimeDepsRoots: mocks.pruneUnknownBundledRuntimeDepsRoots, - repairBundledRuntimeDepsInstallRootAsync: mocks.repairBundledRuntimeDepsInstallRootAsync, resolveBundledRuntimeDependencyPackageInstallRootPlan: mocks.resolveBundledRuntimeDependencyPackageInstallRootPlan, - scanBundledPluginRuntimeDeps: mocks.scanBundledPluginRuntimeDeps, })); const { runPluginsDepsCommand } = await import("./plugins-deps-command.js"); @@ -66,12 +80,17 @@ describe("plugins deps command", () => { mocks.defaultRuntime.writeStdout.mockClear(); mocks.defaultRuntime.writeJson.mockClear(); mocks.defaultRuntime.exit.mockClear(); - mocks.createBundledRuntimeDepsInstallSpecs.mockClear(); + mocks.createBundledRuntimeDepsPackagePlan.mockClear(); mocks.pruneUnknownBundledRuntimeDepsRoots.mockReset(); - mocks.repairBundledRuntimeDepsInstallRootAsync.mockReset(); + mocks.repairBundledRuntimeDepsPackagePlanAsync.mockReset(); mocks.resolveBundledRuntimeDependencyPackageInstallRootPlan.mockReset(); mocks.resolveOpenClawPackageRootSync.mockReset(); - mocks.scanBundledPluginRuntimeDeps.mockReset(); + mocks.runtimeDepsPlan.mockReset(); + mocks.runtimeDepsPlan.mockReturnValue({ + deps: [], + missing: [], + conflicts: [], + }); mocks.resolveBundledRuntimeDependencyPackageInstallRootPlan.mockReturnValue({ installRoot: "/runtime-deps", searchRoots: ["/runtime-deps"], @@ -80,7 +99,7 @@ describe("plugins deps command", () => { }); it("does not reinstall already materialized bundled runtime deps", async () => { - mocks.scanBundledPluginRuntimeDeps.mockReturnValue({ + mocks.runtimeDepsPlan.mockReturnValue({ deps: [{ name: "zod", version: "4.0.0", pluginIds: ["openclaw-demo"] }], missing: [], conflicts: [], @@ -95,7 +114,7 @@ describe("plugins deps command", () => { }, }); - expect(mocks.repairBundledRuntimeDepsInstallRootAsync).not.toHaveBeenCalled(); + expect(mocks.repairBundledRuntimeDepsPackagePlanAsync).not.toHaveBeenCalled(); expect(JSON.parse(mocks.runtimeLogs[0] ?? "null")).toEqual( expect.objectContaining({ packageRoot: "/openclaw-package", @@ -108,7 +127,7 @@ describe("plugins deps command", () => { it("repairs only when bundled runtime deps are missing", async () => { const dep = { name: "zod", version: "4.0.0", pluginIds: ["openclaw-demo"] }; - mocks.scanBundledPluginRuntimeDeps + mocks.runtimeDepsPlan .mockReturnValueOnce({ deps: [dep], missing: [dep], @@ -119,9 +138,8 @@ describe("plugins deps command", () => { missing: [], conflicts: [], }); - mocks.repairBundledRuntimeDepsInstallRootAsync.mockResolvedValue({ - installSpecs: ["zod@4.0.0"], - skipped: false, + mocks.repairBundledRuntimeDepsPackagePlanAsync.mockResolvedValue({ + repairedSpecs: ["zod@4.0.0"], }); await runPluginsDepsCommand({ @@ -133,11 +151,10 @@ describe("plugins deps command", () => { }, }); - expect(mocks.repairBundledRuntimeDepsInstallRootAsync).toHaveBeenCalledWith( + expect(mocks.repairBundledRuntimeDepsPackagePlanAsync).toHaveBeenCalledWith( expect.objectContaining({ - installRoot: "/runtime-deps", - installSpecs: ["zod@4.0.0"], - missingSpecs: ["zod@4.0.0"], + packageRoot: "/openclaw-package", + includeConfiguredChannels: true, }), ); expect(JSON.parse(mocks.runtimeLogs[0] ?? "null")).toEqual( @@ -152,7 +169,7 @@ describe("plugins deps command", () => { it("keeps repair warnings inside JSON output", async () => { const dep = { name: "zod", version: "4.0.0", pluginIds: ["openclaw-demo"] }; - mocks.scanBundledPluginRuntimeDeps + mocks.runtimeDepsPlan .mockReturnValueOnce({ deps: [dep], missing: [dep], @@ -163,11 +180,10 @@ describe("plugins deps command", () => { missing: [], conflicts: [], }); - mocks.repairBundledRuntimeDepsInstallRootAsync.mockImplementation(async (params: unknown) => { + mocks.repairBundledRuntimeDepsPackagePlanAsync.mockImplementation(async (params: unknown) => { (params as { warn: (message: string) => void }).warn("low disk space"); return { - installSpecs: ["zod@4.0.0"], - skipped: false, + repairedSpecs: ["zod@4.0.0"], }; }); @@ -200,7 +216,7 @@ describe("plugins deps command", () => { ["2.0.0", ["openclaw-two"]], ]), }; - mocks.scanBundledPluginRuntimeDeps + mocks.runtimeDepsPlan .mockReturnValueOnce({ deps: [dep], missing: [dep], @@ -211,9 +227,8 @@ describe("plugins deps command", () => { missing: [], conflicts: [conflict], }); - mocks.repairBundledRuntimeDepsInstallRootAsync.mockResolvedValue({ - installSpecs: ["zod@4.0.0"], - skipped: false, + mocks.repairBundledRuntimeDepsPackagePlanAsync.mockResolvedValue({ + repairedSpecs: ["zod@4.0.0"], }); await runPluginsDepsCommand({ @@ -225,10 +240,10 @@ describe("plugins deps command", () => { }, }); - expect(mocks.repairBundledRuntimeDepsInstallRootAsync).toHaveBeenCalledWith( + expect(mocks.repairBundledRuntimeDepsPackagePlanAsync).toHaveBeenCalledWith( expect.objectContaining({ - installSpecs: ["zod@4.0.0"], - missingSpecs: ["zod@4.0.0"], + packageRoot: "/openclaw-package", + includeConfiguredChannels: true, }), ); expect(JSON.parse(mocks.runtimeLogs[0] ?? "null")).toEqual( diff --git a/src/cli/plugins-deps-command.ts b/src/cli/plugins-deps-command.ts index ce85faf303a..a4e26a1aabd 100644 --- a/src/cli/plugins-deps-command.ts +++ b/src/cli/plugins-deps-command.ts @@ -1,12 +1,11 @@ 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 { - createBundledRuntimeDepsInstallSpecs, - pruneUnknownBundledRuntimeDepsRoots, - repairBundledRuntimeDepsInstallRootAsync, - resolveBundledRuntimeDependencyPackageInstallRootPlan, - scanBundledPluginRuntimeDeps, + createBundledRuntimeDepsPackagePlan, + repairBundledRuntimeDepsPackagePlanAsync, + type BundledRuntimeDepsPackagePlan, } from "../plugins/bundled-runtime-deps.js"; import { defaultRuntime } from "../runtime.js"; import { getTerminalTableWidth, renderTable } from "../terminal/table.js"; @@ -35,9 +34,7 @@ function formatRuntimeDepOwners(pluginIds: readonly string[]): string { return pluginIds.length > 0 ? pluginIds.join(", ") : "-"; } -function formatRuntimeDepConflicts( - conflicts: ReturnType["conflicts"], -) { +function formatRuntimeDepConflicts(conflicts: BundledRuntimeDepsPackagePlan["conflicts"]) { return conflicts.map((conflict) => ({ name: conflict.name, versions: conflict.versions, @@ -77,26 +74,23 @@ export async function runPluginsDepsCommand(params: { warn, }) : undefined; - const scanRuntimeDeps = () => - scanBundledPluginRuntimeDeps({ + const createRuntimeDepsPlan = () => + createBundledRuntimeDepsPackagePlan({ packageRoot, config: params.config, includeConfiguredChannels: true, env: process.env, }); - let scan = scanRuntimeDeps(); - const installRootPlan = resolveBundledRuntimeDependencyPackageInstallRootPlan(packageRoot, { - env: process.env, - }); - let installSpecs = createBundledRuntimeDepsInstallSpecs({ deps: scan.deps }); - let missingSpecs = createBundledRuntimeDepsInstallSpecs({ deps: scan.missing }); + let plan = createRuntimeDepsPlan(); let repairedSpecs: string[] = []; + let reusedSpecs: string[] = []; + let reusedFromRoot: string | undefined; - if (params.options.repair && missingSpecs.length > 0) { - const result = await repairBundledRuntimeDepsInstallRootAsync({ - installRoot: installRootPlan.installRoot, - missingSpecs, - installSpecs, + if (params.options.repair && plan.missingSpecs.length > 0) { + const result = await repairBundledRuntimeDepsPackagePlanAsync({ + packageRoot, + config: params.config, + includeConfiguredChannels: true, env: process.env, warn, onProgress: (message) => { @@ -105,24 +99,26 @@ export async function runPluginsDepsCommand(params: { } }, }); - repairedSpecs = result.installSpecs; - scan = scanRuntimeDeps(); - installSpecs = createBundledRuntimeDepsInstallSpecs({ deps: scan.deps }); - missingSpecs = createBundledRuntimeDepsInstallSpecs({ deps: scan.missing }); + repairedSpecs = result.repairedSpecs; + reusedSpecs = result.reusedSpecs ?? []; + reusedFromRoot = result.reusedFromRoot; + plan = createRuntimeDepsPlan(); } if (params.options.json) { defaultRuntime.writeJson({ packageRoot, - installRoot: installRootPlan.installRoot, - installRootExternal: installRootPlan.external, - searchRoots: installRootPlan.searchRoots, - deps: scan.deps, - missing: scan.missing, - conflicts: formatRuntimeDepConflicts(scan.conflicts), - installSpecs, - missingSpecs, + 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 } : {}), }); @@ -132,8 +128,8 @@ export async function runPluginsDepsCommand(params: { const lines = [ theme.heading("Bundled Plugin Runtime Deps"), `${theme.muted("Package root:")} ${shortenHomePath(packageRoot)}`, - `${theme.muted("Install root:")} ${shortenHomePath(installRootPlan.installRoot)}${ - installRootPlan.external ? theme.muted(" (external)") : "" + `${theme.muted("Install root:")} ${shortenHomePath(plan.installRootPlan.installRoot)}${ + plan.installRootPlan.external ? theme.muted(" (external)") : "" }`, ]; if (pruned) { @@ -143,17 +139,17 @@ export async function runPluginsDepsCommand(params: { }`, ); } - if (scan.conflicts.length > 0) { + if (plan.conflicts.length > 0) { lines.push(""); lines.push(theme.error("Version conflicts:")); - for (const conflict of scan.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 (scan.deps.length === 0) { + 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")); @@ -163,12 +159,14 @@ export async function runPluginsDepsCommand(params: { lines.push(""); lines.push( `${theme.muted("Status:")} ${ - scan.missing.length === 0 ? theme.success("materialized") : theme.warn("missing") + plan.missing.length === 0 ? theme.success("materialized") : theme.warn("missing") }`, ); if (repairedSpecs.length > 0) { lines.push(`${theme.muted("Repaired:")} ${repairedSpecs.join(", ")}`); - } else if (params.options.repair && scan.conflicts.length > 0) { + } 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(""); @@ -181,10 +179,10 @@ export async function runPluginsDepsCommand(params: { { key: "Status", header: "Status", minWidth: 12 }, { key: "Plugins", header: "Plugins", minWidth: 24, flex: true }, ], - rows: scan.deps.map((dep) => ({ + rows: plan.deps.map((dep) => ({ Name: dep.name, Version: dep.version, - Status: scan.missing.some( + Status: plan.missing.some( (missing) => missing.name === dep.name && missing.version === dep.version, ) ? theme.warn("missing") diff --git a/src/cli/plugins-inspect-command.ts b/src/cli/plugins-inspect-command.ts new file mode 100644 index 00000000000..9895f9e5173 --- /dev/null +++ b/src/cli/plugins-inspect-command.ts @@ -0,0 +1,361 @@ +import { getRuntimeConfig } from "../config/config.js"; +import type { PluginInstallRecord } from "../config/types.plugins.js"; +import { + tracePluginLifecyclePhase, + tracePluginLifecyclePhaseAsync, +} from "../plugins/plugin-lifecycle-trace.js"; +import { defaultRuntime } from "../runtime.js"; +import { getTerminalTableWidth, renderTable } from "../terminal/table.js"; +import { theme } from "../terminal/theme.js"; +import { shortenHomeInString, shortenHomePath } from "../utils.js"; +import { quietPluginJsonLogger } from "./plugins-command-helpers.js"; + +export type PluginInspectOptions = { + json?: boolean; + all?: boolean; + runtime?: boolean; +}; + +function formatInspectSection(title: string, lines: string[]): string[] { + if (lines.length === 0) { + return []; + } + return ["", theme.muted(`${title}:`), ...lines]; +} + +function formatCapabilityKinds( + capabilities: Array<{ + kind: string; + }>, +): string { + if (capabilities.length === 0) { + return "-"; + } + return capabilities.map((entry) => entry.kind).join(", "); +} + +function formatHookSummary(params: { + usesLegacyBeforeAgentStart: boolean; + typedHookCount: number; + customHookCount: number; +}): string { + const parts: string[] = []; + if (params.usesLegacyBeforeAgentStart) { + parts.push("before_agent_start"); + } + const nonLegacyTypedHookCount = + params.typedHookCount - (params.usesLegacyBeforeAgentStart ? 1 : 0); + if (nonLegacyTypedHookCount > 0) { + parts.push(`${nonLegacyTypedHookCount} typed`); + } + if (params.customHookCount > 0) { + parts.push(`${params.customHookCount} custom`); + } + return parts.length > 0 ? parts.join(", ") : "-"; +} + +function formatInstallLines(install: PluginInstallRecord | undefined): string[] { + if (!install) { + return []; + } + const lines = [`Source: ${install.source}`]; + if (install.spec) { + lines.push(`Spec: ${install.spec}`); + } + if (install.sourcePath) { + lines.push(`Source path: ${shortenHomePath(install.sourcePath)}`); + } + if (install.installPath) { + lines.push(`Install path: ${shortenHomePath(install.installPath)}`); + } + if (install.version) { + lines.push(`Recorded version: ${install.version}`); + } + if (install.installedAt) { + lines.push(`Installed at: ${install.installedAt}`); + } + return lines; +} + +export async function runPluginsInspectCommand( + id: string | undefined, + opts: PluginInspectOptions, +): Promise { + const { + buildAllPluginInspectReports, + buildPluginDiagnosticsReport, + buildPluginInspectReport, + buildPluginSnapshotReport, + formatPluginCompatibilityNotice, + } = await import("../plugins/status.js"); + const { loadInstalledPluginIndexInstallRecords } = + await import("../plugins/installed-plugin-index-records.js"); + const cfg = tracePluginLifecyclePhase("config read", () => getRuntimeConfig(), { + command: "inspect", + }); + const installRecords = await tracePluginLifecyclePhaseAsync( + "install records load", + () => loadInstalledPluginIndexInstallRecords(), + { command: "inspect" }, + ); + const loggerParams = opts.json ? { logger: quietPluginJsonLogger } : {}; + const runtimeInspect = opts.runtime === true; + if (opts.all) { + if (id) { + defaultRuntime.error("Pass either a plugin id or --all, not both."); + return defaultRuntime.exit(1); + } + const report = runtimeInspect + ? tracePluginLifecyclePhase( + "runtime plugin registry load", + () => + buildPluginDiagnosticsReport({ + config: cfg, + ...loggerParams, + }), + { command: "inspect", all: true }, + ) + : tracePluginLifecyclePhase( + "plugin registry snapshot", + () => + buildPluginSnapshotReport({ + config: cfg, + ...loggerParams, + }), + { command: "inspect", all: true }, + ); + const inspectAll = buildAllPluginInspectReports({ + config: cfg, + ...loggerParams, + report, + }); + const inspectAllWithInstall = inspectAll.map((inspect) => ({ + ...inspect, + install: installRecords[inspect.plugin.id], + })); + + if (opts.json) { + defaultRuntime.writeJson(inspectAllWithInstall); + return; + } + + const tableWidth = getTerminalTableWidth(); + const rows = inspectAll.map((inspect) => ({ + Name: inspect.plugin.name || inspect.plugin.id, + ID: inspect.plugin.name && inspect.plugin.name !== inspect.plugin.id ? inspect.plugin.id : "", + Status: + inspect.plugin.status === "loaded" + ? theme.success("loaded") + : inspect.plugin.status === "disabled" + ? theme.warn("disabled") + : theme.error("error"), + Shape: inspect.shape, + Capabilities: formatCapabilityKinds(inspect.capabilities), + Compatibility: + inspect.compatibility.length > 0 + ? inspect.compatibility + .map((entry) => (entry.severity === "warn" ? `warn:${entry.code}` : entry.code)) + .join(", ") + : "none", + Bundle: inspect.bundleCapabilities.length > 0 ? inspect.bundleCapabilities.join(", ") : "-", + Hooks: formatHookSummary({ + usesLegacyBeforeAgentStart: inspect.usesLegacyBeforeAgentStart, + typedHookCount: inspect.typedHooks.length, + customHookCount: inspect.customHooks.length, + }), + })); + defaultRuntime.log( + renderTable({ + width: tableWidth, + columns: [ + { key: "Name", header: "Name", minWidth: 14, flex: true }, + { key: "ID", header: "ID", minWidth: 10, flex: true }, + { key: "Status", header: "Status", minWidth: 10 }, + { key: "Shape", header: "Shape", minWidth: 18 }, + { key: "Capabilities", header: "Capabilities", minWidth: 28, flex: true }, + { key: "Compatibility", header: "Compatibility", minWidth: 24, flex: true }, + { key: "Bundle", header: "Bundle", minWidth: 14, flex: true }, + { key: "Hooks", header: "Hooks", minWidth: 20, flex: true }, + ], + rows, + }).trimEnd(), + ); + return; + } + + if (!id) { + defaultRuntime.error("Provide a plugin id or use --all."); + return defaultRuntime.exit(1); + } + + const snapshotReport = tracePluginLifecyclePhase( + "plugin registry snapshot", + () => + buildPluginSnapshotReport({ + config: cfg, + ...loggerParams, + }), + { command: "inspect" }, + ); + const targetPlugin = snapshotReport.plugins.find((entry) => entry.id === id || entry.name === id); + if (!targetPlugin) { + defaultRuntime.error(`Plugin not found: ${id}`); + return defaultRuntime.exit(1); + } + const report = runtimeInspect + ? tracePluginLifecyclePhase( + "runtime plugin registry load", + () => + buildPluginDiagnosticsReport({ + config: cfg, + ...loggerParams, + onlyPluginIds: [targetPlugin.id], + }), + { command: "inspect", pluginId: targetPlugin.id }, + ) + : snapshotReport; + const inspect = buildPluginInspectReport({ + id: targetPlugin.id, + config: cfg, + ...loggerParams, + report, + }); + if (!inspect) { + defaultRuntime.error(`Plugin not found: ${id}`); + return defaultRuntime.exit(1); + } + const install = installRecords[inspect.plugin.id]; + + if (opts.json) { + defaultRuntime.writeJson({ + ...inspect, + install, + }); + return; + } + + const lines: string[] = []; + lines.push(theme.heading(inspect.plugin.name || inspect.plugin.id)); + if (inspect.plugin.name && inspect.plugin.name !== inspect.plugin.id) { + lines.push(theme.muted(`id: ${inspect.plugin.id}`)); + } + if (inspect.plugin.description) { + lines.push(inspect.plugin.description); + } + lines.push(""); + lines.push(`${theme.muted("Status:")} ${inspect.plugin.status}`); + if (inspect.plugin.failurePhase) { + lines.push(`${theme.muted("Failure phase:")} ${inspect.plugin.failurePhase}`); + } + if (inspect.plugin.failedAt) { + lines.push(`${theme.muted("Failed at:")} ${inspect.plugin.failedAt.toISOString()}`); + } + lines.push(`${theme.muted("Format:")} ${inspect.plugin.format ?? "openclaw"}`); + if (inspect.plugin.bundleFormat) { + lines.push(`${theme.muted("Bundle format:")} ${inspect.plugin.bundleFormat}`); + } + lines.push(`${theme.muted("Source:")} ${shortenHomeInString(inspect.plugin.source)}`); + lines.push(`${theme.muted("Origin:")} ${inspect.plugin.origin}`); + if (inspect.plugin.version) { + lines.push(`${theme.muted("Version:")} ${inspect.plugin.version}`); + } + lines.push(`${theme.muted("Shape:")} ${inspect.shape}`); + lines.push(`${theme.muted("Capability mode:")} ${inspect.capabilityMode}`); + lines.push( + `${theme.muted("Legacy before_agent_start:")} ${inspect.usesLegacyBeforeAgentStart ? "yes" : "no"}`, + ); + if (inspect.bundleCapabilities.length > 0) { + lines.push(`${theme.muted("Bundle capabilities:")} ${inspect.bundleCapabilities.join(", ")}`); + } + lines.push( + ...formatInspectSection( + "Capabilities", + inspect.capabilities.map( + (entry) => `${entry.kind}: ${entry.ids.length > 0 ? entry.ids.join(", ") : "(registered)"}`, + ), + ), + ); + lines.push( + ...formatInspectSection( + "Typed hooks", + inspect.typedHooks.map((entry) => + entry.priority == null ? entry.name : `${entry.name} (priority ${entry.priority})`, + ), + ), + ); + lines.push( + ...formatInspectSection( + "Compatibility warnings", + inspect.compatibility.map(formatPluginCompatibilityNotice), + ), + ); + lines.push( + ...formatInspectSection( + "Custom hooks", + inspect.customHooks.map((entry) => `${entry.name}: ${entry.events.join(", ")}`), + ), + ); + lines.push( + ...formatInspectSection( + "Tools", + inspect.tools.map((entry) => { + const names = entry.names.length > 0 ? entry.names.join(", ") : "(anonymous)"; + return entry.optional ? `${names} [optional]` : names; + }), + ), + ); + lines.push(...formatInspectSection("Commands", inspect.commands)); + lines.push(...formatInspectSection("CLI commands", inspect.cliCommands)); + lines.push(...formatInspectSection("Services", inspect.services)); + lines.push(...formatInspectSection("Gateway methods", inspect.gatewayMethods)); + lines.push( + ...formatInspectSection( + "MCP servers", + inspect.mcpServers.map((entry) => + entry.hasStdioTransport ? entry.name : `${entry.name} (unsupported transport)`, + ), + ), + ); + lines.push( + ...formatInspectSection( + "LSP servers", + inspect.lspServers.map((entry) => + entry.hasStdioTransport ? entry.name : `${entry.name} (unsupported transport)`, + ), + ), + ); + if (inspect.httpRouteCount > 0) { + lines.push(...formatInspectSection("HTTP routes", [String(inspect.httpRouteCount)])); + } + const policyLines: string[] = []; + if (typeof inspect.policy.allowPromptInjection === "boolean") { + policyLines.push(`allowPromptInjection: ${inspect.policy.allowPromptInjection}`); + } + if (typeof inspect.policy.allowConversationAccess === "boolean") { + policyLines.push(`allowConversationAccess: ${inspect.policy.allowConversationAccess}`); + } + if (typeof inspect.policy.allowModelOverride === "boolean") { + policyLines.push(`allowModelOverride: ${inspect.policy.allowModelOverride}`); + } + if (inspect.policy.hasAllowedModelsConfig) { + policyLines.push( + `allowedModels: ${ + inspect.policy.allowedModels.length > 0 + ? inspect.policy.allowedModels.join(", ") + : "(configured but empty)" + }`, + ); + } + lines.push(...formatInspectSection("Policy", policyLines)); + lines.push( + ...formatInspectSection( + "Diagnostics", + inspect.diagnostics.map((entry) => `${entry.level.toUpperCase()}: ${entry.message}`), + ), + ); + lines.push(...formatInspectSection("Install", formatInstallLines(install))); + if (inspect.plugin.error) { + lines.push("", `${theme.error("Error:")} ${inspect.plugin.error}`); + } + defaultRuntime.log(lines.join("\n")); +} diff --git a/src/cli/plugins-list-command.ts b/src/cli/plugins-list-command.ts new file mode 100644 index 00000000000..76bad83418b --- /dev/null +++ b/src/cli/plugins-list-command.ts @@ -0,0 +1,114 @@ +import { getRuntimeConfig } from "../config/config.js"; +import { formatPluginSourceForTable, resolvePluginSourceRoots } from "../plugins/source-display.js"; +import { defaultRuntime } from "../runtime.js"; +import { getTerminalTableWidth, renderTable } from "../terminal/table.js"; +import { theme } from "../terminal/theme.js"; +import { quietPluginJsonLogger } from "./plugins-command-helpers.js"; +import { formatPluginLine } from "./plugins-list-format.js"; + +export type PluginsListOptions = { + json?: boolean; + enabled?: boolean; + verbose?: boolean; +}; + +export async function runPluginsListCommand(opts: PluginsListOptions): Promise { + const { buildPluginRegistrySnapshotReport } = await import("../plugins/status.js"); + const cfg = getRuntimeConfig(); + const report = buildPluginRegistrySnapshotReport({ + config: cfg, + ...(opts.json ? { logger: quietPluginJsonLogger } : {}), + }); + const list = opts.enabled ? report.plugins.filter((p) => p.enabled) : report.plugins; + + if (opts.json) { + const payload = { + workspaceDir: report.workspaceDir, + registry: { + source: report.registrySource, + diagnostics: report.registryDiagnostics, + }, + plugins: list, + diagnostics: report.diagnostics, + }; + defaultRuntime.writeJson(payload); + return; + } + + if (list.length === 0) { + defaultRuntime.log(theme.muted("No plugins found.")); + return; + } + + const enabled = list.filter((p) => p.enabled).length; + defaultRuntime.log( + `${theme.heading("Plugins")} ${theme.muted(`(${enabled}/${list.length} enabled)`)}`, + ); + + if (!opts.verbose) { + const tableWidth = getTerminalTableWidth(); + const sourceRoots = resolvePluginSourceRoots({ + workspaceDir: report.workspaceDir, + }); + const usedRoots = new Set(); + const rows = list.map((plugin) => { + const desc = plugin.description ? theme.muted(plugin.description) : ""; + const formattedSource = formatPluginSourceForTable(plugin, sourceRoots); + if (formattedSource.rootKey) { + usedRoots.add(formattedSource.rootKey); + } + const sourceLine = desc ? `${formattedSource.value}\n${desc}` : formattedSource.value; + return { + Name: plugin.name || plugin.id, + ID: plugin.name && plugin.name !== plugin.id ? plugin.id : "", + Format: plugin.format ?? "openclaw", + Status: + plugin.status === "error" + ? theme.error("error") + : plugin.enabled + ? theme.success("enabled") + : theme.warn("disabled"), + Source: sourceLine, + Version: plugin.version ?? "", + }; + }); + + if (usedRoots.size > 0) { + defaultRuntime.log(theme.muted("Source roots:")); + for (const key of ["stock", "workspace", "global"] as const) { + if (!usedRoots.has(key)) { + continue; + } + const dir = sourceRoots[key]; + if (!dir) { + continue; + } + defaultRuntime.log(` ${theme.command(`${key}:`)} ${theme.muted(dir)}`); + } + defaultRuntime.log(""); + } + + defaultRuntime.log( + renderTable({ + width: tableWidth, + columns: [ + { key: "Name", header: "Name", minWidth: 14, flex: true }, + { key: "ID", header: "ID", minWidth: 10, flex: true }, + { key: "Format", header: "Format", minWidth: 9 }, + { key: "Status", header: "Status", minWidth: 10 }, + { key: "Source", header: "Source", minWidth: 26, flex: true }, + { key: "Version", header: "Version", minWidth: 8 }, + ], + rows, + }).trimEnd(), + ); + return; + } + + const lines: string[] = []; + for (const plugin of list) { + lines.push(formatPluginLine(plugin, true)); + lines.push(""); + } + defaultRuntime.log(lines.join("\n").trim()); +} diff --git a/src/cli/program/preaction.test.ts b/src/cli/program/preaction.test.ts index efb42b5e905..d29f3b9c656 100644 --- a/src/cli/program/preaction.test.ts +++ b/src/cli/program/preaction.test.ts @@ -243,7 +243,9 @@ describe("registerPreActionHooks", () => { runtime: runtimeMock, commandPath: ["agent", "hi"], }); - expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledWith({ scope: "all" }); + expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledWith({ + scope: "all", + }); }); it("loads plugins for json local agent runs", async () => { @@ -257,7 +259,9 @@ describe("registerPreActionHooks", () => { commandPath: ["agent", "hi"], suppressDoctorStdout: true, }); - expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledWith({ scope: "all" }); + expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledWith({ + scope: "all", + }); }); it("keeps setup alias and channels add manifest-first", async () => { @@ -506,7 +510,9 @@ describe("registerPreActionHooks", () => { processArgv: ["node", "openclaw", "channels", "send", "--json"], }); - expect(ensurePluginRegistryLoadedMock).toHaveBeenCalled(); + expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledWith({ + scope: "configured-channels", + }); expect(stderrDuringPluginLoad).toBe(true); // Flag must be restored after plugin loading completes expect(loggingState.forceConsoleToStderr).toBe(false); diff --git a/src/cli/route.test.ts b/src/cli/route.test.ts index a75ef4280b7..ccb14af6f5b 100644 --- a/src/cli/route.test.ts +++ b/src/cli/route.test.ts @@ -90,7 +90,10 @@ describe("tryRouteCli", () => { runtime: expect.any(Object), commandPath: ["status"], }); - expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledWith({ scope: "channels" }); + expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledWith({ + scope: "channels", + installBundledRuntimeDeps: false, + }); }); it("keeps logs routed to stderr for routed --json commands", async () => { @@ -159,7 +162,10 @@ describe("tryRouteCli", () => { runtime: expect.any(Object), commandPath: ["status"], }); - expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledWith({ scope: "channels" }); + expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledWith({ + scope: "channels", + installBundledRuntimeDeps: false, + }); }); it("respects OPENCLAW_HIDE_BANNER for routed commands", async () => { diff --git a/src/commands/agents.providers.test.ts b/src/commands/agents.providers.test.ts index a0ad613a791..ad5e7da830e 100644 --- a/src/commands/agents.providers.test.ts +++ b/src/commands/agents.providers.test.ts @@ -66,7 +66,7 @@ describe("buildProviderStatusIndex", () => { expect(mocks.listReadOnlyChannelPluginsForConfig).toHaveBeenCalledWith( {}, - { includeSetupRuntimeFallback: false }, + { includeSetupFallbackPlugins: false }, ); expect(resolveAccount).not.toHaveBeenCalled(); expect(inspectAccount).toHaveBeenCalledWith({}, "work"); diff --git a/src/commands/agents.providers.ts b/src/commands/agents.providers.ts index 34718f0fda9..0642b79393b 100644 --- a/src/commands/agents.providers.ts +++ b/src/commands/agents.providers.ts @@ -34,7 +34,7 @@ export function buildProviderSummaryMetadataIndex( ): Map { return new Map( listReadOnlyChannelPluginsForConfig(cfg, { - includeSetupRuntimeFallback: false, + includeSetupFallbackPlugins: false, }).map((plugin) => [ plugin.id, { @@ -96,7 +96,7 @@ export async function buildProviderStatusIndex( const map = new Map(); for (const plugin of listReadOnlyChannelPluginsForConfig(cfg, { - includeSetupRuntimeFallback: false, + includeSetupFallbackPlugins: false, })) { const accountIds = plugin.config.listAccountIds(cfg); for (const accountId of accountIds) { diff --git a/src/commands/channels.list.auth-profiles.test.ts b/src/commands/channels.list.auth-profiles.test.ts index e813b349ffc..3031798569b 100644 --- a/src/commands/channels.list.auth-profiles.test.ts +++ b/src/commands/channels.list.auth-profiles.test.ts @@ -135,7 +135,7 @@ describe("channels list auth profiles", () => { expect(mocks.listReadOnlyChannelPluginsForConfig).toHaveBeenCalledWith( expect.any(Object), - expect.objectContaining({ includeSetupRuntimeFallback: true }), + expect.objectContaining({ includeSetupFallbackPlugins: true }), ); const payload = JSON.parse(runtime.log.mock.calls[0]?.[0] as string) as { chat?: Record; @@ -175,7 +175,7 @@ describe("channels list auth profiles", () => { expect(mocks.listReadOnlyChannelPluginsForConfig).toHaveBeenCalledWith( expect.any(Object), - expect.objectContaining({ includeSetupRuntimeFallback: true }), + expect.objectContaining({ includeSetupFallbackPlugins: true }), ); const output = stripAnsi(runtime.log.mock.calls[0]?.[0] as string); expect(output).toContain("Chat channels:"); diff --git a/src/commands/channels.remove.test.ts b/src/commands/channels.remove.test.ts index 9c90a93d58d..e901042382e 100644 --- a/src/commands/channels.remove.test.ts +++ b/src/commands/channels.remove.test.ts @@ -89,7 +89,41 @@ describe("channelsRemoveCommand", () => { setActivePluginRegistry(createTestRegistry()); }); - it("removes an external channel account after installing its plugin on demand", async () => { + it("asks users to add an external channel plugin before removing its account", async () => { + configMocks.readConfigFileSnapshot.mockResolvedValue({ + ...baseConfigSnapshot, + config: { + channels: { + "external-chat": { + enabled: true, + token: "token-1", + }, + }, + }, + }); + const catalogEntry: ChannelPluginCatalogEntry = createExternalChatCatalogEntry(); + catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([catalogEntry]); + + await channelsRemoveCommand( + { + channel: "external-chat", + account: "default", + delete: true, + }, + runtime, + { hasFlags: true }, + ); + + expect(ensureChannelSetupPluginInstalled).not.toHaveBeenCalled(); + expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledTimes(1); + expect(configMocks.writeConfigFile).not.toHaveBeenCalled(); + expect(runtime.error).toHaveBeenCalledWith( + 'Channel plugin "external-chat" is not installed. Run "openclaw channels add --channel external-chat" first.', + ); + expect(runtime.exit).toHaveBeenCalledWith(1); + }); + + it("removes an external channel account when its plugin is already installed", async () => { configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot, config: { @@ -104,17 +138,15 @@ describe("channelsRemoveCommand", () => { const catalogEntry: ChannelPluginCatalogEntry = createExternalChatCatalogEntry(); catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([catalogEntry]); const scopedPlugin = createExternalChatDeletePlugin(); - vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel) - .mockReturnValueOnce(createTestRegistry()) - .mockReturnValueOnce( - createTestRegistry([ - { - pluginId: "@vendor/external-chat-plugin", - plugin: scopedPlugin, - source: "test", - }, - ]), - ); + vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel).mockReturnValue( + createTestRegistry([ + { + pluginId: "@vendor/external-chat-plugin", + plugin: scopedPlugin, + source: "test", + }, + ]), + ); await channelsRemoveCommand( { @@ -126,15 +158,8 @@ describe("channelsRemoveCommand", () => { { hasFlags: true }, ); - expect(ensureChannelSetupPluginInstalled).toHaveBeenCalledWith( - expect.objectContaining({ entry: catalogEntry }), - ); - expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledTimes(2); - expect(registryRefreshMocks.refreshPluginRegistryAfterConfigMutation).toHaveBeenCalledWith( - expect.objectContaining({ - reason: "source-changed", - }), - ); + expect(ensureChannelSetupPluginInstalled).not.toHaveBeenCalled(); + expect(registryRefreshMocks.refreshPluginRegistryAfterConfigMutation).not.toHaveBeenCalled(); expect(configMocks.writeConfigFile).toHaveBeenCalledWith( expect.not.objectContaining({ channels: expect.objectContaining({ diff --git a/src/commands/channels.resolve.test.ts b/src/commands/channels.resolve.test.ts index 5eb06bd07f9..917f055d973 100644 --- a/src/commands/channels.resolve.test.ts +++ b/src/commands/channels.resolve.test.ts @@ -74,7 +74,7 @@ describe("channelsResolveCommand", () => { }); }); - it("persists install-on-demand channel setup before resolving explicit targets", async () => { + it("uses installed channel plugins for explicit target resolution without installing", async () => { const resolveTargets = vi.fn().mockResolvedValue([ { input: "friends", @@ -83,19 +83,11 @@ describe("channelsResolveCommand", () => { name: "Friends", }, ]); - const installedCfg = { - channels: {}, - plugins: { - entries: { - whatsapp: { enabled: true }, - }, - }, - }; mocks.resolveInstallableChannelPlugin.mockResolvedValue({ - cfg: installedCfg, + cfg: { channels: {} }, channelId: "whatsapp", - configChanged: true, - pluginInstalled: true, + configChanged: false, + pluginInstalled: false, plugin: { id: "whatsapp", resolver: { resolveTargets }, @@ -113,22 +105,14 @@ describe("channelsResolveCommand", () => { expect(mocks.resolveInstallableChannelPlugin).toHaveBeenCalledWith( expect.objectContaining({ rawChannel: "whatsapp", - allowInstall: true, - }), - ); - expect(mocks.replaceConfigFile).toHaveBeenCalledWith({ - nextConfig: installedCfg, - baseHash: "config-1", - }); - expect(mocks.refreshPluginRegistryAfterConfigMutation).toHaveBeenCalledWith( - expect.objectContaining({ - config: installedCfg, - reason: "source-changed", + allowInstall: false, }), ); + expect(mocks.replaceConfigFile).not.toHaveBeenCalled(); + expect(mocks.refreshPluginRegistryAfterConfigMutation).not.toHaveBeenCalled(); expect(resolveTargets).toHaveBeenCalledWith( expect.objectContaining({ - cfg: installedCfg, + cfg: { channels: {} }, inputs: ["friends"], kind: "group", }), @@ -136,6 +120,28 @@ describe("channelsResolveCommand", () => { expect(runtime.log).toHaveBeenCalledWith("friends -> 120363000000@g.us (Friends)"); }); + it("tells users to add an explicit catalog channel before resolving", async () => { + mocks.resolveInstallableChannelPlugin.mockResolvedValue({ + cfg: { channels: {} }, + channelId: "external-chat", + catalogEntry: { id: "external-chat" }, + configChanged: false, + pluginInstalled: false, + }); + + await expect( + channelsResolveCommand( + { + channel: "external-chat", + entries: ["friends"], + }, + runtime, + ), + ).rejects.toThrow( + 'Channel plugin "external-chat" is not installed. Run "openclaw channels add --channel external-chat" first.', + ); + }); + it("uses the auto-enabled config snapshot for omitted channel resolution", async () => { const autoEnabledConfig = { channels: { whatsapp: {} }, diff --git a/src/commands/channels/capabilities.ts b/src/commands/channels/capabilities.ts index dbe9d50b7e0..70e2a5a73b2 100644 --- a/src/commands/channels/capabilities.ts +++ b/src/commands/channels/capabilities.ts @@ -240,7 +240,7 @@ export async function channelsCapabilitiesCommand( } const plugins = listReadOnlyChannelPluginsForConfig(cfg, { - includeSetupRuntimeFallback: true, + includeSetupFallbackPlugins: true, }); const selected = !rawChannel || rawChannel === "all" diff --git a/src/commands/channels/list.ts b/src/commands/channels/list.ts index d555ab34aca..c86ed7d21ea 100644 --- a/src/commands/channels/list.ts +++ b/src/commands/channels/list.ts @@ -114,7 +114,7 @@ export async function channelsListCommand( const includeUsage = opts.usage !== false; const plugins = listReadOnlyChannelPluginsForConfig(cfg, { - includeSetupRuntimeFallback: true, + includeSetupFallbackPlugins: true, }); const authStore = loadAuthProfileStoreWithoutExternalProfiles(); diff --git a/src/commands/channels/remove.ts b/src/commands/channels/remove.ts index 9cdfc343f0f..62d6dcf9102 100644 --- a/src/commands/channels/remove.ts +++ b/src/commands/channels/remove.ts @@ -53,7 +53,7 @@ export async function channelsRemoveCommand( if (useWizard && prompter) { await prompter.intro("Remove channel account"); const readOnlyPlugins = listReadOnlyChannelPluginsForConfig(cfg, { - includeSetupRuntimeFallback: true, + includeSetupFallbackPlugins: true, }); const selectedChannel = await prompter.select({ message: "Channel", @@ -115,7 +115,7 @@ export async function channelsRemoveCommand( cfg, runtime, rawChannel: lookupChannel, - allowInstall: true, + allowInstall: false, }); })() : null; @@ -131,6 +131,13 @@ export async function channelsRemoveCommand( channel = resolvedChannel; const plugin = resolvedPluginState?.plugin ?? getChannelPlugin(resolvedChannel); if (!plugin) { + if (resolvedPluginState?.catalogEntry) { + runtime.error( + `Channel plugin "${resolvedPluginState.catalogEntry.id}" is not installed. Run "openclaw channels add --channel ${resolvedPluginState.catalogEntry.id}" first.`, + ); + runtime.exit(1); + return; + } runtime.error(`Unknown channel: ${resolvedChannel}`); runtime.exit(1); return; diff --git a/src/commands/channels/resolve.ts b/src/commands/channels/resolve.ts index d184b1ed3e1..9642c99ed63 100644 --- a/src/commands/channels/resolve.ts +++ b/src/commands/channels/resolve.ts @@ -139,10 +139,15 @@ export async function channelsResolveCommand(opts: ChannelsResolveOptions, runti cfg, runtime, rawChannel: explicitChannel, - allowInstall: true, + allowInstall: false, supports: (plugin) => Boolean(plugin.resolver?.resolveTargets), }) : null; + if (explicitChannel && resolvedExplicit?.catalogEntry && !resolvedExplicit.plugin) { + throw new Error( + `Channel plugin "${resolvedExplicit.catalogEntry.id}" is not installed. Run "openclaw channels add --channel ${resolvedExplicit.catalogEntry.id}" first.`, + ); + } if (resolvedExplicit?.configChanged) { cfg = resolvedExplicit.cfg; const shouldMovePluginInstalls = Boolean( diff --git a/src/commands/channels/status-config-format.ts b/src/commands/channels/status-config-format.ts index 50a7bf33252..0d8133e274d 100644 --- a/src/commands/channels/status-config-format.ts +++ b/src/commands/channels/status-config-format.ts @@ -60,7 +60,7 @@ export async function formatConfigChannelsStatusLines( const sourceConfig = opts?.sourceConfig ?? cfg; const plugins = listReadOnlyChannelPluginsForConfig(cfg, { activationSourceConfig: sourceConfig, - includeSetupRuntimeFallback: false, + includeSetupFallbackPlugins: false, }); for (const plugin of plugins) { const accountIds = plugin.config.listAccountIds(cfg); diff --git a/src/commands/configure.wizard.test.ts b/src/commands/configure.wizard.test.ts index 96cc3d4bba9..c26c73ef3b0 100644 --- a/src/commands/configure.wizard.test.ts +++ b/src/commands/configure.wizard.test.ts @@ -26,10 +26,15 @@ const mocks = vi.hoisted(() => { waitForGatewayReachable: vi.fn(), resolveControlUiLinks: vi.fn(), summarizeExistingConfig: vi.fn(), + promptRemoteGatewayConfig: vi.fn(async (cfg: OpenClawConfig) => ({ + ...cfg, + gateway: { mode: "remote", remote: { url: "wss://gateway.example.test" } }, + })), isCodexNativeWebSearchRelevant: vi.fn(({ config }: { config: OpenClawConfig }) => Boolean(config.auth?.profiles?.["openai-codex:default"]), ), setupChannels: vi.fn(async (cfg: OpenClawConfig) => cfg), + preparePostConfigBundledRuntimeDeps: vi.fn(async () => {}), }; }); @@ -98,7 +103,7 @@ vi.mock("./configure.daemon.js", () => ({ })); vi.mock("./onboard-remote.js", () => ({ - promptRemoteGatewayConfig: vi.fn(), + promptRemoteGatewayConfig: mocks.promptRemoteGatewayConfig, })); vi.mock("./onboard-skills.js", () => ({ @@ -109,6 +114,10 @@ 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, @@ -246,6 +255,26 @@ 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 () => { @@ -613,6 +642,7 @@ 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 073aa06eb83..8c533b4614d 100644 --- a/src/commands/configure.wizard.ts +++ b/src/commands/configure.wizard.ts @@ -50,6 +50,7 @@ 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"); @@ -517,6 +518,7 @@ 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 index 5cccfaadb4e..29aced70a5b 100644 --- a/src/commands/doctor-bundled-plugin-runtime-deps.test.ts +++ b/src/commands/doctor-bundled-plugin-runtime-deps.test.ts @@ -2,11 +2,9 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { - resolveBundledRuntimeDependencyPackageInstallRoot, - scanBundledPluginRuntimeDeps, - type BundledRuntimeDepsInstallParams, -} from "../plugins/bundled-runtime-deps.js"; +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"; @@ -174,7 +172,7 @@ describe("doctor bundled plugin runtime deps", () => { }, }); - const result = scanBundledPluginRuntimeDeps({ packageRoot: root }); + const result = createBundledRuntimeDepsPackagePlan({ packageRoot: root }); expect(result.missing).toEqual([]); expect(result.conflicts).toEqual([]); }); @@ -211,7 +209,7 @@ describe("doctor bundled plugin runtime deps", () => { version: "1.0.0", }); - const result = scanBundledPluginRuntimeDeps({ packageRoot: root }); + 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"]); @@ -227,7 +225,7 @@ describe("doctor bundled plugin runtime deps", () => { writeBundledChannelPlugin(root, "discord", { "discord-only": "1.0.0" }); writeBundledChannelPlugin(root, "whatsapp", { "whatsapp-only": "1.0.0" }); - const result = scanBundledPluginRuntimeDeps({ + const result = createBundledRuntimeDepsPackagePlan({ packageRoot: root, config: { plugins: { enabled: true }, @@ -248,7 +246,7 @@ describe("doctor bundled plugin runtime deps", () => { writeJson(path.join(root, "package.json"), { name: "openclaw" }); writeBundledChannelPlugin(root, "discord", { "discord-only": "1.0.0" }); - const result = scanBundledPluginRuntimeDeps({ + const result = createBundledRuntimeDepsPackagePlan({ packageRoot: root, config: { plugins: { enabled: true }, @@ -264,7 +262,7 @@ describe("doctor bundled plugin runtime deps", () => { writeJson(path.join(root, "package.json"), { name: "openclaw" }); writeBundledChannelPlugin(root, "telegram", { "telegram-only": "1.0.0" }); - const result = scanBundledPluginRuntimeDeps({ + const result = createBundledRuntimeDepsPackagePlan({ packageRoot: root, includeConfiguredChannels: true, config: { @@ -284,7 +282,7 @@ describe("doctor bundled plugin runtime deps", () => { writeJson(path.join(root, "package.json"), { name: "openclaw" }); writeBundledChannelPlugin(root, "telegram", { "telegram-only": "1.0.0" }); - const result = scanBundledPluginRuntimeDeps({ + const result = createBundledRuntimeDepsPackagePlan({ packageRoot: root, includeConfiguredChannels: true, config: { @@ -306,7 +304,7 @@ describe("doctor bundled plugin runtime deps", () => { writeJson(path.join(root, "package.json"), { name: "openclaw" }); writeBundledChannelPlugin(root, "telegram", { "telegram-only": "1.0.0" }); - const result = scanBundledPluginRuntimeDeps({ + const result = createBundledRuntimeDepsPackagePlan({ packageRoot: root, includeConfiguredChannels: true, config: { @@ -331,7 +329,7 @@ describe("doctor bundled plugin runtime deps", () => { writeJson(path.join(root, "package.json"), { name: "openclaw" }); writeDefaultEnabledBundledChannelPlugin(root, "demo", { "demo-only": "1.0.0" }); - const result = scanBundledPluginRuntimeDeps({ + const result = createBundledRuntimeDepsPackagePlan({ packageRoot: root, includeConfiguredChannels: true, config: { @@ -360,7 +358,7 @@ describe("doctor bundled plugin runtime deps", () => { configSchema: { type: "object" }, }); - const result = scanBundledPluginRuntimeDeps({ + const result = createBundledRuntimeDepsPackagePlan({ packageRoot: root, config: { plugins: { enabled: true }, @@ -388,7 +386,7 @@ describe("doctor bundled plugin runtime deps", () => { configSchema: { type: "object" }, }); - const result = scanBundledPluginRuntimeDeps({ + const result = createBundledRuntimeDepsPackagePlan({ packageRoot: root, config: { plugins: { @@ -419,7 +417,7 @@ describe("doctor bundled plugin runtime deps", () => { configSchema: { type: "object" }, }); - const result = scanBundledPluginRuntimeDeps({ + const result = createBundledRuntimeDepsPackagePlan({ packageRoot: root, config: { plugins: { enabled: true, allow: ["browser"] }, @@ -435,7 +433,7 @@ describe("doctor bundled plugin runtime deps", () => { writeJson(path.join(root, "package.json"), { name: "openclaw" }); writeBundledChannelPlugin(root, "telegram", { "telegram-only": "1.0.0" }); - const result = scanBundledPluginRuntimeDeps({ + const result = createBundledRuntimeDepsPackagePlan({ packageRoot: root, config: { plugins: { enabled: true, allow: ["browser"] }, @@ -456,7 +454,7 @@ describe("doctor bundled plugin runtime deps", () => { writeJson(path.join(root, "package.json"), { name: "openclaw" }); writeBundledChannelPlugin(root, "telegram", { "telegram-only": "1.0.0" }); - const result = scanBundledPluginRuntimeDeps({ + const result = createBundledRuntimeDepsPackagePlan({ packageRoot: root, includeConfiguredChannels: true, config: { @@ -661,6 +659,40 @@ describe("doctor bundled plugin runtime deps", () => { expectNoLegacyRuntimeDepsManifest(installRoot); }); + it("repairs a previous incomplete runtime deps install during non-interactive doctor", 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: createNonInteractivePrompter(), + 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" }); diff --git a/src/commands/doctor-bundled-plugin-runtime-deps.ts b/src/commands/doctor-bundled-plugin-runtime-deps.ts index b62e46b29da..ae01acbc9c7 100644 --- a/src/commands/doctor-bundled-plugin-runtime-deps.ts +++ b/src/commands/doctor-bundled-plugin-runtime-deps.ts @@ -1,12 +1,10 @@ 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 { - createBundledRuntimeDepsInstallSpecs, - repairBundledRuntimeDepsInstallRootAsync, - resolveBundledRuntimeDependencyPackageInstallRootPlan, - scanBundledPluginRuntimeDeps, - type BundledRuntimeDepsInstallParams, + createBundledRuntimeDepsPackagePlan, + repairBundledRuntimeDepsPackagePlanAsync, } from "../plugins/bundled-runtime-deps.js"; import type { RuntimeEnv } from "../runtime.js"; import { note } from "../terminal/note.js"; @@ -52,14 +50,14 @@ export async function maybeRepairBundledPluginRuntimeDeps(params: { } const env = params.env ?? process.env; - const { deps, missing, conflicts } = scanBundledPluginRuntimeDeps({ + const plan = createBundledRuntimeDepsPackagePlan({ packageRoot, config: params.config, includeConfiguredChannels: params.includeConfiguredChannels, env, }); - if (conflicts.length > 0) { - const conflictLines = conflicts.flatMap((conflict) => + 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) ?? []; @@ -77,20 +75,16 @@ export async function maybeRepairBundledPluginRuntimeDeps(params: { ); } - if (missing.length === 0) { + if (plan.missing.length === 0) { return; } - const installRootPlan = resolveBundledRuntimeDependencyPackageInstallRootPlan(packageRoot, { - env, - }); - const installSpecs = createBundledRuntimeDepsInstallSpecs({ - deps, - }); note( [ "Bundled plugin runtime deps need staging.", - ...missing.map((dep) => `- ${dep.name}@${dep.version} (used by ${dep.pluginIds.join(", ")})`), + ...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", @@ -112,14 +106,14 @@ export async function maybeRepairBundledPluginRuntimeDeps(params: { try { const { createCliProgress } = await import("../cli/progress.js"); progress = createCliProgress({ - label: `Installing bundled plugin runtime deps (${installSpecs.length})`, + 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 (${installSpecs.length} specs): ${installSpecs.join(", ")}`, + `Installing bundled plugin runtime deps (${plan.installSpecs.length} specs): ${plan.installSpecs.join(", ")}`, ); heartbeat = setInterval(() => { logRuntimeDepsInstallProgress( @@ -128,10 +122,10 @@ export async function maybeRepairBundledPluginRuntimeDeps(params: { ); }, RUNTIME_DEPS_INSTALL_HEARTBEAT_MS); heartbeat.unref?.(); - const result = await repairBundledRuntimeDepsInstallRootAsync({ - installRoot: installRootPlan.installRoot, - missingSpecs: installSpecs, - installSpecs, + const result = await repairBundledRuntimeDepsPackagePlanAsync({ + packageRoot, + config: params.config, + includeConfiguredChannels: params.includeConfiguredChannels, env: params.env ?? process.env, installDeps: params.installDeps ? async (installParams) => { @@ -143,9 +137,16 @@ export async function maybeRepairBundledPluginRuntimeDeps(params: { }); logRuntimeDepsInstallProgress( params.runtime, - `Installed bundled plugin runtime deps in ${formatElapsedMs(Date.now() - installStartedAt)}: ${result.installSpecs.join(", ")}`, + 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", ); - note(`Installed bundled plugin deps: ${result.installSpecs.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)); diff --git a/src/commands/doctor-security.test.ts b/src/commands/doctor-security.test.ts index 6701daf84e4..486afc3f6a3 100644 --- a/src/commands/doctor-security.test.ts +++ b/src/commands/doctor-security.test.ts @@ -202,7 +202,7 @@ describe("noteSecurityWarnings gateway exposure", () => { await noteSecurityWarnings(cfg); expect(listReadOnlyChannelPluginsForConfigMock).toHaveBeenCalledWith(cfg, { includePersistedAuthState: true, - includeSetupRuntimeFallback: true, + includeSetupFallbackPlugins: true, }); const message = lastMessage(); expect(message).toContain('config set session.dmScope "per-channel-peer"'); @@ -465,7 +465,7 @@ describe("noteSecurityWarnings gateway exposure", () => { {}, { includePersistedAuthState: true, - includeSetupRuntimeFallback: true, + includeSetupFallbackPlugins: true, }, ); const message = lastMessage(); diff --git a/src/commands/doctor-security.ts b/src/commands/doctor-security.ts index 05e2a28d062..38bcece5fff 100644 --- a/src/commands/doctor-security.ts +++ b/src/commands/doctor-security.ts @@ -307,7 +307,7 @@ export async function noteSecurityWarnings(cfg: OpenClawConfig) { for (const plugin of listReadOnlyChannelPluginsForConfig(cfg, { includePersistedAuthState: true, - includeSetupRuntimeFallback: true, + includeSetupFallbackPlugins: true, })) { if (!plugin.security) { continue; diff --git a/src/commands/doctor/shared/channel-doctor.test.ts b/src/commands/doctor/shared/channel-doctor.test.ts index 025b22ea390..e279b811685 100644 --- a/src/commands/doctor/shared/channel-doctor.test.ts +++ b/src/commands/doctor/shared/channel-doctor.test.ts @@ -16,7 +16,7 @@ const mocks = vi.hoisted(() => ({ const READ_ONLY_CHANNEL_DOCTOR_OPTIONS = { includePersistedAuthState: false, - includeSetupRuntimeFallback: true, + includeSetupFallbackPlugins: true, } as const; vi.mock("../../../channels/plugins/registry.js", () => ({ diff --git a/src/commands/doctor/shared/channel-doctor.ts b/src/commands/doctor/shared/channel-doctor.ts index 6a6cc6fa2e0..2e7e82cf201 100644 --- a/src/commands/doctor/shared/channel-doctor.ts +++ b/src/commands/doctor/shared/channel-doctor.ts @@ -134,7 +134,7 @@ function safeListReadOnlyChannelPlugins(context: ChannelDoctorLookupContext) { return resolveReadOnlyChannelPluginsForConfig(context.cfg, { ...(context.env ? { env: context.env } : {}), includePersistedAuthState: false, - includeSetupRuntimeFallback: true, + includeSetupFallbackPlugins: true, }).plugins; } catch { return []; diff --git a/src/commands/health.snapshot.test.ts b/src/commands/health.snapshot.test.ts index c0ed1739341..f0c633c6d4b 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 install bundled runtime deps: ENOSPC", + error: "failed to prepare bundled runtime deps: ENOSPC", }), createPluginRecord({ id: "optional-broken", @@ -470,7 +470,7 @@ describe("getHealthSnapshot", () => { activationSource: "explicit", activationReason: "bundled-channel-enabled-in-config", failurePhase: "load", - error: "failed to install bundled runtime deps: ENOSPC", + error: "failed to prepare bundled runtime deps: ENOSPC", }, ]); }); diff --git a/src/commands/health.ts b/src/commands/health.ts index 1b9c7b1e54f..93930b4e3e9 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -338,7 +338,7 @@ export async function getHealthSnapshot(params?: { const includeSensitive = params?.includeSensitive !== false; const channels: Record = {}; const plugins = listReadOnlyChannelPluginsForConfig(cfg, { - includeSetupRuntimeFallback: false, + includeSetupFallbackPlugins: false, }); const channelOrder = plugins.map((plugin) => plugin.id); const channelLabels: Record = {}; @@ -563,7 +563,7 @@ export async function healthCommand( : resolvedAgents.filter((agent) => agent.agentId === defaultAgentId); const channelBindings = buildChannelAccountBindings(cfg); const displayPlugins = listReadOnlyChannelPluginsForConfig(cfg, { - includeSetupRuntimeFallback: false, + includeSetupFallbackPlugins: false, }); if (debugEnabled) { runtime.log(info("[debug] local channel accounts")); diff --git a/src/commands/onboard-non-interactive.gateway.test.ts b/src/commands/onboard-non-interactive.gateway.test.ts index 75e05aa9d91..215015ce7c4 100644 --- a/src/commands/onboard-non-interactive.gateway.test.ts +++ b/src/commands/onboard-non-interactive.gateway.test.ts @@ -9,6 +9,7 @@ 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(() => @@ -131,6 +132,10 @@ vi.mock("./health.js", () => ({ healthCommand: healthCommandMock, })); +vi.mock("./post-config-runtime-deps.js", () => ({ + preparePostConfigBundledRuntimeDeps: preparePostConfigBundledRuntimeDepsMock, +})); + vi.mock("../daemon/service.js", () => ({ resolveGatewayService: () => gatewayServiceMock, })); @@ -316,6 +321,7 @@ 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 () => { @@ -350,6 +356,14 @@ 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); @@ -408,6 +422,7 @@ 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 e13871f0fa3..4149dc2af06 100644 --- a/src/commands/onboard-non-interactive/local.ts +++ b/src/commands/onboard-non-interactive/local.ts @@ -15,6 +15,7 @@ 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, @@ -209,6 +210,7 @@ export async function runNonInteractiveLocalSetup(params: { ...(baseHash !== undefined ? { baseHash } : {}), }); logConfigUpdated(runtime); + await preparePostConfigBundledRuntimeDeps({ config: nextConfig, runtime }); await ensureWorkspaceAndSessions(workspaceDir, runtime, { skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap), diff --git a/src/commands/post-config-runtime-deps.test.ts b/src/commands/post-config-runtime-deps.test.ts new file mode 100644 index 00000000000..7f63914281f --- /dev/null +++ b/src/commands/post-config-runtime-deps.test.ts @@ -0,0 +1,164 @@ +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, + env, + }); + expect(mocks.repairBundledRuntimeDepsPackagePlanAsync).toHaveBeenCalledWith( + expect.objectContaining({ + packageRoot: "/pkg", + config, + includeConfiguredChannels: true, + 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 new file mode 100644 index 00000000000..f0922cf23fd --- /dev/null +++ b/src/commands/post-config-runtime-deps.ts @@ -0,0 +1,133 @@ +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, + 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, + 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/commands/status-all/channels.ts b/src/commands/status-all/channels.ts index d8324ed5e27..f5fcd14a89b 100644 --- a/src/commands/status-all/channels.ts +++ b/src/commands/status-all/channels.ts @@ -191,7 +191,7 @@ export async function buildChannelsTable( opts?: { showSecrets?: boolean; sourceConfig?: OpenClawConfig; - includeSetupRuntimeFallback?: boolean; + includeSetupFallbackPlugins?: boolean; }, ): Promise<{ rows: ChannelRow[]; @@ -210,10 +210,10 @@ export async function buildChannelsTable( }> = []; const sourceConfig = opts?.sourceConfig ?? cfg; - const includeSetupRuntimeFallback = opts?.includeSetupRuntimeFallback ?? true; + const includeSetupFallbackPlugins = opts?.includeSetupFallbackPlugins ?? true; for (const plugin of listReadOnlyChannelPluginsForConfig(cfg, { activationSourceConfig: sourceConfig, - includeSetupRuntimeFallback, + includeSetupFallbackPlugins, })) { const accountIds = plugin.config.listAccountIds(cfg); const defaultAccountId = resolveChannelDefaultAccountId({ diff --git a/src/commands/status-runtime-shared.test.ts b/src/commands/status-runtime-shared.test.ts index 49b07fc7319..835af0d04ff 100644 --- a/src/commands/status-runtime-shared.test.ts +++ b/src/commands/status-runtime-shared.test.ts @@ -74,7 +74,7 @@ describe("status-runtime-shared", () => { { gateway: {} }, { activationSourceConfig: { gateway: {} }, - includeSetupRuntimeFallback: false, + includeSetupFallbackPlugins: false, }, ); }); diff --git a/src/commands/status-runtime-shared.ts b/src/commands/status-runtime-shared.ts index c5c222442c1..8a44502c6ef 100644 --- a/src/commands/status-runtime-shared.ts +++ b/src/commands/status-runtime-shared.ts @@ -30,7 +30,7 @@ export async function resolveStatusSecurityAudit(params: { const { runSecurityAudit } = await loadSecurityAuditModule(); const readOnlyPlugins = resolveReadOnlyChannelPluginsForConfig(params.config, { activationSourceConfig: params.sourceConfig, - includeSetupRuntimeFallback: false, + includeSetupFallbackPlugins: false, }); return await runSecurityAudit({ config: params.config, diff --git a/src/commands/status.link-channel.ts b/src/commands/status.link-channel.ts index 17eba3690f5..c34aac2e0a1 100644 --- a/src/commands/status.link-channel.ts +++ b/src/commands/status.link-channel.ts @@ -19,7 +19,7 @@ export async function resolveLinkChannelContext( const sourceConfig = options.sourceConfig ?? cfg; for (const plugin of listReadOnlyChannelPluginsForConfig(cfg, { activationSourceConfig: sourceConfig, - includeSetupRuntimeFallback: false, + includeSetupFallbackPlugins: false, })) { const { defaultAccountId, account, enabled, configured } = await resolveDefaultChannelAccountContext(plugin, cfg, { diff --git a/src/commands/status.scan-overview.test.ts b/src/commands/status.scan-overview.test.ts index acdc7337c68..86e2f2abd29 100644 --- a/src/commands/status.scan-overview.test.ts +++ b/src/commands/status.scan-overview.test.ts @@ -113,7 +113,7 @@ describe("collectStatusScanOverview", () => { expect(mocks.buildChannelsTable).toHaveBeenCalledWith( expect.any(Object), expect.objectContaining({ - includeSetupRuntimeFallback: true, + includeSetupFallbackPlugins: true, showSecrets: false, sourceConfig: { session: {} }, }), @@ -134,7 +134,7 @@ describe("collectStatusScanOverview", () => { expect(mocks.buildChannelsTable).toHaveBeenCalledWith( expect.any(Object), expect.objectContaining({ - includeSetupRuntimeFallback: false, + includeSetupFallbackPlugins: false, showSecrets: false, sourceConfig: { session: {} }, }), diff --git a/src/commands/status.scan-overview.ts b/src/commands/status.scan-overview.ts index 51d6c8d3b8c..422e927830e 100644 --- a/src/commands/status.scan-overview.ts +++ b/src/commands/status.scan-overview.ts @@ -254,7 +254,7 @@ export async function collectStatusScanOverview(params: { const channels = await buildChannelsTable(cfg, { showSecrets: params.showSecrets, sourceConfig, - includeSetupRuntimeFallback: params.includeChannelSetupRuntimeFallback !== false, + includeSetupFallbackPlugins: params.includeChannelSetupRuntimeFallback !== false, }); params.progress?.tick(); return { channelsStatus, channelIssues, channels }; diff --git a/src/commands/status.scan.test.ts b/src/commands/status.scan.test.ts index ff4c33b3161..407ad25ed65 100644 --- a/src/commands/status.scan.test.ts +++ b/src/commands/status.scan.test.ts @@ -90,7 +90,7 @@ describe("scanStatus", () => { expect(mocks.buildChannelsTable).toHaveBeenCalledWith( expect.objectContaining({ marker: "resolved" }), expect.objectContaining({ - includeSetupRuntimeFallback: false, + includeSetupFallbackPlugins: false, sourceConfig: expect.objectContaining({ marker: "source" }), }), ); @@ -117,7 +117,7 @@ describe("scanStatus", () => { ); expect(mocks.buildChannelsTable).toHaveBeenCalledWith( expect.any(Object), - expect.objectContaining({ includeSetupRuntimeFallback: false }), + expect.objectContaining({ includeSetupFallbackPlugins: false }), ); }); @@ -145,7 +145,7 @@ describe("scanStatus", () => { ); expect(mocks.buildChannelsTable).toHaveBeenCalledWith( expect.any(Object), - expect.objectContaining({ includeSetupRuntimeFallback: true }), + expect.objectContaining({ includeSetupFallbackPlugins: true }), ); }); diff --git a/src/gateway/config-reload-plan.ts b/src/gateway/config-reload-plan.ts index d3f1563638e..e808e8cc8fb 100644 --- a/src/gateway/config-reload-plan.ts +++ b/src/gateway/config-reload-plan.ts @@ -20,6 +20,7 @@ export type GatewayReloadPlan = { restartHealthMonitor: boolean; restartChannels: Set; disposeMcpRuntimes: boolean; + planPluginRuntimeDeps: boolean; noopPaths: string[]; }; @@ -122,6 +123,24 @@ const BASE_RELOAD_RULES_TAIL: ReloadRule[] = [ { prefix: "canvasHost", kind: "restart" }, ]; +const PLUGIN_RUNTIME_DEPS_PLAN_PREFIXES = [ + "auth.order", + "auth.profiles", + "channels", + "models.providers", + "plugins", + "agents.list", + "agents.defaults.imageGenerationModel", + "agents.defaults.imageModel", + "agents.defaults.memorySearch", + "agents.defaults.model", + "agents.defaults.models", + "agents.defaults.musicGenerationModel", + "agents.defaults.pdfModel", + "agents.defaults.subagents.model", + "agents.defaults.videoGenerationModel", +] as const; + let cachedReloadRules: ReloadRule[] | null = null; let cachedRegistry: ReturnType | null = null; let cachedActiveRegistryVersion = -1; @@ -211,6 +230,12 @@ function isPluginInstallTimestampPath(path: string): boolean { return /^plugins\.installs\..+\.(installedAt|resolvedAt)$/.test(path); } +function shouldPlanPluginRuntimeDepsForPath(path: string): boolean { + return PLUGIN_RUNTIME_DEPS_PLAN_PREFIXES.some( + (prefix) => path === prefix || path.startsWith(`${prefix}.`), + ); +} + function getPluginInstallRecords(config: unknown): Record { if (!isPlainObject(config)) { return {}; @@ -288,6 +313,7 @@ export function buildGatewayReloadPlan( restartHealthMonitor: false, restartChannels: new Set(), disposeMcpRuntimes: false, + planPluginRuntimeDeps: false, noopPaths: [], }; @@ -329,6 +355,9 @@ export function buildGatewayReloadPlan( plan.noopPaths.push(path); continue; } + if (shouldPlanPluginRuntimeDepsForPath(path)) { + plan.planPluginRuntimeDeps = true; + } const rule = matchRule(path); if (!rule) { plan.restartGateway = true; diff --git a/src/gateway/config-reload.test.ts b/src/gateway/config-reload.test.ts index 8e4af2faf5b..9071144bd08 100644 --- a/src/gateway/config-reload.test.ts +++ b/src/gateway/config-reload.test.ts @@ -214,6 +214,7 @@ describe("buildGatewayReloadPlan", () => { ); expect(expected.size).toBeGreaterThan(0); expect(plan.restartChannels).toEqual(expected); + expect(plan.planPluginRuntimeDeps).toBe(true); }); it("refreshes channel reload rules when only the tracked channel registry changes", () => { @@ -240,6 +241,7 @@ describe("buildGatewayReloadPlan", () => { ]); expect(plan.restartGateway).toBe(false); expect(plan.restartHeartbeat).toBe(true); + expect(plan.planPluginRuntimeDeps).toBe(true); expect(plan.hotReasons).toEqual( expect.arrayContaining(["models.providers.openai.models", "agents.defaults.model"]), ); @@ -257,6 +259,7 @@ describe("buildGatewayReloadPlan", () => { const plan = buildGatewayReloadPlan(["agents.defaults.models"]); expect(plan.restartGateway).toBe(false); expect(plan.restartHeartbeat).toBe(true); + expect(plan.planPluginRuntimeDeps).toBe(true); expect(plan.hotReasons).toContain("agents.defaults.models"); expect(plan.noopPaths).toEqual([]); }); @@ -265,6 +268,7 @@ describe("buildGatewayReloadPlan", () => { const plan = buildGatewayReloadPlan(["agents.list"]); expect(plan.restartGateway).toBe(false); expect(plan.restartHeartbeat).toBe(true); + expect(plan.planPluginRuntimeDeps).toBe(true); expect(plan.hotReasons).toContain("agents.list"); expect(plan.noopPaths).toEqual([]); }); @@ -275,6 +279,7 @@ describe("buildGatewayReloadPlan", () => { "plugins.installs.lossless-claw.installedAt", ]); expect(plan.restartGateway).toBe(false); + expect(plan.planPluginRuntimeDeps).toBe(false); expect(plan.noopPaths).toEqual([ "plugins.installs.lossless-claw.resolvedAt", "plugins.installs.lossless-claw.installedAt", @@ -346,6 +351,7 @@ describe("buildGatewayReloadPlan", () => { it("treats secrets config changes as no-op for gateway restart planning", () => { const plan = buildGatewayReloadPlan(["secrets.providers.default.path"]); expect(plan.restartGateway).toBe(false); + expect(plan.planPluginRuntimeDeps).toBe(false); expect(plan.noopPaths).toContain("secrets.providers.default.path"); }); diff --git a/src/gateway/config-reload.ts b/src/gateway/config-reload.ts index 7b5f7acbaf8..f99eb895bdd 100644 --- a/src/gateway/config-reload.ts +++ b/src/gateway/config-reload.ts @@ -72,6 +72,7 @@ function isNoopReloadPlan(plan: GatewayReloadPlan): boolean { !plan.restartHeartbeat && !plan.restartHealthMonitor && !plan.disposeMcpRuntimes && + !plan.planPluginRuntimeDeps && plan.restartChannels.size === 0 ); } diff --git a/src/gateway/server-aux-handlers.test.ts b/src/gateway/server-aux-handlers.test.ts index 6a7b867a406..55b8f4d28d3 100644 --- a/src/gateway/server-aux-handlers.test.ts +++ b/src/gateway/server-aux-handlers.test.ts @@ -26,6 +26,7 @@ function createReloadPlan(overrides?: Partial): GatewayReload restartHealthMonitor: overrides?.restartHealthMonitor ?? false, restartChannels: overrides?.restartChannels ?? new Set(), disposeMcpRuntimes: overrides?.disposeMcpRuntimes ?? false, + planPluginRuntimeDeps: overrides?.planPluginRuntimeDeps ?? false, noopPaths: overrides?.noopPaths ?? [], }; } diff --git a/src/gateway/server-plugin-bootstrap.ts b/src/gateway/server-plugin-bootstrap.ts index d114593ea40..17eeaefdf41 100644 --- a/src/gateway/server-plugin-bootstrap.ts +++ b/src/gateway/server-plugin-bootstrap.ts @@ -1,7 +1,7 @@ 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.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,8 +37,10 @@ type GatewayPluginBootstrapParams = { pluginLookUpTable?: PluginLookUpTable; preferSetupRuntimeForChannelPlugins?: boolean; suppressPluginInfoLogs?: boolean; + installBundledRuntimeDeps?: boolean; logDiagnostics?: boolean; bundledRuntimeDepsInstaller?: (params: BundledRuntimeDepsInstallParams) => void; + bundledRuntimeDepsRepairError?: unknown; beforePrimeRegistry?: (pluginRegistry: PluginRegistry) => void; }; @@ -104,7 +106,9 @@ 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 96e6ce85c4b..a85f26cbf9e 100644 --- a/src/gateway/server-plugins.ts +++ b/src/gateway/server-plugins.ts @@ -2,7 +2,7 @@ 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.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,7 +531,9 @@ export function loadGatewayPlugins(params: { pluginLookUpTable?: PluginLookUpTable; preferSetupRuntimeForChannelPlugins?: boolean; suppressPluginInfoLogs?: boolean; + installBundledRuntimeDeps?: boolean; bundledRuntimeDepsInstaller?: (params: BundledRuntimeDepsInstallParams) => void; + bundledRuntimeDepsRepairError?: unknown; }) { const activationAutoEnabled = params.activationSourceConfig !== undefined @@ -603,7 +605,9 @@ 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 77d53ae1943..5d2053b6514 100644 --- a/src/gateway/server-reload-handlers.ts +++ b/src/gateway/server-reload-handlers.ts @@ -8,6 +8,7 @@ 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, @@ -15,6 +16,8 @@ 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, @@ -62,6 +65,48 @@ 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; @@ -260,6 +305,13 @@ 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-runtime-state.test.ts b/src/gateway/server-runtime-state.test.ts new file mode 100644 index 00000000000..3fb42b87b93 --- /dev/null +++ b/src/gateway/server-runtime-state.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { createEmptyPluginRegistry } from "../plugins/registry.js"; +import { + getActivePluginChannelRegistry, + pinActivePluginHttpRouteRegistry, + pinActivePluginChannelRegistry, + releasePinnedPluginChannelRegistry, + releasePinnedPluginHttpRouteRegistry, + resetPluginRuntimeStateForTest, + resolveActivePluginHttpRouteRegistry, + setActivePluginRegistry, +} from "../plugins/runtime.js"; +import { createGatewayRuntimeState } from "./server-runtime-state.js"; + +function createRegistryWithRoute(path: string) { + const registry = createEmptyPluginRegistry(); + registry.httpRoutes.push({ + path, + auth: "plugin", + match: "exact", + handler: () => true, + pluginId: "demo", + source: "test", + }); + return registry; +} + +describe("createGatewayRuntimeState", () => { + afterEach(() => { + releasePinnedPluginHttpRouteRegistry(); + releasePinnedPluginChannelRegistry(); + resetPluginRuntimeStateForTest(); + }); + + it("releases post-bootstrap repinned plugin registries on cleanup", async () => { + const startupRegistry = createRegistryWithRoute("/startup"); + const loadedRegistry = createRegistryWithRoute("/loaded"); + const fallbackRegistry = createRegistryWithRoute("/fallback"); + + setActivePluginRegistry(startupRegistry); + const runtimeState = await createGatewayRuntimeState({ + cfg: {}, + bindHost: "127.0.0.1", + port: 0, + controlUiEnabled: false, + controlUiBasePath: "/", + openAiChatCompletionsEnabled: false, + openResponsesEnabled: false, + resolvedAuth: {} as never, + getResolvedAuth: () => ({}) as never, + hooksConfig: () => null, + getHookClientIpConfig: () => ({}) as never, + pluginRegistry: startupRegistry, + deps: {} as never, + canvasRuntime: {} as never, + canvasHostEnabled: false, + logCanvas: { info: () => {}, warn: () => {} }, + log: { info: () => {}, warn: () => {} }, + logHooks: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } as never, + logPlugins: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } as never, + }); + + pinActivePluginHttpRouteRegistry(loadedRegistry); + pinActivePluginChannelRegistry(loadedRegistry); + expect(resolveActivePluginHttpRouteRegistry(fallbackRegistry)).toBe(loadedRegistry); + expect(getActivePluginChannelRegistry()).toBe(loadedRegistry); + + runtimeState.releasePluginRouteRegistry(); + + expect(resolveActivePluginHttpRouteRegistry(fallbackRegistry)).toBe(startupRegistry); + expect(getActivePluginChannelRegistry()).toBe(startupRegistry); + }); +}); diff --git a/src/gateway/server-runtime-state.ts b/src/gateway/server-runtime-state.ts index 75b611fa9ff..55eb571a84c 100644 --- a/src/gateway/server-runtime-state.ts +++ b/src/gateway/server-runtime-state.ts @@ -311,7 +311,10 @@ export async function createGatewayRuntimeState(params: { canvasHost, releasePluginRouteRegistry: () => { // Releases both pinned HTTP-route and channel registries set at startup. - releasePinnedPluginHttpRouteRegistry(params.pluginRegistry); + // Release unconditionally: plugin startup/reload can re-pin these + // surfaces to a registry that differs from the original runtime-state + // bootstrap registry. + releasePinnedPluginHttpRouteRegistry(); // Release unconditionally (no registry arg): the channel pin may have // been re-pinned to a deferred-reload registry that differs from the // original params.pluginRegistry, so an identity-guarded release would @@ -339,7 +342,7 @@ export async function createGatewayRuntimeState(params: { toolEventRecipients, }; } catch (err) { - releasePinnedPluginHttpRouteRegistry(params.pluginRegistry); + releasePinnedPluginHttpRouteRegistry(); releasePinnedPluginChannelRegistry(); throw err; } diff --git a/src/gateway/server-startup-early.test.ts b/src/gateway/server-startup-early.test.ts index 06aa7624e46..547851db9ba 100644 --- a/src/gateway/server-startup-early.test.ts +++ b/src/gateway/server-startup-early.test.ts @@ -1,7 +1,27 @@ -import { describe, expect, it } from "vitest"; -import { startGatewayEarlyRuntime } from "./server-startup-early.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getMachineDisplayName: vi.fn(async () => "Test Machine"), + startGatewayDiscovery: vi.fn(async () => ({ bonjourStop: null })), +})); + +vi.mock("../infra/machine-name.js", () => ({ + getMachineDisplayName: mocks.getMachineDisplayName, +})); + +vi.mock("./server-discovery-runtime.js", () => ({ + startGatewayDiscovery: mocks.startGatewayDiscovery, +})); + +import { startGatewayEarlyRuntime, startGatewayPluginDiscovery } from "./server-startup-early.js"; describe("startGatewayEarlyRuntime", () => { + beforeEach(() => { + mocks.getMachineDisplayName.mockClear(); + mocks.startGatewayDiscovery.mockClear(); + mocks.startGatewayDiscovery.mockResolvedValue({ bonjourStop: null }); + }); + it("does not eagerly start the MCP loopback server", async () => { const earlyRuntime = await startGatewayEarlyRuntime({ minimalTestGateway: true, @@ -41,4 +61,41 @@ describe("startGatewayEarlyRuntime", () => { expect(earlyRuntime).not.toHaveProperty("mcpServer"); }); + + it("starts discovery with the current plugin registry services", async () => { + const stop = vi.fn(async () => {}); + mocks.startGatewayDiscovery.mockResolvedValueOnce({ bonjourStop: stop } as never); + const service = { + pluginId: "bonjour", + service: { id: "bonjour", advertise: vi.fn() }, + }; + + await expect( + startGatewayPluginDiscovery({ + minimalTestGateway: false, + cfgAtStart: { discovery: { mdns: { mode: "full" } } } as never, + port: 19_001, + gatewayTls: { enabled: true, fingerprintSha256: "abc123" }, + tailscaleMode: "serve" as never, + logDiscovery: { + info: () => {}, + warn: () => {}, + }, + pluginRegistry: { + gatewayDiscoveryServices: [service], + } as never, + }), + ).resolves.toBe(stop); + + expect(mocks.startGatewayDiscovery).toHaveBeenCalledWith( + expect.objectContaining({ + machineDisplayName: "Test Machine", + port: 19_001, + gatewayTls: { enabled: true, fingerprintSha256: "abc123" }, + tailscaleMode: "serve", + mdnsMode: "full", + gatewayDiscoveryServices: [service], + }), + ); + }); }); diff --git a/src/gateway/server-startup-early.ts b/src/gateway/server-startup-early.ts index 41d7f7f52da..224ea8839ed 100644 --- a/src/gateway/server-startup-early.ts +++ b/src/gateway/server-startup-early.ts @@ -16,6 +16,38 @@ import { import { startGatewayDiscovery } from "./server-discovery-runtime.js"; import { startGatewayMaintenanceTimers } from "./server-maintenance.js"; +export async function startGatewayPluginDiscovery(params: { + minimalTestGateway: boolean; + cfgAtStart: OpenClawConfig; + port: number; + gatewayTls: { enabled: boolean; fingerprintSha256?: string }; + tailscaleMode: GatewayTailscaleMode; + logDiscovery: { + info: (msg: string) => void; + warn: (msg: string) => void; + }; + pluginRegistry?: PluginRegistry; +}): Promise<(() => Promise) | null> { + if (params.minimalTestGateway) { + return null; + } + const machineDisplayName = await getMachineDisplayName(); + const discovery = await startGatewayDiscovery({ + machineDisplayName, + port: params.port, + gatewayTls: params.gatewayTls.enabled + ? { enabled: true, fingerprintSha256: params.gatewayTls.fingerprintSha256 } + : undefined, + wideAreaDiscoveryEnabled: params.cfgAtStart.discovery?.wideArea?.enabled === true, + wideAreaDiscoveryDomain: params.cfgAtStart.discovery?.wideArea?.domain, + tailscaleMode: params.tailscaleMode, + mdnsMode: params.cfgAtStart.discovery?.mdns?.mode, + gatewayDiscoveryServices: params.pluginRegistry?.gatewayDiscoveryServices, + logDiscovery: params.logDiscovery, + }); + return discovery.bonjourStop; +} + export async function startGatewayEarlyRuntime(params: { minimalTestGateway: boolean; cfgAtStart: OpenClawConfig; @@ -59,24 +91,7 @@ export async function startGatewayEarlyRuntime(params: { setSkillsRefreshTimer: (timer: ReturnType | null) => void; getRuntimeConfig: () => OpenClawConfig; }) { - let bonjourStop: (() => Promise) | null = null; - if (!params.minimalTestGateway) { - const machineDisplayName = await getMachineDisplayName(); - const discovery = await startGatewayDiscovery({ - machineDisplayName, - port: params.port, - gatewayTls: params.gatewayTls.enabled - ? { enabled: true, fingerprintSha256: params.gatewayTls.fingerprintSha256 } - : undefined, - wideAreaDiscoveryEnabled: params.cfgAtStart.discovery?.wideArea?.enabled === true, - wideAreaDiscoveryDomain: params.cfgAtStart.discovery?.wideArea?.domain, - tailscaleMode: params.tailscaleMode, - mdnsMode: params.cfgAtStart.discovery?.mdns?.mode, - gatewayDiscoveryServices: params.pluginRegistry?.gatewayDiscoveryServices, - logDiscovery: params.logDiscovery, - }); - bonjourStop = discovery.bonjourStop; - } + const bonjourStop = await startGatewayPluginDiscovery(params); if (!params.minimalTestGateway) { setSkillsRemoteRegistry(params.nodeRegistry); diff --git a/src/gateway/server-startup-plugins.test.ts b/src/gateway/server-startup-plugins.test.ts index 315af780c07..d581ae5824b 100644 --- a/src/gateway/server-startup-plugins.test.ts +++ b/src/gateway/server-startup-plugins.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js"; const applyPluginAutoEnable = vi.hoisted(() => @@ -16,16 +17,33 @@ const loadGatewayStartupPlugins = vi.hoisted(() => gatewayMethods: ["ping"], })), ); +const prepareBundledPluginRuntimeLoadRoot = vi.hoisted(() => vi.fn((params: unknown) => params)); +const registerBundledRuntimeDependencyJitiAliases = vi.hoisted(() => vi.fn()); const pruneUnknownBundledRuntimeDepsRoots = vi.hoisted(() => vi.fn((_params: unknown) => ({ scanned: 0, removed: 0, skippedLocked: 0 })), ); -const repairBundledRuntimeDepsInstallRootAsync = vi.hoisted(() => - vi.fn(async (_params: unknown) => ({})), +const repairBundledRuntimeDepsPackagePlanAsync = vi.hoisted(() => + vi.fn(async (_params: unknown) => ({ repairedSpecs: ["grammy@1.37.0"] })), ); -const resolveBundledRuntimeDependencyPackageInstallRoot = vi.hoisted(() => - vi.fn((_packageRoot: string, _params: unknown) => "/runtime"), +const pluginManifestRegistry = vi.hoisted( + (): PluginManifestRegistry => ({ + plugins: [ + { + id: "telegram", + origin: "bundled", + rootDir: "/package/dist/extensions/telegram", + source: "/package/dist/extensions/telegram/index.js", + manifestPath: "/package/dist/extensions/telegram/package.json", + channels: ["telegram"], + providers: [], + cliBackends: [], + skills: [], + hooks: [], + }, + ], + diagnostics: [], + }), ); -const pluginManifestRegistry = vi.hoisted(() => ({ plugins: [], diagnostics: [] })); const pluginMetadataSnapshot = vi.hoisted( (): PluginMetadataSnapshot => ({ policyHash: "policy", @@ -92,14 +110,6 @@ const runChannelPluginStartupMaintenance = vi.hoisted(() => vi.fn(async (_params: unknown) => undefined), ); const runStartupSessionMigration = vi.hoisted(() => vi.fn(async (_params: unknown) => undefined)); -const scanBundledPluginRuntimeDeps = vi.hoisted(() => - vi.fn((_params: unknown) => ({ - deps: [{ name: "grammy", version: "1.37.0", pluginIds: ["telegram"] }], - missing: [{ name: "grammy", version: "1.37.0", pluginIds: ["telegram"] }], - conflicts: [], - })), -); - vi.mock("../agents/agent-scope.js", () => ({ resolveAgentWorkspaceDir: () => "/workspace", resolveDefaultAgentId: () => "default", @@ -123,13 +133,23 @@ vi.mock("../infra/openclaw-root.js", () => ({ })); vi.mock("../plugins/bundled-runtime-deps.js", () => ({ + repairBundledRuntimeDepsPackagePlanAsync: (params: unknown) => + repairBundledRuntimeDepsPackagePlanAsync(params), +})); + +vi.mock("../plugins/bundled-runtime-deps-roots.js", () => ({ pruneUnknownBundledRuntimeDepsRoots: (params: unknown) => pruneUnknownBundledRuntimeDepsRoots(params), - repairBundledRuntimeDepsInstallRootAsync: (params: unknown) => - repairBundledRuntimeDepsInstallRootAsync(params), - resolveBundledRuntimeDependencyPackageInstallRoot: (packageRoot: string, params: unknown) => - resolveBundledRuntimeDependencyPackageInstallRoot(packageRoot, params), - scanBundledPluginRuntimeDeps: (params: unknown) => scanBundledPluginRuntimeDeps(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", () => ({ @@ -175,13 +195,16 @@ describe("prepareGatewayPluginBootstrap runtime-deps staging", () => { applyPluginAutoEnable.mockClear(); initSubagentRegistry.mockClear(); loadGatewayStartupPlugins.mockClear(); + prepareBundledPluginRuntimeLoadRoot.mockReset().mockImplementation((params: unknown) => params); + registerBundledRuntimeDependencyJitiAliases.mockClear(); pruneUnknownBundledRuntimeDepsRoots.mockClear().mockReturnValue({ scanned: 0, removed: 0, skippedLocked: 0, }); - repairBundledRuntimeDepsInstallRootAsync.mockReset().mockResolvedValue({}); - resolveBundledRuntimeDependencyPackageInstallRoot.mockClear(); + repairBundledRuntimeDepsPackagePlanAsync.mockReset().mockResolvedValue({ + repairedSpecs: ["grammy@1.37.0"], + }); loadPluginLookUpTable.mockClear().mockReturnValue({ manifestRegistry: pluginManifestRegistry, startup: { @@ -193,16 +216,10 @@ describe("prepareGatewayPluginBootstrap runtime-deps staging", () => { resolveOpenClawPackageRootSync.mockClear().mockReturnValue("/package"); runChannelPluginStartupMaintenance.mockClear(); runStartupSessionMigration.mockClear(); - scanBundledPluginRuntimeDeps.mockClear().mockReturnValue({ - deps: [{ name: "grammy", version: "1.37.0", pluginIds: ["telegram"] }], - missing: [{ name: "grammy", version: "1.37.0", pluginIds: ["telegram"] }], - conflicts: [], - }); }); - it("falls back to per-plugin runtime-deps installs after failed pre-start staging", async () => { - const installError = new Error("offline registry"); - repairBundledRuntimeDepsInstallRootAsync.mockRejectedValueOnce(installError); + it("falls back to loader-level runtime-deps staging after failed pre-start staging", async () => { + repairBundledRuntimeDepsPackagePlanAsync.mockRejectedValueOnce(new Error("offline registry")); const log = createLog(); const { prepareGatewayPluginBootstrap } = await import("./server-startup-plugins.js"); @@ -228,31 +245,64 @@ describe("prepareGatewayPluginBootstrap runtime-deps staging", () => { pluginLookUpTable: expect.objectContaining({ manifestRegistry: pluginManifestRegistry, }), + installBundledRuntimeDeps: true, }), ); - expect(scanBundledPluginRuntimeDeps).toHaveBeenCalledWith( + expect(repairBundledRuntimeDepsPackagePlanAsync).toHaveBeenCalledOnce(); + expect(prepareBundledPluginRuntimeLoadRoot).toHaveBeenCalledWith( expect.objectContaining({ - selectedPluginIds: ["telegram"], + pluginId: "telegram", + installMissingDeps: false, + previousRepairError: expect.any(Error), }), ); expect(log.warn).toHaveBeenCalledWith( - expect.stringContaining( - "gateway startup will continue with per-plugin runtime-deps installs", - ), + 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("pre-stages the full startup dependency set", async () => { - scanBundledPluginRuntimeDeps.mockReturnValueOnce({ - deps: [ - { name: "alpha-runtime", version: "1.0.0", pluginIds: ["telegram"] }, - { name: "grammy", version: "1.37.0", pluginIds: ["telegram"] }, - ], - missing: [{ name: "grammy", version: "1.37.0", pluginIds: ["telegram"] }], - conflicts: [], + 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(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: true }), + ); + }); + + 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"); @@ -264,13 +314,39 @@ describe("prepareGatewayPluginBootstrap runtime-deps staging", () => { log, }); - expect(repairBundledRuntimeDepsInstallRootAsync).toHaveBeenCalledWith( + expect(repairBundledRuntimeDepsPackagePlanAsync).toHaveBeenCalledWith( expect.objectContaining({ - installRoot: "/runtime", - missingSpecs: ["alpha-runtime@1.0.0", "grammy@1.37.0"], - installSpecs: ["alpha-runtime@1.0.0", "grammy@1.37.0"], + packageRoot: "/package", + exactPluginIds: ["telegram"], }), ); + expect(loadGatewayStartupPlugins).toHaveBeenCalledWith( + expect.objectContaining({ installBundledRuntimeDeps: true }), + ); + }); + + 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 () => { @@ -415,10 +491,10 @@ describe("prepareGatewayPluginBootstrap runtime-deps staging", () => { ); }); - it("falls back to per-plugin runtime-deps installs after failed pre-start scan", async () => { - scanBundledPluginRuntimeDeps.mockImplementationOnce(() => { - throw new Error("unsupported runtime dependency spec"); - }); + it("falls back to loader-level runtime-deps staging 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"); @@ -437,12 +513,12 @@ describe("prepareGatewayPluginBootstrap runtime-deps staging", () => { }), }); - expect(repairBundledRuntimeDepsInstallRootAsync).not.toHaveBeenCalled(); expect(loadGatewayStartupPlugins).toHaveBeenCalledOnce(); expect(log.warn).toHaveBeenCalledWith( - expect.stringContaining( - "failed to scan bundled runtime deps before gateway startup; gateway startup will continue with per-plugin runtime-deps installs", - ), + expect.stringContaining("unsupported runtime dependency spec"), + ); + expect(loadGatewayStartupPlugins).toHaveBeenCalledWith( + expect.objectContaining({ installBundledRuntimeDeps: true }), ); expect(loadGatewayStartupPlugins.mock.calls[0]?.[0]).not.toHaveProperty( "bundledRuntimeDepsInstaller", @@ -482,8 +558,7 @@ describe("prepareGatewayPluginBootstrap runtime-deps staging", () => { }); expect(loadPluginLookUpTable).not.toHaveBeenCalled(); - expect(scanBundledPluginRuntimeDeps).not.toHaveBeenCalled(); - expect(repairBundledRuntimeDepsInstallRootAsync).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 e2de6cd9659..753b53de100 100644 --- a/src/gateway/server-startup-plugins.ts +++ b/src/gateway/server-startup-plugins.ts @@ -5,12 +5,11 @@ 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 { - pruneUnknownBundledRuntimeDepsRoots, - repairBundledRuntimeDepsInstallRootAsync, - resolveBundledRuntimeDependencyPackageInstallRoot, - scanBundledPluginRuntimeDeps, -} from "../plugins/bundled-runtime-deps.js"; +import { registerBundledRuntimeDependencyJitiAliases } from "../plugins/bundled-runtime-deps-jiti-aliases.js"; +import { 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"; @@ -27,6 +26,10 @@ type GatewayPluginBootstrapLog = { debug: (message: string) => void; }; +type GatewayBundledRuntimeDepsPrestageResult = { + repairError?: unknown; +}; + export function resolveGatewayStartupMaintenanceConfig(params: { cfgAtStart: OpenClawConfig; startupRuntimeConfig: OpenClawConfig; @@ -42,10 +45,11 @@ export function resolveGatewayStartupMaintenanceConfig(params: { async function prestageGatewayBundledRuntimeDeps(params: { cfg: OpenClawConfig; + manifestRegistry: PluginManifestRegistry; pluginIds: readonly string[]; log: GatewayPluginBootstrapLog; -}): Promise { - await measureDiagnosticsTimelineSpan( +}): Promise { + return await measureDiagnosticsTimelineSpan( "runtimeDeps.stage", () => prestageGatewayBundledRuntimeDepsImpl(params), { @@ -60,77 +64,92 @@ async function prestageGatewayBundledRuntimeDeps(params: { async function prestageGatewayBundledRuntimeDepsImpl(params: { cfg: OpenClawConfig; + manifestRegistry: PluginManifestRegistry; pluginIds: readonly string[]; log: GatewayPluginBootstrapLog; -}): Promise { +}): Promise { if (params.pluginIds.length === 0) { - return; + return {}; } - const packageRoot = resolveOpenClawPackageRootSync({ - argv1: process.argv[1], - cwd: process.cwd(), - moduleUrl: import.meta.url, - }); - if (!packageRoot) { - return; - } - const pruned = pruneUnknownBundledRuntimeDepsRoots({ - env: process.env, - warn: (message) => params.log.warn(`[plugins] ${message}`), - }); - if (pruned.removed > 0) { - params.log.info( - `[plugins] pruned stale bundled runtime deps roots (${pruned.removed} removed, ${pruned.skippedLocked} locked, ${pruned.scanned} scanned)`, - ); - } - let scanResult: ReturnType; - try { - scanResult = scanBundledPluginRuntimeDeps({ - packageRoot, - config: params.cfg, - selectedPluginIds: [...params.pluginIds], - env: process.env, - }); - } catch (error) { - params.log.warn( - `[plugins] failed to scan bundled runtime deps before gateway startup; gateway startup will continue with per-plugin runtime-deps installs: ${String(error)}`, - ); - return; - } - const { deps, missing, conflicts } = scanResult; - if (conflicts.length > 0) { - params.log.warn( - `[plugins] bundled runtime deps have version conflicts: ${conflicts.map((conflict) => `${conflict.name} (${conflict.versions.join(", ")})`).join("; ")}`, - ); - } - if (missing.length === 0) { - return; - } - const installSpecs = deps.map((dep) => `${dep.name}@${dep.version}`); - const installRoot = resolveBundledRuntimeDependencyPackageInstallRoot(packageRoot, { - env: process.env, + let repairError: unknown; + const packageRoot = resolveOpenClawPackageRootSync({ moduleUrl: import.meta.url }); + 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, + previousRepairError: repairError, }); + return repairError === undefined ? {} : { repairError }; +} + +function prestageGatewayBundledRuntimeMirrors(params: { + cfg: OpenClawConfig; + manifestRegistry: PluginManifestRegistry; + pluginIds: readonly string[]; + log: GatewayPluginBootstrapLog; + previousRepairError?: unknown; +}): void { + const pluginIdSet = new Set(params.pluginIds); const startedAt = Date.now(); - params.log.info( - `[plugins] staging bundled runtime deps before gateway startup (${installSpecs.length} specs): ${installSpecs.join(", ")}`, - ); - try { - await repairBundledRuntimeDepsInstallRootAsync({ - installRoot, - missingSpecs: installSpecs, - installSpecs, - env: process.env, - warn: (message) => params.log.warn(`[plugins] ${message}`), - }); - } catch (error) { - params.log.warn( - `[plugins] failed to stage bundled runtime deps before gateway startup after ${Date.now() - startedAt}ms; gateway startup will continue with per-plugin runtime-deps installs: ${String(error)}`, - ); - return; + 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: false, + previousRepairError: params.previousRepairError, + memoizePreparedRoot: true, + registerRuntimeAliasRoot: registerBundledRuntimeDependencyJitiAliases, + }); + 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(", ")}`, + ); } - params.log.info( - `[plugins] installed bundled runtime deps before gateway startup in ${Date.now() - startedAt}ms: ${installSpecs.join(", ")}`, - ); } export async function prepareGatewayPluginBootstrap(params: { @@ -140,6 +159,7 @@ export async function prepareGatewayPluginBootstrap(params: { pluginMetadataSnapshot?: PluginMetadataSnapshot; minimalTestGateway: boolean; log: GatewayPluginBootstrapLog; + loadRuntimePlugins?: boolean; }) { const activationSourceConfig = params.activationSourceConfig ?? params.cfgAtStart; const startupMaintenanceConfig = resolveGatewayStartupMaintenanceConfig({ @@ -205,27 +225,26 @@ export async function prepareGatewayPluginBootstrap(params: { const emptyPluginRegistry = createEmptyPluginRegistry(); let pluginRegistry = emptyPluginRegistry; let baseGatewayMethods = baseMethods; + const shouldLoadRuntimePlugins = params.loadRuntimePlugins !== false; - if (!params.minimalTestGateway) { - await prestageGatewayBundledRuntimeDeps({ - cfg: gatewayPluginConfig, - pluginIds: startupPluginIds, - log: params.log, - }); - ({ pluginRegistry, gatewayMethods: baseGatewayMethods } = loadGatewayStartupPlugins({ - cfg: gatewayPluginConfig, - activationSourceConfig, - workspaceDir: defaultWorkspaceDir, - log: params.log, - coreGatewayMethodNames: baseMethods, - baseMethods, - pluginIds: startupPluginIds, - pluginLookUpTable, - preferSetupRuntimeForChannelPlugins: deferredConfiguredChannelPluginIds.length > 0, - suppressPluginInfoLogs: deferredConfiguredChannelPluginIds.length > 0, - })); + if (!params.minimalTestGateway && shouldLoadRuntimePlugins) { + ({ pluginRegistry, gatewayMethods: baseGatewayMethods } = await loadGatewayStartupPluginRuntime( + { + cfg: gatewayPluginConfig, + activationSourceConfig, + workspaceDir: defaultWorkspaceDir, + log: params.log, + baseMethods, + startupPluginIds, + pluginLookUpTable, + preferSetupRuntimeForChannelPlugins: deferredConfiguredChannelPluginIds.length > 0, + suppressPluginInfoLogs: deferredConfiguredChannelPluginIds.length > 0, + }, + )); } else { - pluginRegistry = getActivePluginRegistry() ?? emptyPluginRegistry; + pluginRegistry = params.minimalTestGateway + ? (getActivePluginRegistry() ?? emptyPluginRegistry) + : emptyPluginRegistry; setActivePluginRegistry(pluginRegistry); } @@ -238,5 +257,42 @@ export async function prepareGatewayPluginBootstrap(params: { baseMethods, pluginRegistry, baseGatewayMethods, + runtimePluginsLoaded: !params.minimalTestGateway && shouldLoadRuntimePlugins, }; } + +export async function loadGatewayStartupPluginRuntime(params: { + cfg: OpenClawConfig; + activationSourceConfig?: OpenClawConfig; + workspaceDir: string; + log: GatewayPluginBootstrapLog; + baseMethods: string[]; + startupPluginIds: string[]; + pluginLookUpTable?: ReturnType; + 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, + workspaceDir: params.workspaceDir, + log: params.log, + coreGatewayMethodNames: params.baseMethods, + baseMethods: params.baseMethods, + pluginIds: params.startupPluginIds, + pluginLookUpTable: params.pluginLookUpTable, + installBundledRuntimeDeps: true, + bundledRuntimeDepsRepairError: prestageResult.repairError, + preferSetupRuntimeForChannelPlugins: params.preferSetupRuntimeForChannelPlugins, + suppressPluginInfoLogs: params.suppressPluginInfoLogs, + }); +} diff --git a/src/gateway/server-startup-post-attach.test.ts b/src/gateway/server-startup-post-attach.test.ts index d7c17441c7d..0ead2cc92e1 100644 --- a/src/gateway/server-startup-post-attach.test.ts +++ b/src/gateway/server-startup-post-attach.test.ts @@ -253,6 +253,113 @@ describe("startGatewayPostAttachRuntime", () => { expect(hoisted.startGatewayMemoryBackend).not.toHaveBeenCalled(); }); + it("loads deferred startup plugins before channel sidecars", async () => { + const events: string[] = []; + const loadedPluginRegistry = { + plugins: [{ id: "acpx", status: "loaded" }], + typedHooks: [], + } as never; + const loadStartupPlugins = vi.fn(async () => { + events.push("load-startup-plugins"); + return { + pluginRegistry: loadedPluginRegistry, + gatewayMethods: ["ping", "acp.spawn"], + }; + }); + const onStartupPluginsLoading = vi.fn(() => { + events.push("startup-loading"); + }); + const onStartupPluginsLoaded = vi.fn(() => { + events.push("startup-loaded"); + }); + const startGatewaySidecars = vi.fn(async (params) => { + events.push("sidecars"); + expect(params.pluginRegistry).toBe(loadedPluginRegistry); + return { pluginServices: null }; + }); + + await startGatewayPostAttachRuntime( + { + ...createPostAttachParams({ + pluginRegistry: { + plugins: [], + typedHooks: [], + } as never, + loadStartupPlugins, + onStartupPluginsLoading, + onStartupPluginsLoaded, + }), + }, + createPostAttachRuntimeDeps({ startGatewaySidecars }), + ); + + expect(events).toEqual([ + "startup-loading", + "load-startup-plugins", + "startup-loaded", + "sidecars", + ]); + expect(loadStartupPlugins).toHaveBeenCalledTimes(1); + expect(onStartupPluginsLoaded).toHaveBeenCalledWith({ + pluginRegistry: loadedPluginRegistry, + gatewayMethods: ["ping", "acp.spawn"], + }); + expect(hoisted.logGatewayStartup).toHaveBeenCalledWith( + expect.objectContaining({ loadedPluginIds: ["acpx"] }), + ); + }); + + it("waits for deferred startup plugin attachment before channel sidecars", async () => { + const events: string[] = []; + let finishAttachment!: () => void; + const attachmentFinished = new Promise((resolve) => { + finishAttachment = () => { + events.push("startup-loaded-end"); + resolve(); + }; + }); + const loadedPluginRegistry = { + plugins: [{ id: "acpx", status: "loaded" }], + typedHooks: [], + } as never; + const loadStartupPlugins = vi.fn(async () => ({ + pluginRegistry: loadedPluginRegistry, + gatewayMethods: ["ping", "acp.spawn"], + })); + const onStartupPluginsLoaded = vi.fn(() => { + events.push("startup-loaded-start"); + return attachmentFinished; + }); + const startGatewaySidecars = vi.fn(async () => { + events.push("sidecars"); + return { pluginServices: null }; + }); + + const runtimePromise = startGatewayPostAttachRuntime( + { + ...createPostAttachParams({ + pluginRegistry: { + plugins: [], + typedHooks: [], + } as never, + loadStartupPlugins, + onStartupPluginsLoaded, + }), + }, + createPostAttachRuntimeDeps({ startGatewaySidecars }), + ); + + await vi.waitFor(() => { + expect(events).toEqual(["startup-loaded-start"]); + }); + expect(startGatewaySidecars).not.toHaveBeenCalled(); + + finishAttachment(); + await runtimePromise; + + expect(events).toEqual(["startup-loaded-start", "startup-loaded-end", "sidecars"]); + }); + it("keeps the qmd memory backend lazy by default", async () => { await startGatewayPostAttachRuntime({ ...createPostAttachParams(), diff --git a/src/gateway/server-startup-post-attach.ts b/src/gateway/server-startup-post-attach.ts index 3bf93c7e096..9669dbd40d7 100644 --- a/src/gateway/server-startup-post-attach.ts +++ b/src/gateway/server-startup-post-attach.ts @@ -8,6 +8,7 @@ import type { scheduleGatewayUpdateCheck } from "../infra/update-startup.js"; import type { getGlobalHookRunner } from "../plugins/hook-runner-global.js"; import type { PluginHookGatewayCronService } from "../plugins/hook-types.js"; import type { loadOpenClawPlugins } from "../plugins/loader.js"; +import type { PluginRegistry } from "../plugins/registry.js"; import type { PluginServicesHandle } from "../plugins/services.js"; import { GATEWAY_EVENT_UPDATE_AVAILABLE, @@ -579,6 +580,15 @@ export async function startGatewayPostAttachRuntime( }; logChannels: { info: (msg: string) => void; error: (msg: string) => void }; unavailableGatewayMethods: Set; + loadStartupPlugins?: () => Awaitable<{ + pluginRegistry: PluginRegistry; + gatewayMethods: string[]; + }>; + onStartupPluginsLoading?: () => void; + onStartupPluginsLoaded?: (result: { + pluginRegistry: PluginRegistry; + gatewayMethods: string[]; + }) => Awaitable; getCronService?: () => PluginHookGatewayCronService | null | undefined; onPluginServices?: (pluginServices: PluginServicesHandle | null) => void; onSidecarsReady?: () => void; @@ -595,6 +605,16 @@ export async function startGatewayPostAttachRuntime( } }); + let pluginRegistry = params.pluginRegistry; + if (!params.minimalTestGateway && params.loadStartupPlugins) { + params.onStartupPluginsLoading?.(); + const loaded = await measureStartup(params.startupTrace, "plugins.runtime-post-bind", () => + params.loadStartupPlugins!(), + ); + pluginRegistry = loaded.pluginRegistry; + await params.onStartupPluginsLoaded?.(loaded); + } + await measureStartup(params.startupTrace, "post-attach.log", () => runtimeDeps.logGatewayStartup({ cfg: params.cfgAtStart, @@ -602,7 +622,7 @@ export async function startGatewayPostAttachRuntime( bindHosts: params.bindHosts, port: params.port, tlsEnabled: params.tlsEnabled, - loadedPluginIds: params.pluginRegistry.plugins + loadedPluginIds: pluginRegistry.plugins .filter((plugin) => plugin.status === "loaded") .map((plugin) => plugin.id), log: params.log, @@ -640,13 +660,13 @@ export async function startGatewayPostAttachRuntime( ); const sidecarsPromise = params.minimalTestGateway - ? Promise.resolve({ pluginServices: null }) + ? Promise.resolve({ pluginServices: null, pluginRegistry }) : new Promise((resolve) => setImmediate(resolve)).then(async () => { params.log.info("starting channels and sidecars..."); const result = await measureStartup(params.startupTrace, "sidecars.total", () => runtimeDeps.startGatewaySidecars({ cfg: params.gatewayPluginConfigAtStart, - pluginRegistry: params.pluginRegistry, + pluginRegistry, defaultWorkspaceDir: params.defaultWorkspaceDir, deps: params.deps, startChannels: params.startChannels, @@ -663,15 +683,15 @@ export async function startGatewayPostAttachRuntime( params.onSidecarsReady?.(); params.startupTrace?.mark("sidecars.ready"); params.log.info("gateway ready"); - return result; + return { ...result, pluginRegistry }; }); void sidecarsPromise - .then(async () => { + .then(async (sidecarsResult) => { if (params.minimalTestGateway) { return; } - if (!hasGatewayStartHooks(params.pluginRegistry)) { + if (!hasGatewayStartHooks(sidecarsResult.pluginRegistry)) { return; } await new Promise((resolve) => setImmediate(resolve)); diff --git a/src/gateway/server-startup.ts b/src/gateway/server-startup.ts index 2d925c74746..e8495ad51c3 100644 --- a/src/gateway/server-startup.ts +++ b/src/gateway/server-startup.ts @@ -1,4 +1,4 @@ -export { startGatewayEarlyRuntime } from "./server-startup-early.js"; +export { startGatewayEarlyRuntime, startGatewayPluginDiscovery } from "./server-startup-early.js"; export { __testing, startGatewayPostAttachRuntime, diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index bec904292e8..53a9d5224db 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -42,6 +42,10 @@ import { } from "../plugins/current-plugin-metadata-snapshot.js"; import { runGlobalGatewayStopSafely } from "../plugins/hook-runner-global.js"; import type { PluginHookGatewayCronService } from "../plugins/hook-types.js"; +import { + pinActivePluginChannelRegistry, + pinActivePluginHttpRouteRegistry, +} from "../plugins/runtime.js"; import type { PluginRuntime } from "../plugins/runtime/types.js"; import { getTotalQueueSize } from "../process/command-queue.js"; import type { RuntimeEnv } from "../runtime.js"; @@ -85,9 +89,16 @@ import { loadGatewayStartupConfigSnapshot, prepareGatewayStartupConfig, } from "./server-startup-config.js"; -import { prepareGatewayPluginBootstrap } from "./server-startup-plugins.js"; +import { + loadGatewayStartupPluginRuntime, + prepareGatewayPluginBootstrap, +} from "./server-startup-plugins.js"; import { STARTUP_UNAVAILABLE_GATEWAY_METHODS } from "./server-startup-unavailable-methods.js"; -import { startGatewayEarlyRuntime, startGatewayPostAttachRuntime } from "./server-startup.js"; +import { + startGatewayEarlyRuntime, + startGatewayPluginDiscovery, + startGatewayPostAttachRuntime, +} from "./server-startup.js"; import { createWizardSessionTracker } from "./server-wizard-sessions.js"; import { attachGatewayWsHandlers } from "./server-ws-runtime.js"; import { createGatewayEventLoopHealthMonitor } from "./server/event-loop-health.js"; @@ -487,6 +498,7 @@ export async function startGatewayServer( enqueueSystemEvent(`[${code}] ${message}`, { sessionKey: resolveMainSessionKey(cfg), contextKey: code, + trusted: false, }); }; const activateRuntimeSecrets = createRuntimeSecretsActivator({ @@ -577,6 +589,7 @@ export async function startGatewayServer( pluginMetadataSnapshot: startupConfigLoad.pluginMetadataSnapshot, minimalTestGateway, log, + loadRuntimePlugins: false, }), ); const { @@ -586,6 +599,7 @@ export async function startGatewayServer( startupPluginIds, pluginLookUpTable, baseMethods, + runtimePluginsLoaded, } = pluginBootstrap; setCurrentPluginMetadataSnapshot(pluginLookUpTable, { config: gatewayPluginConfigAtStart }); if (pluginLookUpTable) { @@ -710,6 +724,7 @@ export async function startGatewayServer( const serverStartedAt = Date.now(); const readinessEventLoopHealth = createGatewayEventLoopHealthMonitor(); let startupSidecarsReady = minimalTestGateway; + let startupPendingReason = "startup-sidecars"; const channelManager = createChannelManager({ getRuntimeConfig: () => applyPluginAutoEnable({ @@ -726,6 +741,7 @@ export async function startGatewayServer( channelManager, startedAt: serverStartedAt, getStartupPending: () => !startupSidecarsReady, + getStartupPendingReason: () => startupPendingReason, getEventLoopHealth: readinessEventLoopHealth.snapshot, }); log.info("starting HTTP server..."); @@ -970,6 +986,59 @@ export async function startGatewayServer( stopChannel, logChannels, }); + const attachedGatewayExtraHandlers = { + ...pluginRegistry.gatewayHandlers, + ...extraHandlers, + }; + let attachedPluginGatewayHandlerKeys = new Set(Object.keys(pluginRegistry.gatewayHandlers)); + const replaceAttachedPluginRuntime = (loaded: { + pluginRegistry: typeof pluginRegistry; + gatewayMethods: string[]; + }) => { + pluginRegistry = loaded.pluginRegistry; + baseGatewayMethods = loaded.gatewayMethods; + runtimeState.gatewayMethods.splice( + 0, + runtimeState.gatewayMethods.length, + ...listActiveGatewayMethods(baseGatewayMethods), + ); + for (const key of attachedPluginGatewayHandlerKeys) { + delete attachedGatewayExtraHandlers[key]; + } + Object.assign(attachedGatewayExtraHandlers, pluginRegistry.gatewayHandlers); + attachedPluginGatewayHandlerKeys = new Set(Object.keys(pluginRegistry.gatewayHandlers)); + pinActivePluginHttpRouteRegistry(pluginRegistry); + pinActivePluginChannelRegistry(pluginRegistry); + }; + const refreshAttachedGatewayDiscovery = async (nextPluginRegistry: typeof pluginRegistry) => { + if (minimalTestGateway) { + return; + } + try { + const stopPreviousDiscovery = runtimeState.bonjourStop; + runtimeState.bonjourStop = null; + if (stopPreviousDiscovery) { + try { + await stopPreviousDiscovery(); + } catch (err) { + logDiscovery.warn( + `gateway discovery stop failed before plugin refresh: ${String(err)}`, + ); + } + } + runtimeState.bonjourStop = await startGatewayPluginDiscovery({ + minimalTestGateway, + cfgAtStart, + port, + gatewayTls, + tailscaleMode, + logDiscovery, + pluginRegistry: nextPluginRegistry, + }); + } catch (err) { + logDiscovery.warn(`gateway discovery refresh failed after plugin load: ${String(err)}`); + } + }; const canvasHostServerPort = (canvasHostServer as CanvasHostServer | null)?.port; @@ -1050,9 +1119,9 @@ export async function startGatewayServer( : () => {}; if (!minimalTestGateway) { - if (deferredConfiguredChannelPluginIds.length > 0) { + if (runtimePluginsLoaded && deferredConfiguredChannelPluginIds.length > 0) { const { reloadDeferredGatewayPlugins } = await import("./server-plugin-bootstrap.js"); - ({ pluginRegistry, gatewayMethods: baseGatewayMethods } = reloadDeferredGatewayPlugins({ + const loaded = reloadDeferredGatewayPlugins({ cfg: gatewayPluginConfigAtStart, activationSourceConfig: startupActivationSourceConfig, workspaceDir: defaultWorkspaceDir, @@ -1062,8 +1131,9 @@ export async function startGatewayServer( pluginIds: startupPluginIds, pluginLookUpTable, logDiagnostics: false, - })); - runtimeState.gatewayMethods = listActiveGatewayMethods(baseGatewayMethods); + }); + replaceAttachedPluginRuntime(loaded); + await refreshAttachedGatewayDiscovery(loaded.pluginRegistry); } } @@ -1088,7 +1158,7 @@ export async function startGatewayServer( logGateway: log, logHealth, logWsControl, - extraHandlers: { ...pluginRegistry.gatewayHandlers, ...extraHandlers }, + extraHandlers: attachedGatewayExtraHandlers, broadcast, context: gatewayRequestContext, }); @@ -1123,6 +1193,26 @@ export async function startGatewayServer( logHooks, logChannels, unavailableGatewayMethods, + loadStartupPlugins: runtimePluginsLoaded + ? undefined + : () => + loadGatewayStartupPluginRuntime({ + cfg: gatewayPluginConfigAtStart, + activationSourceConfig: startupActivationSourceConfig, + workspaceDir: defaultWorkspaceDir, + log, + baseMethods, + startupPluginIds, + pluginLookUpTable, + }), + onStartupPluginsLoading: () => { + startupPendingReason = "plugin-runtime-deps"; + }, + onStartupPluginsLoaded: async (loaded) => { + replaceAttachedPluginRuntime(loaded); + startupPendingReason = "startup-sidecars"; + await refreshAttachedGatewayDiscovery(loaded.pluginRegistry); + }, getCronService: () => runtimeState?.cronState.cron as PluginHookGatewayCronService | undefined, onPluginServices: (pluginServices) => { diff --git a/src/gateway/server.reload.test.ts b/src/gateway/server.reload.test.ts index a75f502e91d..bee73bfbc4b 100644 --- a/src/gateway/server.reload.test.ts +++ b/src/gateway/server.reload.test.ts @@ -51,6 +51,15 @@ 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 = { getRuntimeSnapshot: vi.fn(() => ({ @@ -153,6 +162,9 @@ const hoisted = vi.hoisted(() => { stopGmailWatcher, resetModelCatalogCache, disposeAllSessionMcpRuntimes, + pruneUnknownBundledRuntimeDepsRoots, + repairBundledRuntimeDepsPackagePlanAsync, + resolveOpenClawPackageRootSync, providerManager, createChannelManager, startGatewayConfigReloader, @@ -202,6 +214,30 @@ vi.mock("../agents/pi-bundle-mcp-tools.js", async () => { }; }); +vi.mock("../infra/openclaw-root.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveOpenClawPackageRootSync: hoisted.resolveOpenClawPackageRootSync, + }; +}); + +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", @@ -306,6 +342,11 @@ 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(); }); @@ -848,6 +889,57 @@ describe("gateway hot reload", () => { }); }); + 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 bdde7d4418f..519467e3777 100644 --- a/src/gateway/server/readiness.test.ts +++ b/src/gateway/server/readiness.test.ts @@ -64,6 +64,7 @@ function createReadinessHarness(params: { startedAgoMs: number; accounts: Record>; getStartupPending?: () => boolean; + getStartupPendingReason?: Parameters[0]["getStartupPendingReason"]; getEventLoopHealth?: Parameters[0]["getEventLoopHealth"]; cacheTtlMs?: number; }) { @@ -75,6 +76,7 @@ function createReadinessHarness(params: { channelManager: manager, startedAt, getStartupPending: params.getStartupPending, + getStartupPendingReason: params.getStartupPendingReason, getEventLoopHealth: params.getEventLoopHealth, cacheTtlMs: params.cacheTtlMs, }), @@ -107,6 +109,22 @@ describe("createReadinessChecker", () => { }); }); + it("reports the current startup pending reason", () => { + withReadinessClock(() => { + const { readiness } = createReadinessHarness({ + startedAgoMs: 5 * 60_000, + accounts: {}, + getStartupPending: () => true, + getStartupPendingReason: () => "plugin-runtime-deps", + }); + expect(readiness()).toEqual({ + ready: false, + failing: ["plugin-runtime-deps"], + uptimeMs: 300_000, + }); + }); + }); + it("does not cache startup-pending readiness", () => { withReadinessClock(() => { let startupPending = true; diff --git a/src/gateway/server/readiness.ts b/src/gateway/server/readiness.ts index 953002a5ba5..cbdb23de4e2 100644 --- a/src/gateway/server/readiness.ts +++ b/src/gateway/server/readiness.ts @@ -37,6 +37,7 @@ export function createReadinessChecker(deps: { channelManager: ChannelManager; startedAt: number; getStartupPending?: () => boolean; + getStartupPendingReason?: () => string | undefined; getEventLoopHealth?: () => GatewayEventLoopHealth | undefined; cacheTtlMs?: number; }): ReadinessChecker { @@ -49,8 +50,9 @@ export function createReadinessChecker(deps: { const now = Date.now(); const uptimeMs = now - startedAt; if (deps.getStartupPending?.()) { + const reason = deps.getStartupPendingReason?.() ?? "startup-sidecars"; return withEventLoopHealth( - { ready: false, failing: ["startup-sidecars"], uptimeMs }, + { ready: false, failing: [reason], uptimeMs }, deps.getEventLoopHealth, ); } diff --git a/src/infra/channel-summary.ts b/src/infra/channel-summary.ts index e1cf9a073c5..1b90e2801d3 100644 --- a/src/infra/channel-summary.ts +++ b/src/infra/channel-summary.ts @@ -56,7 +56,7 @@ async function listChannelSummaryPlugins(params: { const { listReadOnlyChannelPluginsForConfig } = await import("../channels/plugins/read-only.js"); return listReadOnlyChannelPluginsForConfig(params.cfg, { activationSourceConfig: params.sourceConfig, - includeSetupRuntimeFallback: false, + includeSetupFallbackPlugins: false, }); } diff --git a/src/infra/npm-install-env.ts b/src/infra/npm-install-env.ts index 16861f14e00..7d328c3112a 100644 --- a/src/infra/npm-install-env.ts +++ b/src/infra/npm-install-env.ts @@ -6,8 +6,12 @@ const NPM_CONFIG_KEYS_TO_RESET = new Set([ "npm_config_cache", "npm_config_dry_run", "npm_config_global", + "npm_config_include_workspace_root", + "npm_config_ignore_scripts", "npm_config_location", "npm_config_prefix", + "npm_config_workspace", + "npm_config_workspaces", ]); export function createNpmProjectInstallEnv( diff --git a/src/infra/safe-package-install.test.ts b/src/infra/safe-package-install.test.ts index a994dbbe202..b01458c922f 100644 --- a/src/infra/safe-package-install.test.ts +++ b/src/infra/safe-package-install.test.ts @@ -6,6 +6,7 @@ describe("safe npm install helpers", () => { expect( createSafeNpmInstallArgs({ omitDev: true, + ignoreWorkspaces: true, loglevel: "error", noAudit: true, noFund: true, @@ -15,6 +16,7 @@ describe("safe npm install helpers", () => { "--omit=dev", "--loglevel=error", "--ignore-scripts", + "--workspaces=false", "--no-audit", "--no-fund", ]); @@ -25,12 +27,18 @@ describe("safe npm install helpers", () => { createSafeNpmInstallEnv( { PATH: "/usr/bin:/bin", + NPM_CONFIG_IGNORE_SCRIPTS: "false", npm_config_global: "true", + npm_config_include_workspace_root: "true", + npm_config_ignore_scripts: "false", npm_config_location: "global", npm_config_package_lock: "true", + npm_config_workspace: "extensions/telegram", + npm_config_workspaces: "true", }, { cacheDir: "/tmp/openclaw-npm-cache", + ignoreWorkspaces: true, legacyPeerDeps: true, packageLock: false, quiet: true, @@ -49,12 +57,14 @@ describe("safe npm install helpers", () => { 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_loglevel: "error", npm_config_package_lock: "false", npm_config_progress: "false", npm_config_save: "false", + npm_config_workspaces: "false", npm_config_yes: "true", }); }); diff --git a/src/infra/safe-package-install.ts b/src/infra/safe-package-install.ts index 8481be0f64c..bc82d418c18 100644 --- a/src/infra/safe-package-install.ts +++ b/src/infra/safe-package-install.ts @@ -2,12 +2,14 @@ import type { NpmProjectInstallEnvOptions } from "./npm-install-env.js"; import { createNpmProjectInstallEnv } from "./npm-install-env.js"; export type SafeNpmInstallEnvOptions = NpmProjectInstallEnvOptions & { + ignoreWorkspaces?: boolean; legacyPeerDeps?: boolean; packageLock?: boolean; quiet?: boolean; }; export type SafeNpmInstallArgsOptions = { + ignoreWorkspaces?: boolean; loglevel?: "error" | "silent"; noAudit?: boolean; noFund?: boolean; @@ -24,7 +26,9 @@ export function createSafeNpmInstallEnv( NPM_CONFIG_IGNORE_SCRIPTS: "true", npm_config_audit: "false", npm_config_fund: "false", + npm_config_ignore_scripts: "true", npm_config_package_lock: options.packageLock === true ? "true" : "false", + ...(options.ignoreWorkspaces ? { npm_config_workspaces: "false" } : {}), ...(options.legacyPeerDeps ? { npm_config_legacy_peer_deps: "true" } : {}), }; if (options.quiet) { @@ -43,6 +47,7 @@ export function createSafeNpmInstallArgs(options: SafeNpmInstallArgsOptions = {} ...(options.omitDev ? ["--omit=dev"] : []), ...(options.loglevel ? [`--loglevel=${options.loglevel}`] : []), "--ignore-scripts", + ...(options.ignoreWorkspaces ? ["--workspaces=false"] : []), ...(options.noAudit ? ["--no-audit"] : []), ...(options.noFund ? ["--no-fund"] : []), ]; diff --git a/src/plugin-sdk/facade-loader.test.ts b/src/plugin-sdk/facade-loader.test.ts index c82f81c4bc7..21271b0fa04 100644 --- a/src/plugin-sdk/facade-loader.test.ts +++ b/src/plugin-sdk/facade-loader.test.ts @@ -2,10 +2,10 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveBundledRuntimeDependencyInstallRoot } from "../plugins/bundled-runtime-deps-roots.js"; import { clearBundledRuntimeDependencyNodePaths, ensureBundledPluginRuntimeDeps, - resolveBundledRuntimeDependencyInstallRoot, } from "../plugins/bundled-runtime-deps.js"; import { shouldExpectNativeJitiForJavaScriptTestRuntime } from "../test-utils/jiti-runtime.js"; import { diff --git a/src/plugins/bundled-runtime-deps-install.ts b/src/plugins/bundled-runtime-deps-install.ts index 6b0916441ad..8430be528f0 100644 --- a/src/plugins/bundled-runtime-deps-install.ts +++ b/src/plugins/bundled-runtime-deps-install.ts @@ -6,7 +6,6 @@ import { sanitizeTerminalText } from "../terminal/safe-text.js"; import { beginBundledRuntimeDepsInstall } from "./bundled-runtime-deps-activity.js"; import { BUNDLED_RUNTIME_DEPS_LOCK_DIR, - withBundledRuntimeDepsFilesystemLock, withBundledRuntimeDepsFilesystemLockAsync, } from "./bundled-runtime-deps-lock.js"; import { @@ -14,6 +13,7 @@ import { ensureNpmInstallExecutionManifest, isRuntimeDepsPlanMaterialized, removeLegacyRuntimeDepsManifest, + removeRuntimeDepsNodeModulesSymlink, } from "./bundled-runtime-deps-materialization.js"; import { createBundledRuntimeDepsInstallArgs, @@ -34,10 +34,6 @@ export type BundledRuntimeDepsInstallParams = { warn?: (message: string) => void; }; -function withBundledRuntimeDepsInstallRootLock(installRoot: string, run: () => T): T { - return withBundledRuntimeDepsFilesystemLock(installRoot, BUNDLED_RUNTIME_DEPS_LOCK_DIR, run); -} - async function withBundledRuntimeDepsInstallRootLockAsync( installRoot: string, run: () => Promise, @@ -164,6 +160,9 @@ function createBundledRuntimeDepsInstallContext(params: { const installEnv = createBundledRuntimeDepsInstallEnv(params.env, { cacheDir: path.join(installExecutionRoot, ".openclaw-npm-cache"), }); + if (!isolatedExecutionRoot) { + removeRuntimeDepsNodeModulesSymlink(params.installRoot); + } const runner = resolveBundledRuntimeDepsPackageManagerRunner({ installExecutionRoot, env: installEnv, @@ -285,12 +284,13 @@ export function installBundledRuntimeDeps(params: { 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 (isRuntimeDepsPlanMaterialized(params.installRoot, installSpecs)) { + if (!params.force && isRuntimeDepsPlanMaterialized(params.installRoot, installSpecs)) { removeLegacyRuntimeDepsManifest(params.installRoot); return; } @@ -326,12 +326,13 @@ export async function installBundledRuntimeDepsAsync(params: { 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 (isRuntimeDepsPlanMaterialized(params.installRoot, installSpecs)) { + if (!params.force && isRuntimeDepsPlanMaterialized(params.installRoot, installSpecs)) { removeLegacyRuntimeDepsManifest(params.installRoot); return; } @@ -360,46 +361,6 @@ export async function installBundledRuntimeDepsAsync(params: { } } -export function repairBundledRuntimeDepsInstallRoot(params: { - installRoot: string; - missingSpecs: string[]; - installSpecs: string[]; - env: NodeJS.ProcessEnv; - installDeps?: (params: BundledRuntimeDepsInstallParams) => void; - warn?: (message: string) => void; -}): { installSpecs: string[] } { - return withBundledRuntimeDepsInstallRootLock(params.installRoot, () => { - const installSpecs = normalizeRuntimeDepSpecs(params.installSpecs); - const install = - params.installDeps ?? - ((installParams) => - installBundledRuntimeDeps({ - installRoot: installParams.installRoot, - missingSpecs: installParams.missingSpecs, - installSpecs: installParams.installSpecs, - env: params.env, - warn: params.warn, - })); - const finishActivity = beginBundledRuntimeDepsInstall({ - installRoot: params.installRoot, - missingSpecs: installSpecs, - installSpecs, - }); - ensureNpmInstallExecutionManifest(params.installRoot, installSpecs); - try { - install({ - installRoot: params.installRoot, - missingSpecs: installSpecs, - installSpecs, - }); - } finally { - finishActivity(); - } - removeLegacyRuntimeDepsManifest(params.installRoot); - return { installSpecs }; - }); -} - export async function repairBundledRuntimeDepsInstallRootAsync(params: { installRoot: string; missingSpecs: string[]; @@ -421,6 +382,7 @@ export async function repairBundledRuntimeDepsInstallRootAsync(params: { env: params.env, warn: params.warn, onProgress: params.onProgress, + force: true, })); const finishActivity = beginBundledRuntimeDepsInstall({ installRoot: params.installRoot, diff --git a/src/plugins/bundled-runtime-deps-lock.ts b/src/plugins/bundled-runtime-deps-lock.ts index b7f866f39ee..8244d210347 100644 --- a/src/plugins/bundled-runtime-deps-lock.ts +++ b/src/plugins/bundled-runtime-deps-lock.ts @@ -120,6 +120,13 @@ export function shouldRemoveRuntimeDepsLock( 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; } diff --git a/src/plugins/bundled-runtime-deps-materialization.ts b/src/plugins/bundled-runtime-deps-materialization.ts index 6976b86a394..3a12046f0c7 100644 --- a/src/plugins/bundled-runtime-deps-materialization.ts +++ b/src/plugins/bundled-runtime-deps-materialization.ts @@ -12,7 +12,7 @@ import { satisfies } from "./semver.runtime.js"; const LEGACY_RETAINED_RUNTIME_DEPS_MANIFEST = ".openclaw-runtime-deps.json"; -export function readGeneratedInstallManifestSpecs(installRoot: string): string[] | null { +function readGeneratedInstallManifestSpecs(installRoot: string): string[] | null { const parsed = readRuntimeDepsJsonObject(path.join(installRoot, "package.json")); if (parsed?.name !== "openclaw-runtime-deps-install") { return null; @@ -43,21 +43,12 @@ function readPackageRuntimeDepSpecs(packageRoot: string): string[] | null { return normalizeRuntimeDepSpecs(specs); } -function sameRuntimeDepSpecs(left: readonly string[], right: readonly string[]): boolean { - const normalizedLeft = normalizeRuntimeDepSpecs(left); - const normalizedRight = normalizeRuntimeDepSpecs(right); - return ( - normalizedLeft.length === normalizedRight.length && - normalizedLeft.every((entry, index) => entry === normalizedRight[index]) - ); -} - function runtimeDepSpecsIncludeAll( - candidate: readonly string[], - required: readonly string[], + availableSpecs: readonly string[], + requestedSpecs: readonly string[], ): boolean { - const candidateSet = new Set(normalizeRuntimeDepSpecs(candidate)); - return normalizeRuntimeDepSpecs(required).every((spec) => candidateSet.has(spec)); + const available = new Set(normalizeRuntimeDepSpecs(availableSpecs)); + return normalizeRuntimeDepSpecs(requestedSpecs).every((spec) => available.has(spec)); } function readInstalledRuntimeDepPackage( @@ -76,32 +67,163 @@ function readInstalledRuntimeDepPackage( } } -function hasInstalledRuntimeDepEntryFiles(packageDir: string, packageJson: JsonObject): boolean { - const main = packageJson.main; - if (typeof main !== "string" || main.trim() === "") { +function hasRuntimeDepEntryFile(packageDir: string, rawEntry: string): boolean { + const entry = rawEntry.trim(); + if (entry === "") { return true; } - const mainPath = path.resolve(packageDir, main); - if (mainPath !== packageDir && !mainPath.startsWith(`${packageDir}${path.sep}`)) { + const entryPath = path.resolve(packageDir, entry); + if (entryPath !== packageDir && !entryPath.startsWith(`${packageDir}${path.sep}`)) { return false; } - if (fs.existsSync(mainPath)) { - return true; + 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(`${mainPath}.js`) || - fs.existsSync(`${mainPath}.json`) || - fs.existsSync(`${mainPath}.node`) || - fs.existsSync(path.join(mainPath, "index.js")) || - fs.existsSync(path.join(mainPath, "index.json")) || - fs.existsSync(path.join(mainPath, "index.node")) + 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")) ); } -export function isRuntimeDepSatisfied( - rootDir: string, - dep: { name: string; version: string }, -): boolean { +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 (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 isRuntimeDepSatisfied(rootDir: string, dep: { name: string; version: string }): boolean { const installed = readInstalledRuntimeDepPackage(rootDir, dep.name); if (!installed) { return false; @@ -138,7 +260,8 @@ export function isRuntimeDepsPlanMaterialized( return ( ((generatedManifestSpecs !== null && runtimeDepSpecsIncludeAll(generatedManifestSpecs, installSpecs)) || - (packageManifestSpecs !== null && sameRuntimeDepSpecs(packageManifestSpecs, installSpecs))) && + (packageManifestSpecs !== null && + runtimeDepSpecsIncludeAll(packageManifestSpecs, installSpecs))) && hasSatisfiedInstallSpecPackages(installRoot, installSpecs) ); } @@ -162,6 +285,54 @@ export function removeLegacyRuntimeDepsManifest(installRoot: string): void { }); } +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) { diff --git a/src/plugins/bundled-runtime-deps-package-manager.ts b/src/plugins/bundled-runtime-deps-package-manager.ts index a5e64d83b20..ecc9c99c033 100644 --- a/src/plugins/bundled-runtime-deps-package-manager.ts +++ b/src/plugins/bundled-runtime-deps-package-manager.ts @@ -26,6 +26,7 @@ export function createBundledRuntimeDepsInstallEnv( const nextEnv: NodeJS.ProcessEnv = { ...createSafeNpmInstallEnv(env, { ...options, + ignoreWorkspaces: true, legacyPeerDeps: true, packageLock: true, }), @@ -41,7 +42,12 @@ export function createBundledRuntimeDepsInstallEnv( } export function createBundledRuntimeDepsInstallArgs(): string[] { - return [...createSafeNpmInstallArgs({ noAudit: true, noFund: true }), "--omit=dev"]; + return createSafeNpmInstallArgs({ + ignoreWorkspaces: true, + noAudit: true, + noFund: true, + omitDev: true, + }); } function createBundledRuntimeDepsPnpmInstallArgs(params: { storeDir: string }): string[] { diff --git a/src/plugins/bundled-runtime-deps-roots.ts b/src/plugins/bundled-runtime-deps-roots.ts index f21dd222ccb..f9187864ea1 100644 --- a/src/plugins/bundled-runtime-deps-roots.ts +++ b/src/plugins/bundled-runtime-deps-roots.ts @@ -12,13 +12,11 @@ import { 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; -export type BundledRuntimeDepsInstallRoot = { +export type BundledRuntimeDepsInstallRootPlan = { installRoot: string; external: boolean; -}; - -export type BundledRuntimeDepsInstallRootPlan = BundledRuntimeDepsInstallRoot & { searchRoots: string[]; }; @@ -191,11 +189,66 @@ export function pruneUnknownBundledRuntimeDepsRoots( return { scanned, removed, skippedLocked }; } -function resolveExternalBundledRuntimeDepsInstallRoot(params: { - pluginRoot: string; - env: NodeJS.ProcessEnv; -}): string { - return resolveExternalBundledRuntimeDepsInstallRoots(params).at(-1)!; +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); +} + +function readPackageKeyPathHash(packageKey: string): string | null { + return PACKAGE_KEY_PATH_HASH_RE.exec(packageKey)?.[1] ?? null; } function resolveExternalBundledRuntimeDepsInstallRoots(params: { @@ -283,12 +336,7 @@ export function resolveBundledRuntimeDependencyPackageInstallRootPlan( !isSourceCheckoutRoot(packageRoot) ) { return createBundledRuntimeDepsInstallRootPlan({ - installRoot: - externalRoots.at(-1) ?? - resolveExternalBundledRuntimeDepsInstallRoot({ - pluginRoot: path.join(packageRoot, "dist", "extensions", "__package__"), - env, - }), + installRoot: externalRoots.at(-1)!, searchRoots: externalRoots, external: true, }); @@ -301,12 +349,7 @@ export function resolveBundledRuntimeDependencyPackageInstallRootPlan( }); } return createBundledRuntimeDepsInstallRootPlan({ - installRoot: - externalRoots.at(-1) ?? - resolveExternalBundledRuntimeDepsInstallRoot({ - pluginRoot: path.join(packageRoot, "dist", "extensions", "__package__"), - env, - }), + installRoot: externalRoots.at(-1)!, searchRoots: externalRoots, external: true, }); @@ -332,12 +375,7 @@ export function resolveBundledRuntimeDependencyInstallRootPlan( isPackagedBundledPluginRoot(pluginRoot) ) { return createBundledRuntimeDepsInstallRootPlan({ - installRoot: - externalRoots.at(-1) ?? - resolveExternalBundledRuntimeDepsInstallRoot({ - pluginRoot, - env, - }), + installRoot: externalRoots.at(-1)!, searchRoots: externalRoots, external: true, }); @@ -350,12 +388,7 @@ export function resolveBundledRuntimeDependencyInstallRootPlan( }); } return createBundledRuntimeDepsInstallRootPlan({ - installRoot: - externalRoots.at(-1) ?? - resolveExternalBundledRuntimeDepsInstallRoot({ - pluginRoot, - env, - }), + installRoot: externalRoots.at(-1)!, searchRoots: externalRoots, external: true, }); @@ -367,17 +400,3 @@ export function resolveBundledRuntimeDependencyInstallRoot( ): string { return resolveBundledRuntimeDependencyInstallRootPlan(pluginRoot, options).installRoot; } - -export function resolveBundledRuntimeDependencyInstallRootInfo( - pluginRoot: string, - options: { env?: NodeJS.ProcessEnv; forceExternal?: boolean } = {}, -): BundledRuntimeDepsInstallRoot { - const { installRoot, external } = resolveBundledRuntimeDependencyInstallRootPlan( - pluginRoot, - options, - ); - return { - installRoot, - external, - }; -} diff --git a/src/plugins/bundled-runtime-deps-selection.ts b/src/plugins/bundled-runtime-deps-selection.ts index 0e541ddd500..297676eff0c 100644 --- a/src/plugins/bundled-runtime-deps-selection.ts +++ b/src/plugins/bundled-runtime-deps-selection.ts @@ -595,16 +595,16 @@ function shouldIncludeBundledPluginRuntimeDeps(params: { config?: OpenClawConfig; plugins?: NormalizedPluginsConfig; pluginIds?: ReadonlySet; - selectedPluginIds?: ReadonlySet; + exactPluginIds?: ReadonlySet; pluginId: string; pluginDir: string; configuredModelOwnerPluginIds?: ReadonlySet; includeConfiguredChannels?: boolean; manifestCache?: BundledPluginRuntimeDepsManifestCache; }): boolean { - if (params.selectedPluginIds) { + if (params.exactPluginIds) { return ( - params.selectedPluginIds.has(params.pluginId) && + params.exactPluginIds.has(params.pluginId) && !( params.config && params.plugins && @@ -658,7 +658,7 @@ export function collectBundledPluginRuntimeDeps(params: { extensionsDir: string; config?: OpenClawConfig; pluginIds?: ReadonlySet; - selectedPluginIds?: ReadonlySet; + exactPluginIds?: ReadonlySet; includeConfiguredChannels?: boolean; manifestCache?: BundledPluginRuntimeDepsManifestCache; normalizePluginId?: NormalizePluginId; @@ -702,7 +702,7 @@ export function collectBundledPluginRuntimeDeps(params: { config: params.config, plugins, pluginIds: params.pluginIds, - selectedPluginIds: params.selectedPluginIds, + exactPluginIds: params.exactPluginIds, pluginId, pluginDir, configuredModelOwnerPluginIds, diff --git a/src/plugins/bundled-runtime-deps.test.ts b/src/plugins/bundled-runtime-deps.test.ts index a06fc88a2ba..bcc28cff32f 100644 --- a/src/plugins/bundled-runtime-deps.test.ts +++ b/src/plugins/bundled-runtime-deps.test.ts @@ -11,26 +11,40 @@ import { waitForBundledRuntimeDepsInstallIdle, } from "./bundled-runtime-deps-activity.js"; import { - assertBundledRuntimeDepsInstalled, - ensureNpmInstallExecutionManifest, -} from "./bundled-runtime-deps-materialization.js"; -import { - __testing as bundledRuntimeDepsTesting, - createBundledRuntimeDependencyAliasMap, - createBundledRuntimeDepsInstallArgs, - createBundledRuntimeDepsInstallEnv, - ensureBundledPluginRuntimeDeps, 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, - repairBundledRuntimeDepsInstallRootAsync, - resolveBundledRuntimeDependencyPackageInstallRoot, resolveBundledRuntimeDependencyInstallRoot, resolveBundledRuntimeDependencyInstallRootPlan, - resolveBundledRuntimeDepsNpmRunner, - scanBundledPluginRuntimeDeps, - type BundledRuntimeDepsInstallParams, + resolveBundledRuntimeDependencyPackageInstallRoot, +} from "./bundled-runtime-deps-roots.js"; +import { + BundledRuntimeDepsMissingError, + createBundledRuntimeDependencyAliasMap, + createBundledRuntimeDepsPackagePlan, + ensureBundledPluginRuntimeDeps, + repairBundledRuntimeDepsPackagePlanAsync, } from "./bundled-runtime-deps.js"; import { writeBundledPluginRuntimeDepsPackage as writeBundledPluginPackage, @@ -105,10 +119,11 @@ describe("resolveBundledRuntimeDepsNpmRunner", () => { 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", - "--omit=dev", ]); expect( createBundledRuntimeDepsInstallEnv( @@ -116,13 +131,18 @@ describe("resolveBundledRuntimeDepsNpmRunner", () => { 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", }, @@ -141,10 +161,12 @@ describe("resolveBundledRuntimeDepsNpmRunner", () => { 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", }); }); @@ -223,7 +245,7 @@ describe("resolveBundledRuntimeDepsNpmRunner", () => { const pnpmCmdPath = "C:\\Program Files\\nodejs\\pnpm.cmd"; expect( - bundledRuntimeDepsTesting.resolveBundledRuntimeDepsPnpmRunner({ + resolveBundledRuntimeDepsPnpmRunner({ env: {}, execPath, existsSync: (candidate) => candidate === pnpmCmdPath, @@ -238,7 +260,7 @@ describe("resolveBundledRuntimeDepsNpmRunner", () => { const pnpmExePath = "C:\\Program Files\\nodejs\\pnpm.exe"; expect( - bundledRuntimeDepsTesting.resolveBundledRuntimeDepsPnpmRunner({ + resolveBundledRuntimeDepsPnpmRunner({ env: {}, execPath, existsSync: (candidate) => candidate === pnpmExePath, @@ -322,15 +344,25 @@ describe("installBundledRuntimeDeps", () => { expect(spawnSyncMock).toHaveBeenCalledWith( expect.any(String), - [safeNpmCliPath, "install", "--ignore-scripts", "--no-audit", "--no-fund", "--omit=dev"], + [ + 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", }), }), ); @@ -395,6 +427,44 @@ describe("installBundledRuntimeDeps", () => { ); }); + 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) => { @@ -425,6 +495,30 @@ describe("installBundledRuntimeDeps", () => { ); }); + 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[] = []; @@ -716,11 +810,7 @@ describe("installBundledRuntimeDeps", () => { const installExecutionRoot = makeTempDir(); spawnSyncMock.mockImplementation((_command, _args, options) => { const cwd = String(options?.cwd ?? ""); - fs.mkdirSync(path.join(cwd, "node_modules", "tokenjuice"), { recursive: true }); - fs.writeFileSync( - path.join(cwd, "node_modules", "tokenjuice", "package.json"), - JSON.stringify({ name: "tokenjuice", version: "0.6.1" }), - ); + writeInstalledPackage(cwd, "tokenjuice", "0.6.1"); return { pid: 123, output: [], @@ -902,7 +992,7 @@ describe("installBundledRuntimeDeps", () => { ); }); - it("accepts extensionless package main entries resolved by Node", () => { + 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"); @@ -934,11 +1024,7 @@ describe("installBundledRuntimeDeps", () => { const installExecutionRoot = path.join(installRoot, ".openclaw-install-stage"); spawnSyncMock.mockImplementation((_command, _args, options) => { const cwd = String(options?.cwd ?? ""); - fs.mkdirSync(path.join(cwd, "node_modules", "tokenjuice"), { recursive: true }); - fs.writeFileSync( - path.join(cwd, "node_modules", "tokenjuice", "package.json"), - JSON.stringify({ name: "tokenjuice", version: "0.6.1" }), - ); + writeInstalledPackage(cwd, "tokenjuice", "0.6.1"); return { pid: 123, output: [], @@ -989,11 +1075,7 @@ describe("installBundledRuntimeDeps", () => { }); spawnSyncMock.mockImplementation((_command, _args, options) => { const cwd = String(options?.cwd ?? ""); - fs.mkdirSync(path.join(cwd, "node_modules", "tokenjuice"), { recursive: true }); - fs.writeFileSync( - path.join(cwd, "node_modules", "tokenjuice", "package.json"), - JSON.stringify({ name: "tokenjuice", version: "0.6.1" }), - ); + writeInstalledPackage(cwd, "tokenjuice", "0.6.1"); return { pid: 123, output: [], @@ -1058,10 +1140,10 @@ describe("installBundledRuntimeDeps", () => { }); }); -describe("scanBundledPluginRuntimeDeps config policy", () => { +describe("createBundledRuntimeDepsPackagePlan config policy", () => { type RuntimeDepsConfigCase = { name: string; - config: Parameters[0]["config"]; + config: Parameters[0]["config"]; includeConfiguredChannels: boolean; expectedDeps: string[]; }; @@ -1250,7 +1332,7 @@ describe("scanBundledPluginRuntimeDeps config policy", () => { ]; it.each(cases)("$name", ({ config, includeConfiguredChannels, expectedDeps }) => { - const result = scanBundledPluginRuntimeDeps({ + const result = createBundledRuntimeDepsPackagePlan({ packageRoot: setupPolicyPackageRoot(), config, includeConfiguredChannels, @@ -1263,7 +1345,7 @@ describe("scanBundledPluginRuntimeDeps config policy", () => { it("honors deny and disabled entries when scanning an explicit effective plugin set", () => { const packageRoot = setupPolicyPackageRoot(); - const denied = scanBundledPluginRuntimeDeps({ + const denied = createBundledRuntimeDepsPackagePlan({ packageRoot, pluginIds: ["telegram"], config: { @@ -1271,7 +1353,7 @@ describe("scanBundledPluginRuntimeDeps config policy", () => { channels: { telegram: { enabled: true } }, }, }); - const disabled = scanBundledPluginRuntimeDeps({ + const disabled = createBundledRuntimeDepsPackagePlan({ packageRoot, pluginIds: ["telegram"], config: { @@ -1279,7 +1361,7 @@ describe("scanBundledPluginRuntimeDeps config policy", () => { channels: { telegram: { enabled: true } }, }, }); - const allowed = scanBundledPluginRuntimeDeps({ + const allowed = createBundledRuntimeDepsPackagePlan({ packageRoot, pluginIds: ["telegram"], config: { @@ -1296,9 +1378,9 @@ describe("scanBundledPluginRuntimeDeps config policy", () => { }); it("trusts preselected startup plugin ids without reapplying config policy", () => { - const result = scanBundledPluginRuntimeDeps({ + const result = createBundledRuntimeDepsPackagePlan({ packageRoot: setupPolicyPackageRoot(), - selectedPluginIds: ["telegram"], + exactPluginIds: ["telegram"], config: { plugins: { allow: ["browser"] }, channels: { telegram: { botToken: "123:abc" } }, @@ -1312,9 +1394,9 @@ describe("scanBundledPluginRuntimeDeps config policy", () => { }); it("does not stage explicitly disabled preselected channel deps", () => { - const result = scanBundledPluginRuntimeDeps({ + const result = createBundledRuntimeDepsPackagePlan({ packageRoot: setupPolicyPackageRoot(), - selectedPluginIds: ["telegram"], + exactPluginIds: ["telegram"], config: { plugins: { allow: ["telegram"] }, channels: { telegram: { enabled: false, botToken: "123:abc" } }, @@ -1331,8 +1413,9 @@ describe("scanBundledPluginRuntimeDeps config policy", () => { 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 = scanBundledPluginRuntimeDeps({ + const result = createBundledRuntimeDepsPackagePlan({ packageRoot, config: {}, env, @@ -1343,7 +1426,7 @@ describe("scanBundledPluginRuntimeDeps config policy", () => { expect(result.conflicts).toEqual([]); }); - it("accepts staged runtime deps whose package main relies on Node extension resolution", () => { + 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 }); @@ -1357,13 +1440,291 @@ describe("scanBundledPluginRuntimeDeps config policy", () => { 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 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 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 = scanBundledPluginRuntimeDeps({ + const result = createBundledRuntimeDepsPackagePlan({ packageRoot, config: {}, env, @@ -1375,6 +1736,227 @@ describe("scanBundledPluginRuntimeDeps config policy", () => { 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(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); + }); + + 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); + }); + + 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({ @@ -1387,7 +1969,7 @@ describe("scanBundledPluginRuntimeDeps config policy", () => { const manifestPath = path.join(pluginRoot, "openclaw.plugin.json"); const readFileSyncSpy = vi.spyOn(fs, "readFileSync"); - scanBundledPluginRuntimeDeps({ packageRoot, config: {} }); + createBundledRuntimeDepsPackagePlan({ packageRoot, config: {} }); expect( readFileSyncSpy.mock.calls.filter((call) => path.resolve(String(call[0])) === manifestPath), @@ -1417,7 +1999,7 @@ describe("scanBundledPluginRuntimeDeps config policy", () => { enabledByDefault: true, }); - const result = scanBundledPluginRuntimeDeps({ + const result = createBundledRuntimeDepsPackagePlan({ packageRoot, config: {}, env: { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }, @@ -1458,7 +2040,7 @@ describe("scanBundledPluginRuntimeDeps config policy", () => { `import chokidar from "chokidar";\n`, ); - const result = scanBundledPluginRuntimeDeps({ + const result = createBundledRuntimeDepsPackagePlan({ packageRoot, config: {}, env: { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }, @@ -1497,9 +2079,9 @@ describe("scanBundledPluginRuntimeDeps config policy", () => { `import chokidar from "chokidar";\n`, ); - const result = scanBundledPluginRuntimeDeps({ + const result = createBundledRuntimeDepsPackagePlan({ packageRoot, - selectedPluginIds: ["slack"], + exactPluginIds: ["slack"], config: { channels: { slack: { botToken: "xoxb-token" } }, }, @@ -1558,9 +2140,9 @@ describe("scanBundledPluginRuntimeDeps config policy", () => { 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 = scanBundledPluginRuntimeDeps({ + const result = createBundledRuntimeDepsPackagePlan({ packageRoot, - selectedPluginIds: ["whatsapp"], + exactPluginIds: ["whatsapp"], config: { channels: { whatsapp: { enabled: true } }, }, @@ -1610,9 +2192,9 @@ describe("scanBundledPluginRuntimeDeps config policy", () => { channels: ["slack"], }); - const result = scanBundledPluginRuntimeDeps({ + const result = createBundledRuntimeDepsPackagePlan({ packageRoot, - selectedPluginIds: ["slack"], + exactPluginIds: ["slack"], config: { channels: { slack: { botToken: "xoxb-token" } }, }, @@ -1653,7 +2235,7 @@ describe("scanBundledPluginRuntimeDeps config policy", () => { enabledByDefault: true, }); - const result = scanBundledPluginRuntimeDeps({ + const result = createBundledRuntimeDepsPackagePlan({ packageRoot, config: {}, env: { OPENCLAW_PLUGIN_STAGE_DIR: stageDir }, @@ -1693,7 +2275,7 @@ describe("scanBundledPluginRuntimeDeps config policy", () => { "@mariozechner/pi-ai@0.70.5", ]); - const result = scanBundledPluginRuntimeDeps({ + const result = createBundledRuntimeDepsPackagePlan({ packageRoot, config: {}, env, @@ -1734,7 +2316,7 @@ describe("scanBundledPluginRuntimeDeps config policy", () => { "7.15.1", ); - const result = scanBundledPluginRuntimeDeps({ + const result = createBundledRuntimeDepsPackagePlan({ packageRoot, config: {}, env, @@ -1802,6 +2384,49 @@ describe("ensureBundledPluginRuntimeDeps", () => { 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"); @@ -1956,13 +2581,7 @@ describe("ensureBundledPluginRuntimeDeps", () => { env, installDeps: (params) => { calls.push(params); - fs.mkdirSync(path.join(params.installRoot, "node_modules", "@slack", "web-api"), { - recursive: true, - }); - fs.writeFileSync( - path.join(params.installRoot, "node_modules", "@slack", "web-api", "package.json"), - JSON.stringify({ name: "@slack/web-api", version: "7.15.1" }), - ); + writeInstalledPackage(params.installRoot, "@slack/web-api", "7.15.1"); }, pluginId: "slack", pluginRoot, @@ -1996,6 +2615,46 @@ describe("ensureBundledPluginRuntimeDeps", () => { 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(); @@ -2078,11 +2737,7 @@ describe("ensureBundledPluginRuntimeDeps", () => { calls.push(params); for (const spec of params.installSpecs ?? params.missingSpecs) { const name = spec.slice(0, spec.lastIndexOf("@")); - fs.mkdirSync(path.join(params.installRoot, "node_modules", name), { recursive: true }); - fs.writeFileSync( - path.join(params.installRoot, "node_modules", name, "package.json"), - JSON.stringify({ name, version: spec.slice(spec.lastIndexOf("@") + 1) }), - ); + writeInstalledPackage(params.installRoot, name, spec.slice(spec.lastIndexOf("@") + 1)); } }; @@ -2268,6 +2923,44 @@ describe("ensureBundledPluginRuntimeDeps", () => { ).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(); @@ -2584,11 +3277,7 @@ describe("ensureBundledPluginRuntimeDeps", () => { path.join(mirroredPluginRoot, "package.json"), JSON.stringify({ dependencies: { grammy: "^1.42.0" } }), ); - fs.mkdirSync(path.join(installRoot, "node_modules", "grammy"), { recursive: true }); - fs.writeFileSync( - path.join(installRoot, "node_modules", "grammy", "package.json"), - JSON.stringify({ name: "grammy", version: "1.42.0" }), - ); + writeInstalledPackage(installRoot, "grammy", "1.42.0"); writeGeneratedRuntimeDepsManifest(installRoot, ["grammy@^1.42.0"]); const nestedUnknownRoot = path.join( @@ -2736,12 +3425,7 @@ describe("ensureBundledPluginRuntimeDeps", () => { spawnSyncMock.mockImplementation((_command, _args, options) => { const cwd = String(options?.cwd); expect(cwd).toBe(path.join(pluginRoot, ".openclaw-install-stage")); - const depRoot = path.join(cwd, "node_modules", "voice-runtime"); - fs.mkdirSync(depRoot, { recursive: true }); - fs.writeFileSync( - path.join(depRoot, "package.json"), - JSON.stringify({ name: "voice-runtime", version: "1.0.0" }), - ); + writeInstalledPackage(cwd, "voice-runtime", "1.0.0"); return { status: 0, stdout: "", stderr: "" } as ReturnType; }); @@ -2896,7 +3580,7 @@ describe("ensureBundledPluginRuntimeDeps", () => { it("does not expire active runtime-deps install locks by age alone", () => { expect( - bundledRuntimeDepsTesting.shouldRemoveRuntimeDepsLock( + shouldRemoveRuntimeDepsLock( { pid: 123, createdAtMs: 0 }, Number.MAX_SAFE_INTEGER, () => true, @@ -2906,7 +3590,7 @@ describe("ensureBundledPluginRuntimeDeps", () => { it("expires runtime-deps install locks whose owner PID is dead", () => { expect( - bundledRuntimeDepsTesting.shouldRemoveRuntimeDepsLock( + shouldRemoveRuntimeDepsLock( // Conventional non-existent PID for dead-process simulation { pid: 99999, createdAtMs: 0 }, 1_000, @@ -2917,7 +3601,7 @@ describe("ensureBundledPluginRuntimeDeps", () => { it("expires runtime-deps install locks whose owner PID is dead regardless of age", () => { expect( - bundledRuntimeDepsTesting.shouldRemoveRuntimeDepsLock( + shouldRemoveRuntimeDepsLock( // Conventional non-existent PID for dead-process simulation { pid: 99999, createdAtMs: Date.now() }, Date.now(), @@ -2928,7 +3612,7 @@ describe("ensureBundledPluginRuntimeDeps", () => { it("treats a PID-alive lock with matching starttime as held by the same incarnation", () => { expect( - bundledRuntimeDepsTesting.shouldRemoveRuntimeDepsLock( + shouldRemoveRuntimeDepsLock( { pid: 7, starttime: 1_000, createdAtMs: 2_000 }, 2_500, () => true, @@ -2945,7 +3629,7 @@ describe("ensureBundledPluginRuntimeDeps", () => { // isProcessAlive. Capturing the writer's start-time and comparing it to // the live PID's start-time disambiguates incarnations. expect( - bundledRuntimeDepsTesting.shouldRemoveRuntimeDepsLock( + shouldRemoveRuntimeDepsLock( { pid: 7, starttime: 1_000, createdAtMs: 2_000 }, 2_500, () => true, @@ -2962,7 +3646,7 @@ describe("ensureBundledPluginRuntimeDeps", () => { // disambiguation path is start-time evidence on both sides; without it // we err toward "still held" rather than risk stomping a real install. expect( - bundledRuntimeDepsTesting.shouldRemoveRuntimeDepsLock( + shouldRemoveRuntimeDepsLock( { pid: 7, starttime: 1_000, createdAtMs: 0 }, Number.MAX_SAFE_INTEGER, () => true, @@ -2971,19 +3655,43 @@ describe("ensureBundledPluginRuntimeDeps", () => { ).toBe(false); }); - it("does not expire fresh ownerless runtime-deps install locks", () => { + it("expires legacy PID-alive locks without starttime or createdAtMs when lock files are stale", () => { expect( - bundledRuntimeDepsTesting.shouldRemoveRuntimeDepsLock( - { lockDirMtimeMs: 1_000 }, - 31_000, + 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( - bundledRuntimeDepsTesting.shouldRemoveRuntimeDepsLock( + shouldRemoveRuntimeDepsLock( { lockDirMtimeMs: 1_000, ownerFileMtimeMs: 31_000 }, 61_000, () => true, @@ -2992,18 +3700,12 @@ describe("ensureBundledPluginRuntimeDeps", () => { }); it("expires ownerless runtime-deps install locks after the owner write grace window", () => { - expect( - bundledRuntimeDepsTesting.shouldRemoveRuntimeDepsLock( - { lockDirMtimeMs: 1_000 }, - 31_001, - () => true, - ), - ).toBe(true); + 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( - bundledRuntimeDepsTesting.shouldRemoveRuntimeDepsLock( + shouldRemoveRuntimeDepsLock( { lockDirMtimeMs: 1_000, ownerFileMtimeMs: 2_000 }, 32_001, () => true, @@ -3012,7 +3714,7 @@ describe("ensureBundledPluginRuntimeDeps", () => { }); it("includes runtime-deps lock owner details in timeout messages", () => { - const message = bundledRuntimeDepsTesting.formatRuntimeDepsLockTimeoutMessage({ + const message = formatRuntimeDepsLockTimeoutMessage({ lockDir: "/tmp/openclaw-plugin/.openclaw-runtime-deps.lock", owner: { pid: 0, @@ -3078,6 +3780,50 @@ describe("ensureBundledPluginRuntimeDeps", () => { 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"); @@ -3294,7 +4040,7 @@ describe("ensureBundledPluginRuntimeDeps", () => { 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(pluginRoot, "node_modules", "tokenjuice"), { recursive: true }); + fs.mkdirSync(pluginRoot, { recursive: true }); fs.writeFileSync( path.join(pluginRoot, "package.json"), JSON.stringify({ @@ -3303,10 +4049,7 @@ describe("ensureBundledPluginRuntimeDeps", () => { }, }), ); - fs.writeFileSync( - path.join(pluginRoot, "node_modules", "tokenjuice", "package.json"), - JSON.stringify({ name: "tokenjuice", version: "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"] }), @@ -3497,7 +4240,7 @@ describe("ensureBundledPluginRuntimeDeps", () => { expect(installRoot).not.toBe(pluginRoot); }); - it("repairs package-level mirrors when an installed package entry file is missing", () => { + it("trusts package-manager materialized mirrors when manifest and package version match", () => { const packageRoot = makeTempDir(); const stageDir = makeTempDir(); fs.writeFileSync( @@ -3528,6 +4271,8 @@ describe("ensureBundledPluginRuntimeDeps", () => { 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({ @@ -3539,14 +4284,8 @@ describe("ensureBundledPluginRuntimeDeps", () => { }, }); - expect(result.installedSpecs).toEqual(["ajv@8.20.0"]); - expect(calls).toEqual([ - { - installRoot, - missingSpecs: ["ajv@8.20.0"], - installSpecs: ["ajv@8.20.0"], - }, - ]); + expect(result.installedSpecs).toEqual([]); + expect(calls).toEqual([]); }); it("mirrors sqlite-vec into the packaged default memory runtime deps", () => { @@ -3950,4 +4689,71 @@ describe("ensureBundledPluginRuntimeDeps", () => { 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 index be7174e4b3a..9d7ea8aea03 100644 --- a/src/plugins/bundled-runtime-deps.ts +++ b/src/plugins/bundled-runtime-deps.ts @@ -5,42 +5,29 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { beginBundledRuntimeDepsInstall } from "./bundled-runtime-deps-activity.js"; import { installBundledRuntimeDeps, - installBundledRuntimeDepsAsync, - repairBundledRuntimeDepsInstallRoot, repairBundledRuntimeDepsInstallRootAsync, type BundledRuntimeDepsInstallParams, } from "./bundled-runtime-deps-install.js"; import { readRuntimeDepsJsonObject } from "./bundled-runtime-deps-json.js"; import { BUNDLED_RUNTIME_DEPS_LOCK_DIR, - formatRuntimeDepsLockTimeoutMessage, - shouldRemoveRuntimeDepsLock, + removeRuntimeDepsLockIfStale, withBundledRuntimeDepsFilesystemLock, } from "./bundled-runtime-deps-lock.js"; import { ensureNpmInstallExecutionManifest, isRuntimeDepSatisfiedInAnyRoot, isRuntimeDepsPlanMaterialized, + linkRuntimeDepsNodeModulesFromRoot, removeLegacyRuntimeDepsManifest, + removeRuntimeDepsNodeModulesSymlink, } from "./bundled-runtime-deps-materialization.js"; -import { - createBundledRuntimeDepsInstallArgs, - createBundledRuntimeDepsInstallEnv, - resolveBundledRuntimeDepsNpmRunner, - resolveBundledRuntimeDepsPnpmRunner, - type BundledRuntimeDepsNpmRunner, -} from "./bundled-runtime-deps-package-manager.js"; import { isSourceCheckoutRoot, - isWritableDirectory, - pruneUnknownBundledRuntimeDepsRoots, - resolveBundledRuntimeDependencyInstallRoot, - resolveBundledRuntimeDependencyInstallRootInfo, + listSiblingExternalBundledRuntimeDepsRoots, resolveBundledRuntimeDependencyInstallRootPlan, - resolveBundledRuntimeDependencyPackageInstallRoot, resolveBundledRuntimeDependencyPackageInstallRootPlan, resolveBundledRuntimeDependencyPackageRoot, - type BundledRuntimeDepsInstallRoot, type BundledRuntimeDepsInstallRootPlan, } from "./bundled-runtime-deps-roots.js"; import { @@ -64,45 +51,26 @@ import { type NormalizePluginId, } from "./config-normalization-shared.js"; -export { - createBundledRuntimeDepsInstallArgs, - createBundledRuntimeDepsInstallEnv, - installBundledRuntimeDeps, - installBundledRuntimeDepsAsync, - repairBundledRuntimeDepsInstallRoot, - repairBundledRuntimeDepsInstallRootAsync, - resolveBundledRuntimeDepsNpmRunner, - withBundledRuntimeDepsFilesystemLock, -}; -export type { BundledRuntimeDepsNpmRunner }; -export type { BundledRuntimeDepsInstallParams } from "./bundled-runtime-deps-install.js"; -export type { RuntimeDepEntry } from "./bundled-runtime-deps-specs.js"; -export { - isWritableDirectory, - pruneUnknownBundledRuntimeDepsRoots, - resolveBundledRuntimeDependencyInstallRoot, - resolveBundledRuntimeDependencyInstallRootInfo, - resolveBundledRuntimeDependencyInstallRootPlan, - resolveBundledRuntimeDependencyPackageInstallRoot, - resolveBundledRuntimeDependencyPackageInstallRootPlan, - resolveBundledRuntimeDependencyPackageRoot, -}; -export type { - BundledRuntimeDepsInstallRoot, - BundledRuntimeDepsInstallRootPlan, -} from "./bundled-runtime-deps-roots.js"; -export type { RuntimeDepConflict } from "./bundled-runtime-deps-selection.js"; - -export const __testing = { - formatRuntimeDepsLockTimeoutMessage, - resolveBundledRuntimeDepsPnpmRunner, - shouldRemoveRuntimeDepsLock, -}; - 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[]; @@ -111,6 +79,27 @@ export type BundledRuntimeDepsPlan = { 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; + 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:*` @@ -183,7 +172,7 @@ export function clearBundledRuntimeDependencyNodePaths(): void { (Module as unknown as { _initPaths?: () => void })._initPaths?.(); } -export function createBundledRuntimeDepsInstallSpecs(params: { +function createBundledRuntimeDepsInstallSpecs(params: { deps: readonly { name: string; version: string }[]; }): string[] { return params.deps @@ -208,6 +197,53 @@ function createBundledRuntimeDepsPlan(params: { }; } +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; @@ -239,56 +275,169 @@ function collectPackageLevelRuntimeDepsForPlugin(params: { }); } -export function scanBundledPluginRuntimeDeps(params: { - packageRoot: string; - config?: OpenClawConfig; - pluginIds?: readonly string[]; - selectedPluginIds?: readonly string[]; - includeConfiguredChannels?: boolean; - env?: NodeJS.ProcessEnv; -}): { - deps: RuntimeDepEntry[]; - missing: RuntimeDepEntry[]; - conflicts: RuntimeDepConflict[]; -} { - if (isSourceCheckoutRoot(params.packageRoot)) { - return { deps: [], missing: [], conflicts: [] }; +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 extensionsDir = path.join(params.packageRoot, "dist", "extensions"); - if (!fs.existsSync(extensionsDir)) { - return { deps: [], missing: [], conflicts: [] }; + const reusableRoot = findReusableBundledRuntimeDepsRoot(params); + if (!reusableRoot) { + return null; } - const manifestCache: BundledPluginRuntimeDepsManifestCache = new Map(); - const normalizePluginId = - params.config || params.pluginIds || params.selectedPluginIds - ? createBundledRuntimeDepsPluginIdNormalizer({ - extensionsDir, - manifestCache, - }) - : undefined; - const { deps, conflicts, pluginIds } = collectBundledPluginRuntimeDeps({ - extensionsDir, - config: params.config, - pluginIds: normalizePluginIdSet(params.pluginIds, normalizePluginId), - selectedPluginIds: normalizePluginIdSet(params.selectedPluginIds, normalizePluginId), - includeConfiguredChannels: params.includeConfiguredChannels, - manifestCache, - ...(normalizePluginId ? { normalizePluginId } : {}), - }); - const packageRuntimeDeps = - pluginIds.length > 0 ? collectMirroredPackageRuntimeDeps(params.packageRoot) : []; + 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, pluginIds } = collectBundledPluginRuntimeDeps({ + extensionsDir, + ...(params.config ? { config: params.config } : {}), + ...(exactPluginIds ? { exactPluginIds } : {}), + ...(!exactPluginIds && scopedPluginIds ? { pluginIds: scopedPluginIds } : {}), + ...(!exactPluginIds && params.includeConfiguredChannels !== undefined + ? { includeConfiguredChannels: params.includeConfiguredChannels } + : {}), + manifestCache, + ...(normalizePluginId ? { normalizePluginId } : {}), + }); + const packageRuntimeDeps = + pluginIds.length > 0 ? collectMirroredPackageRuntimeDeps(params.packageRoot) : []; const plan = createBundledRuntimeDepsPlan({ deps: [...deps, ...packageRuntimeDeps], conflicts, installRootPlan, }); - return { deps: plan.deps, missing: plan.missing, conflicts: plan.conflicts }; + 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; + env: NodeJS.ProcessEnv; + installDeps?: (params: BundledRuntimeDepsInstallParams) => Promise | void; + onProgress?: (message: string) => void; + warn?: (message: string) => void; +}): Promise { + const plan = createBundledRuntimeDepsPackagePlan(params); + if (plan.missingSpecs.length === 0) { + 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); + 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 } : {}), + }); + return { plan, repairedSpecs: result.installSpecs }; } export function createBundledRuntimeDependencyAliasMap(params: { @@ -323,6 +472,7 @@ export function ensureBundledPluginRuntimeDeps(params: { pluginRoot: string; env: NodeJS.ProcessEnv; config?: OpenClawConfig; + installMissingDeps?: boolean; installDeps?: (params: BundledRuntimeDepsInstallParams) => void; }): BundledRuntimeDepsEnsureResult { const extensionsDir = path.dirname(params.pluginRoot); @@ -373,7 +523,9 @@ export function ensureBundledPluginRuntimeDeps(params: { const installRoot = installRootPlan.installRoot; const packageRoot = resolveBundledRuntimeDependencyPackageRoot(params.pluginRoot); const usePackageLevelPlan = - packageRoot && path.resolve(installRoot) !== path.resolve(params.pluginRoot); + packageRoot && + !isSourceCheckoutRoot(packageRoot) && + path.resolve(installRoot) !== path.resolve(params.pluginRoot); let deps = pluginDepEntries; if (usePackageLevelPlan && packageRoot) { const requestedPluginPlan = collectPackageLevelRuntimeDepsForPlugin({ @@ -427,6 +579,22 @@ export function ensureBundledPluginRuntimeDeps(params: { 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) @@ -442,6 +610,7 @@ export function ensureBundledPluginRuntimeDeps(params: { missingSpecs: installParams.installSpecs ?? installParams.missingSpecs, installSpecs: installParams.installSpecs, env: params.env, + force: true, }); }); const finishActivity = beginBundledRuntimeDepsInstall({ diff --git a/src/plugins/bundled-runtime-root.test.ts b/src/plugins/bundled-runtime-root.test.ts index ce78e47dae4..7fe2edf747c 100644 --- a/src/plugins/bundled-runtime-root.test.ts +++ b/src/plugins/bundled-runtime-root.test.ts @@ -2,10 +2,18 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { resolveBundledRuntimeDependencyInstallRoot } from "./bundled-runtime-deps.js"; +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 { prepareBundledPluginRuntimeRoot } from "./bundled-runtime-root.js"; -import { writeGeneratedRuntimeDepsManifest } from "./test-helpers/bundled-runtime-deps-fixtures.js"; +import { + clearPreparedBundledPluginRuntimeLoadRoots, + prepareBundledPluginRuntimeLoadRoot, + prepareBundledPluginRuntimeRoot, +} from "./bundled-runtime-root.js"; +import { + writeGeneratedRuntimeDepsManifest, + writeInstalledRuntimeDepPackage, +} from "./test-helpers/bundled-runtime-deps-fixtures.js"; const tempRoots: string[] = []; @@ -17,6 +25,7 @@ function makeTempRoot(): string { afterEach(() => { vi.restoreAllMocks(); + clearPreparedBundledPluginRuntimeLoadRoots(); for (const root of tempRoots.splice(0)) { fs.rmSync(root, { recursive: true, force: true }); } @@ -625,6 +634,103 @@ describe("prepareBundledPluginRuntimeRoot", () => { 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(); diff --git a/src/plugins/bundled-runtime-root.ts b/src/plugins/bundled-runtime-root.ts index 50ccbb7af93..1f54f1c4c74 100644 --- a/src/plugins/bundled-runtime-root.ts +++ b/src/plugins/bundled-runtime-root.ts @@ -1,13 +1,15 @@ 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 { - ensureBundledPluginRuntimeDeps, resolveBundledRuntimeDependencyInstallRootPlan, resolveBundledRuntimeDependencyPackageRoot, +} from "./bundled-runtime-deps-roots.js"; +import { + ensureBundledPluginRuntimeDeps, registerBundledRuntimeDependencyNodePath, - withBundledRuntimeDepsFilesystemLock, - type BundledRuntimeDepsInstallParams, } from "./bundled-runtime-deps.js"; import { markBundledRuntimeDistMirrorPrepared, @@ -28,6 +30,72 @@ export type PreparedBundledPluginRuntimeLoadRoot = { 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); @@ -42,11 +110,44 @@ export function prepareBundledPluginRuntimeRoot(params: { 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; @@ -54,8 +155,11 @@ export function prepareBundledPluginRuntimeLoadRoot(params: { 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; @@ -63,39 +167,48 @@ export function prepareBundledPluginRuntimeLoadRoot(params: { env, }); const installRoot = installRootPlan.installRoot; - const depsInstallResult = ensureBundledPluginRuntimeDeps({ - pluginId: params.pluginId, - pluginRoot: params.pluginRoot, - env, - config: params.config, - installDeps: params.installDeps, - }); - if (depsInstallResult.installedSpecs.length > 0) { - params.logInstalled?.(depsInstallResult.installedSpecs); + 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)) { - ensureOpenClawPluginSdkAlias(path.dirname(path.dirname(params.pluginRoot))); - return { + 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; } - const packageRoot = resolveBundledRuntimeDependencyPackageRoot(params.pluginRoot); - if (packageRoot) { - registerBundledRuntimeDependencyNodePath(packageRoot); - params.registerRuntimeAliasRoot?.(packageRoot); - } - for (const searchRoot of installRootPlan.searchRoots) { - registerBundledRuntimeDependencyNodePath(searchRoot); - params.registerRuntimeAliasRoot?.(searchRoot); - } + registerBundledRuntimeLoadRootAliases({ + pluginRoot: params.pluginRoot, + installRoot, + searchRoots: installRootPlan.searchRoots, + registerRuntimeAliasRoot: params.registerRuntimeAliasRoot, + }); const mirrorRoot = mirrorBundledPluginRuntimeRoot({ pluginId: params.pluginId, pluginRoot: params.pluginRoot, installRoot, }); - return { + const prepared = { pluginRoot: mirrorRoot, modulePath: remapBundledPluginRuntimePath({ source: params.modulePath, @@ -112,6 +225,10 @@ export function prepareBundledPluginRuntimeLoadRoot(params: { } : {}), }; + if (params.memoizePreparedRoot) { + preparedRuntimeLoadRoots.set(cacheKey, prepared); + } + return prepared; } function remapBundledPluginRuntimePath(params: { diff --git a/src/plugins/bundled-runtime-staging.test.ts b/src/plugins/bundled-runtime-staging.test.ts new file mode 100644 index 00000000000..068430da20d --- /dev/null +++ b/src/plugins/bundled-runtime-staging.test.ts @@ -0,0 +1,103 @@ +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 new file mode 100644 index 00000000000..2ed6bb8df99 --- /dev/null +++ b/src/plugins/bundled-runtime-staging.ts @@ -0,0 +1,92 @@ +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/doctor-contract-registry.ts b/src/plugins/doctor-contract-registry.ts index 0b7af25cf36..a2e645c3562 100644 --- a/src/plugins/doctor-contract-registry.ts +++ b/src/plugins/doctor-contract-registry.ts @@ -217,13 +217,13 @@ function resolvePluginDoctorContracts(params?: { }); const entries: PluginDoctorContractEntry[] = []; - const selectedPluginIds = params?.pluginIds ? new Set(params.pluginIds) : null; + const scopedPluginIds = params?.pluginIds ? new Set(params.pluginIds) : null; for (const record of manifestRegistry.plugins) { if ( - selectedPluginIds && - !selectedPluginIds.has(record.id) && - !record.channels.some((channelId) => selectedPluginIds.has(channelId)) && - !record.providers.some((providerId) => selectedPluginIds.has(providerId)) + scopedPluginIds && + !scopedPluginIds.has(record.id) && + !record.channels.some((channelId) => scopedPluginIds.has(channelId)) && + !record.providers.some((providerId) => scopedPluginIds.has(providerId)) ) { continue; } diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 0cfbd070cb0..796522d5d2f 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -22,10 +22,8 @@ import { type DetachedTaskLifecycleRuntime, } from "../tasks/detached-task-runtime-state.js"; import { withEnv } from "../test-utils/env.js"; -import { - resolveBundledRuntimeDependencyInstallRootPlan, - type BundledRuntimeDepsInstallParams, -} from "./bundled-runtime-deps.js"; +import type { BundledRuntimeDepsInstallParams } from "./bundled-runtime-deps-install.js"; +import { resolveBundledRuntimeDependencyInstallRootPlan } 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"; diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 9c8f6c57c6b..bf83f6f5c98 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -10,7 +10,6 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import type { GatewayRequestHandler } from "../gateway/server-methods/types.js"; import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; -import { measureDiagnosticsTimelineSpanSync } from "../infra/diagnostics-timeline.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { DEFAULT_MEMORY_DREAMING_PLUGIN_ID, @@ -27,21 +26,18 @@ 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, - registerBundledRuntimeDependencyJitiAliases, resolveBundledRuntimeDependencyJitiAliasMap, } from "./bundled-runtime-deps-jiti-aliases.js"; -import { - clearBundledRuntimeDependencyNodePaths, - installBundledRuntimeDeps, - type BundledRuntimeDepsInstallParams, -} from "./bundled-runtime-deps.js"; +import { clearBundledRuntimeDependencyNodePaths } from "./bundled-runtime-deps.js"; import { clearBundledRuntimeDistMirrorPreparationCache } from "./bundled-runtime-dist-mirror-cache.js"; import { + clearPreparedBundledPluginRuntimeLoadRoots, ensureOpenClawPluginSdkAlias, - prepareBundledPluginRuntimeLoadRoot, } from "./bundled-runtime-root.js"; +import { prepareBundledRuntimeLoadRootForPlugin } from "./bundled-runtime-staging.js"; import { clearPluginCommands, listRegisteredPluginCommands, @@ -192,6 +188,7 @@ export type PluginLoadOptions = { installBundledRuntimeDeps?: boolean; throwOnLoadError?: boolean; bundledRuntimeDepsInstaller?: (params: BundledRuntimeDepsInstallParams) => void; + bundledRuntimeDepsRepairError?: unknown; manifestRegistry?: PluginManifestRegistry; }; @@ -291,6 +288,7 @@ export function clearPluginLoaderCache(): void { pluginLoaderCacheState.clear(); clearBundledRuntimeDependencyNodePaths(); clearBundledRuntimeDistMirrorPreparationCache(); + clearPreparedBundledPluginRuntimeLoadRoots(); clearBundledRuntimeDependencyJitiAliases(); clearAgentHarnesses(); clearPluginCommands(); @@ -1463,79 +1461,28 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi markPluginActivationDisabled(record, enableState.reason); } - if ( - shouldLoadModules && - shouldInstallBundledRuntimeDeps && - candidate.origin === "bundled" && - enableState.enabled - ) { - let runtimeDepsInstallStartedAt: number | null = null; - let runtimeDepsInstallSpecs: string[] = []; + if (shouldLoadModules && candidate.origin === "bundled" && enableState.enabled) { try { - const preparedRuntimeRoot = prepareBundledPluginRuntimeLoadRoot({ + const preparedRuntimeRoot = prepareBundledRuntimeLoadRootForPlugin({ pluginId: record.id, pluginRoot, modulePath: runtimeCandidateSource, ...(runtimeSetupSource ? { setupModulePath: runtimeSetupSource } : {}), env, config: cfg, - registerRuntimeAliasRoot: registerBundledRuntimeDependencyJitiAliases, - installDeps: (installParams) => { - const installSpecs = installParams.installSpecs ?? installParams.missingSpecs; - runtimeDepsInstallStartedAt = Date.now(); - runtimeDepsInstallSpecs = installSpecs; - if (shouldActivate) { - logger.info( - `[plugins] ${record.id} staging bundled runtime deps (${installSpecs.length} specs): ${installSpecs.join(", ")}`, - ); - } - const installer = - options.bundledRuntimeDepsInstaller ?? - ((params: BundledRuntimeDepsInstallParams) => - installBundledRuntimeDeps({ - installRoot: params.installRoot, - installExecutionRoot: params.installExecutionRoot, - missingSpecs: params.installSpecs ?? params.missingSpecs, - installSpecs: params.installSpecs, - env, - warn: (message) => logger.warn(`[plugins] ${record.id}: ${message}`), - })); - measureDiagnosticsTimelineSpanSync( - "runtimeDeps.stage", - () => installer(installParams), - { - phase: "startup", - config: cfg, - env, - attributes: { - pluginId: record.id, - dependencyCount: installSpecs.length, - }, - }, - ); - }, - logInstalled: (installedSpecs) => { - if (shouldActivate) { - const elapsed = - runtimeDepsInstallStartedAt === null - ? "" - : ` in ${Date.now() - runtimeDepsInstallStartedAt}ms`; - logger.info( - `[plugins] ${record.id} installed bundled runtime deps${elapsed}: ${installedSpecs.join(", ")}`, - ); - } - }, + installMissingDeps: shouldInstallBundledRuntimeDeps, + previousRepairError: options.bundledRuntimeDepsRepairError, + shouldLog: shouldActivate, + logger, + ...(options.bundledRuntimeDepsInstaller + ? { installer: options.bundledRuntimeDepsInstaller } + : {}), }); runtimePluginRoot = preparedRuntimeRoot.pluginRoot; runtimeCandidateSource = preparedRuntimeRoot.modulePath; runtimeSetupSource = preparedRuntimeRoot.setupModulePath; } catch (error) { - if (shouldActivate && runtimeDepsInstallStartedAt !== null) { - logger.error( - `[plugins] ${record.id} failed to stage bundled runtime deps after ${Date.now() - runtimeDepsInstallStartedAt}ms: ${runtimeDepsInstallSpecs.join(", ")}`, - ); - } - pushPluginLoadError(`failed to install bundled runtime deps: ${String(error)}`); + pushPluginLoadError(`failed to prepare bundled runtime deps: ${String(error)}`); continue; } } diff --git a/src/plugins/public-surface-loader.test.ts b/src/plugins/public-surface-loader.test.ts index 135fd374a0b..fdfba878deb 100644 --- a/src/plugins/public-surface-loader.test.ts +++ b/src/plugins/public-surface-loader.test.ts @@ -3,10 +3,8 @@ 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 { - clearBundledRuntimeDependencyNodePaths, - resolveBundledRuntimeDependencyInstallRoot, -} from "./bundled-runtime-deps.js"; +import { resolveBundledRuntimeDependencyInstallRoot } from "./bundled-runtime-deps-roots.js"; +import { clearBundledRuntimeDependencyNodePaths } from "./bundled-runtime-deps.js"; const tempDirs: string[] = []; const originalBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; diff --git a/src/plugins/runtime/runtime-registry-loader.ts b/src/plugins/runtime/runtime-registry-loader.ts index b0799e7bf92..e1ab00b429b 100644 --- a/src/plugins/runtime/runtime-registry-loader.ts +++ b/src/plugins/runtime/runtime-registry-loader.ts @@ -92,6 +92,7 @@ export function ensurePluginRegistryLoaded(options?: { workspaceDir?: string; onlyPluginIds?: string[]; onlyChannelIds?: string[]; + installBundledRuntimeDeps?: boolean; }): void { const scope = options?.scope ?? "all"; const requestedPluginIdsFromOptions = normalizePluginIdScope(options?.onlyPluginIds); @@ -174,6 +175,7 @@ export function ensurePluginRegistryLoaded(options?: { }, { throwOnLoadError: true, + installBundledRuntimeDeps: options?.installBundledRuntimeDeps, ...(hasExplicitPluginIdScope(requestedPluginIds) || shouldForwardChannelScope({ scope, scopedLoad }) || hasNonEmptyPluginIdScope(expectedChannelPluginIds) diff --git a/src/plugins/setup-registry.ts b/src/plugins/setup-registry.ts index 2fa71664002..b4069c0a533 100644 --- a/src/plugins/setup-registry.ts +++ b/src/plugins/setup-registry.ts @@ -419,10 +419,10 @@ export function resolvePluginSetupRegistry(params?: { pluginIds?: readonly string[]; }): PluginSetupRegistry { const env = params?.env ?? process.env; - const selectedPluginIds = params?.pluginIds + const scopedPluginIds = params?.pluginIds ? new Set(params.pluginIds.map((pluginId) => pluginId.trim()).filter(Boolean)) : null; - if (selectedPluginIds && selectedPluginIds.size === 0) { + if (scopedPluginIds && scopedPluginIds.size === 0) { const empty = { providers: [], cliBackends: [], @@ -449,7 +449,7 @@ export function resolvePluginSetupRegistry(params?: { }); for (const record of manifestRegistry.plugins) { - if (selectedPluginIds && !selectedPluginIds.has(record.id)) { + if (scopedPluginIds && !scopedPluginIds.has(record.id)) { continue; } if (record.setup?.requiresRuntime === false) { diff --git a/src/plugins/status.ts b/src/plugins/status.ts index a4fcbe2db57..85db36b9de9 100644 --- a/src/plugins/status.ts +++ b/src/plugins/status.ts @@ -319,6 +319,7 @@ 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 index 5c48a2836e8..1afcb43ed51 100644 --- a/src/plugins/test-helpers/bundled-runtime-deps-fixtures.ts +++ b/src/plugins/test-helpers/bundled-runtime-deps-fixtures.ts @@ -13,6 +13,7 @@ export function writeInstalledRuntimeDepPackage( 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 { diff --git a/src/security/audit-channel-readonly-setup-fallback.test.ts b/src/security/audit-channel-readonly-setup-fallback.test.ts index a891eba6897..ff9220b6c34 100644 --- a/src/security/audit-channel-readonly-setup-fallback.test.ts +++ b/src/security/audit-channel-readonly-setup-fallback.test.ts @@ -79,7 +79,7 @@ describe("security audit channel read-only setup fallback", () => { cfg, expect.objectContaining({ includePersistedAuthState: true, - includeSetupRuntimeFallback: true, + includeSetupFallbackPlugins: true, }), ); expect(report.findings).toEqual( diff --git a/src/security/audit.ts b/src/security/audit.ts index 57e0a3894a3..e2f3db7440f 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -1059,7 +1059,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise 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, @@ -158,6 +159,10 @@ vi.mock("../commands/onboard-skills.js", () => ({ setupSkills, })); +vi.mock("../commands/post-config-runtime-deps.js", () => ({ + preparePostConfigBundledRuntimeDeps, +})); + vi.mock("../agents/auth-profiles.js", () => ({ ensureAuthProfileStore, })); @@ -422,6 +427,7 @@ 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"]; @@ -454,6 +460,45 @@ 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 () => { diff --git a/src/wizard/setup.ts b/src/wizard/setup.ts index 7061ef89723..19da67f29af 100644 --- a/src/wizard/setup.ts +++ b/src/wizard/setup.ts @@ -8,6 +8,7 @@ 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"; @@ -775,6 +776,7 @@ 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/scripts/postinstall-bundled-plugins.test.ts b/test/scripts/postinstall-bundled-plugins.test.ts index 47756f331a7..c10047b0eed 100644 --- a/test/scripts/postinstall-bundled-plugins.test.ts +++ b/test/scripts/postinstall-bundled-plugins.test.ts @@ -6,6 +6,8 @@ import { createBundledRuntimeDependencyInstallArgs, createBundledRuntimeDependencyInstallEnv, createNestedNpmInstallEnv, +} from "../../scripts/lib/bundled-runtime-deps-install.mjs"; +import { isDirectPostinstallInvocation, pruneOpenClawCompileCache, pruneInstalledPackageDist, @@ -51,41 +53,6 @@ async function writePluginPackage( } describe("bundled plugin postinstall", () => { - function createNpmInstallArgs(...packages: string[]) { - return createBundledRuntimeDependencyInstallArgs(packages); - } - - function createBareNpmRunner(packages: string[]) { - return { - command: "npm", - args: createNpmInstallArgs(...packages), - env: { - HOME: "/tmp/home", - PATH: "/tmp/node/bin", - }, - shell: false as const, - }; - } - - function expectNpmInstallSpawn( - spawnSync: ReturnType, - packageRoot: string, - packages: string[], - ) { - expect(spawnSync).toHaveBeenCalledWith("npm", createNpmInstallArgs(...packages), { - cwd: packageRoot, - encoding: "utf8", - env: { - HOME: "/tmp/home", - PATH: "/tmp/node/bin", - }, - shell: false, - stdio: "pipe", - windowsHide: true, - windowsVerbatimArguments: undefined, - }); - } - it("recognizes direct invocation through symlinked temp prefixes", () => { const realpathSync = vi.fn((value: string) => value.replace(/^\/var\/folders\//u, "/private/var/folders/"), @@ -125,9 +92,14 @@ describe("bundled plugin postinstall", () => { 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({ @@ -139,13 +111,17 @@ describe("bundled plugin postinstall", () => { 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", @@ -154,9 +130,11 @@ describe("bundled plugin postinstall", () => { 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", }); }); @@ -174,7 +152,6 @@ describe("bundled plugin postinstall", () => { env: { HOME: "/tmp/home" }, extensionsDir, packageRoot, - npmRunner: createBareNpmRunner(["acpx@0.4.1"]), spawnSync, log: { log: vi.fn(), warn: vi.fn() }, }); @@ -714,34 +691,6 @@ describe("bundled plugin postinstall", () => { expect(unlinkSync).toHaveBeenCalledWith("/pkg/dist/stale.js"); }); - it("runs nested local installs with sanitized env when the sentinel package is missing", async () => { - const extensionsDir = await createExtensionsDir(); - const packageRoot = path.dirname(path.dirname(extensionsDir)); - await writePluginPackage(extensionsDir, "acpx", { - dependencies: { - acpx: "0.4.1", - }, - }); - const spawnSync = vi.fn(() => ({ status: 0, stderr: "", stdout: "" })); - - runBundledPluginPostinstall({ - env: { - OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS: "1", - npm_config_global: "true", - npm_config_location: "global", - npm_config_prefix: "/opt/homebrew", - HOME: "/tmp/home", - }, - extensionsDir, - packageRoot, - npmRunner: createBareNpmRunner(["acpx@0.4.1"]), - spawnSync, - log: { log: vi.fn(), warn: vi.fn() }, - }); - - expectNpmInstallSpawn(spawnSync, packageRoot, ["acpx@0.4.1"]); - }); - it("skips reinstall when the bundled sentinel package already exists", async () => { const extensionsDir = await createExtensionsDir(); const packageRoot = path.dirname(path.dirname(extensionsDir)); @@ -768,26 +717,6 @@ describe("bundled plugin postinstall", () => { expect(spawnSync).not.toHaveBeenCalled(); }); - it("reinstalls bundled runtime deps when optional native children are missing", async () => { - const extensionsDir = await createExtensionsDir(); - const packageRoot = path.dirname(path.dirname(extensionsDir)); - await writeDiscordDaveyOptionalDependencyFixture(extensionsDir, packageRoot); - const spawnSync = vi.fn(() => ({ status: 0, stderr: "", stdout: "" })); - - runBundledPluginPostinstall({ - env: { HOME: "/tmp/home", OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS: "1" }, - extensionsDir, - packageRoot, - arch: "arm64", - npmRunner: createBareNpmRunner(["@snazzah/davey@0.1.11"]), - platform: "win32", - spawnSync, - log: { log: vi.fn(), warn: vi.fn() }, - }); - - expectNpmInstallSpawn(spawnSync, packageRoot, ["@snazzah/davey@0.1.11"]); - }); - 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)); @@ -863,103 +792,6 @@ describe("bundled plugin postinstall", () => { ); }); - it("installs missing bundled plugin runtime deps during global installs", async () => { - const extensionsDir = await createExtensionsDir(); - const packageRoot = path.dirname(path.dirname(extensionsDir)); - await writePluginPackage(extensionsDir, "slack", { - dependencies: { - "@slack/web-api": "7.11.0", - }, - }); - await writePluginPackage(extensionsDir, "telegram", { - dependencies: { - grammy: "1.38.4", - }, - }); - const spawnSync = vi.fn(() => ({ status: 0, stderr: "", stdout: "" })); - - runBundledPluginPostinstall({ - env: { - OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS: "1", - npm_config_global: "true", - npm_config_location: "global", - npm_config_prefix: "/opt/homebrew", - HOME: "/tmp/home", - }, - extensionsDir, - packageRoot, - npmRunner: createBareNpmRunner(["@slack/web-api@7.11.0", "grammy@1.38.4"]), - spawnSync, - log: { log: vi.fn(), warn: vi.fn() }, - }); - - expectNpmInstallSpawn(spawnSync, packageRoot, ["@slack/web-api@7.11.0", "grammy@1.38.4"]); - }); - - it("installs only missing bundled plugin runtime deps", async () => { - const extensionsDir = await createExtensionsDir(); - const packageRoot = path.dirname(path.dirname(extensionsDir)); - await writePluginPackage(extensionsDir, "slack", { - dependencies: { - "@slack/web-api": "7.11.0", - }, - }); - await writePluginPackage(extensionsDir, "telegram", { - dependencies: { - grammy: "1.38.4", - }, - }); - await fs.mkdir(path.join(packageRoot, "node_modules", "@slack", "web-api"), { - recursive: true, - }); - await fs.writeFile( - path.join(packageRoot, "node_modules", "@slack", "web-api", "package.json"), - "{}\n", - ); - const spawnSync = vi.fn(() => ({ status: 0, stderr: "", stdout: "" })); - - runBundledPluginPostinstall({ - env: { - OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS: "1", - HOME: "/tmp/home", - }, - extensionsDir, - packageRoot, - npmRunner: createBareNpmRunner(["grammy@1.38.4"]), - spawnSync, - log: { log: vi.fn(), warn: vi.fn() }, - }); - - expectNpmInstallSpawn(spawnSync, packageRoot, ["grammy@1.38.4"]); - }); - - it("installs bundled plugin deps when npm location is global", async () => { - const extensionsDir = await createExtensionsDir(); - const packageRoot = path.dirname(path.dirname(extensionsDir)); - await writePluginPackage(extensionsDir, "telegram", { - dependencies: { - grammy: "1.38.4", - }, - }); - const spawnSync = vi.fn(() => ({ status: 0, stderr: "", stdout: "" })); - - runBundledPluginPostinstall({ - env: { - OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS: "1", - npm_config_location: "global", - npm_config_prefix: "/opt/homebrew", - HOME: "/tmp/home", - }, - extensionsDir, - packageRoot, - npmRunner: createBareNpmRunner(["grammy@1.38.4"]), - spawnSync, - log: { log: vi.fn(), warn: vi.fn() }, - }); - - expectNpmInstallSpawn(spawnSync, packageRoot, ["grammy@1.38.4"]); - }); - 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");