diff --git a/docs/.internal/extension-host-migration/openclaw-capability-catalog-and-arbitration-spec.md b/docs/.internal/extension-host-migration/openclaw-capability-catalog-and-arbitration-spec.md new file mode 100644 index 00000000000..ac3bb8a23fa --- /dev/null +++ b/docs/.internal/extension-host-migration/openclaw-capability-catalog-and-arbitration-spec.md @@ -0,0 +1,636 @@ +Temporary internal migration note: remove this document once the extension-host migration is complete. + +# OpenClaw Capability Catalog And Arbitration Spec + +Date: 2026-03-15 + +## Purpose + +This document defines how the system compiles agent-visible, operator-visible, and runtime-internal catalogs from active contributions and how it resolves conflicting or parallel providers. + +The kernel should expose canonical actions, not raw plugin identities. + +Host-managed install, onboarding, and lightweight channel catalogs remain separate from the kernel capability catalog. + +## TODOs + +- [ ] Implement kernel-owned internal and agent-visible catalogs. +- [ ] Implement host-owned operator catalogs and static setup catalogs. +- [ ] Implement canonical action registration and review workflow in code. +- [ ] Implement arbitration and conflict handling for at least one multi-provider family. +- [ ] Migrate the existing tool, provider, setup, and slot-selection surfaces so they no longer act as parallel catalog or arbitration systems. +- [ ] Record pilot parity for `thread-ownership` first and `telegram` second before broader catalog publication. +- [ ] Track which current `main` actions have been mapped into canonical action ids. + +## Implementation Status + +Current status against this spec: + +- canonical catalogs and arbitration have not started +- host-managed static metadata work and early runtime/lifecycle boundary extraction have landed + +What has been implemented: + +- an initial Phase 0 cutover inventory now exists in `src/extension-host/cutover-inventory.md` +- channel catalog package metadata parsing now routes through host-owned schema helpers +- host-owned resolved-extension records now carry the static metadata needed for install, onboarding, and lightweight operator UX +- config doc baseline generation now uses the same host-owned resolved-extension metadata path +- plugin SDK alias resolution now routes through `src/extension-host/compat/loader-compat.ts` +- loader alias-wired module loader creation now routes through `src/extension-host/activation/loader-module-loader.ts` +- loader cache key construction and registry cache control now route through `src/extension-host/activation/loader-cache.ts` +- loader lazy runtime proxy creation now routes through `src/extension-host/activation/loader-runtime-proxy.ts` +- loader provenance helpers now route through `src/extension-host/policy/loader-provenance.ts` +- loader duplicate-order and record/error policy now route through `src/extension-host/policy/loader-policy.ts` +- loader discovery policy outcomes now route through `src/extension-host/policy/loader-discovery-policy.ts` +- loader initial candidate planning and record creation now route through `src/extension-host/activation/loader-records.ts` +- loader entry-path opening and module import now route through `src/extension-host/activation/loader-import.ts` +- loader module-export resolution, config validation, and memory-slot load decisions now route through `src/extension-host/activation/loader-runtime.ts` +- loader post-import planning and `register(...)` execution now route through `src/extension-host/activation/loader-register.ts` +- loader per-candidate orchestration now routes through `src/extension-host/activation/loader-flow.ts` +- loader top-level load orchestration now routes through `src/extension-host/activation/loader-orchestrator.ts` +- loader host process state now routes through `src/extension-host/activation/loader-host-state.ts` +- loader preflight and cache-hit setup now routes through `src/extension-host/activation/loader-preflight.ts` +- loader post-preflight pipeline composition now routes through `src/extension-host/activation/loader-pipeline.ts` +- loader execution setup composition now routes through `src/extension-host/activation/loader-execution.ts` +- loader discovery and manifest bootstrap now routes through `src/extension-host/activation/loader-bootstrap.ts` +- loader mutable activation state now routes through `src/extension-host/activation/loader-session.ts` +- loader session run and finalization composition now routes through `src/extension-host/activation/loader-run.ts` +- loader activation policy outcomes now route through `src/extension-host/policy/loader-activation-policy.ts` +- loader record-state transitions now route through `src/extension-host/activation/loader-state.ts`, which now enforces an explicit loader lifecycle state machine while preserving compatibility `PluginRecord.status` values +- loader finalization policy results now route through `src/extension-host/policy/loader-finalization-policy.ts` +- loader final cache, readiness promotion, and activation finalization now routes through `src/extension-host/activation/loader-finalize.ts` +- channel, provider, gateway-method, tool, CLI, service, command, context-engine, and hook registration normalization now has a host-owned helper boundary for future catalog migration +- low-risk runtime compatibility writes for channel, provider, gateway-method, HTTP-route, tool, CLI, service, command, context-engine, and hook registrations now route through `src/extension-host/contributions/registry-writes.ts` ahead of broader catalog-backed registry ownership +- legacy internal-hook bridging and typed prompt-injection compatibility policy now route through `src/extension-host/compat/hook-compat.ts` ahead of broader catalog-backed registry ownership +- compatibility `OpenClawPluginApi` composition and logger shaping now route through `src/extension-host/compat/plugin-api.ts` ahead of broader catalog-backed registry ownership +- compatibility plugin-registry facade ownership now routes through `src/extension-host/compat/plugin-registry.ts` ahead of broader catalog-backed registry ownership +- compatibility plugin-registry policy now routes through `src/extension-host/compat/plugin-registry-compat.ts` ahead of broader catalog-backed registry ownership +- compatibility plugin-registry registration actions now route through `src/extension-host/compat/plugin-registry-registrations.ts` ahead of broader catalog-backed registry ownership +- host-owned runtime registry accessors now route through `src/extension-host/contributions/runtime-registry.ts` ahead of broader catalog-backed registry ownership, and the channel, provider, tool, command, HTTP-route, gateway-method, CLI, and service slices now keep host-owned storage there with mirrored legacy compatibility views +- plugin command registration, matching, execution, listing, native command-spec projection, and loader reload clearing now route through `src/extension-host/contributions/command-runtime.ts` ahead of broader catalog-backed ownership +- service startup, stop ordering, service-context creation, and failure logging now route through `src/extension-host/contributions/service-lifecycle.ts` ahead of broader catalog-backed lifecycle ownership +- CLI duplicate detection, registrar invocation, and async failure logging now route through `src/extension-host/contributions/cli-lifecycle.ts` ahead of broader catalog-backed CLI ownership +- gateway method-id aggregation, plugin diagnostic shaping, and extra-handler composition now route through `src/extension-host/contributions/gateway-methods.ts` ahead of broader catalog-backed gateway ownership +- plugin tool resolution, conflict handling, optional-tool gating, and plugin-tool metadata tracking now route through `src/extension-host/contributions/tool-runtime.ts` ahead of broader catalog-backed tool ownership +- plugin provider projection from registry entries into runtime provider objects now routes through `src/extension-host/contributions/provider-runtime.ts` ahead of broader catalog-backed provider ownership +- plugin provider discovery filtering, order grouping, and result normalization now route through `src/extension-host/contributions/provider-discovery.ts` ahead of broader catalog-backed provider-discovery ownership +- provider matching, auth-method selection, config-patch merging, and default-model application now route through `src/extension-host/contributions/provider-auth.ts` ahead of broader catalog-backed provider-auth ownership +- provider onboarding option building, model-picker entry building, and provider-method choice resolution now route through `src/extension-host/contributions/provider-wizard.ts` ahead of broader catalog-backed provider-setup ownership +- loaded-provider auth application, plugin-enable gating, auth-method execution, and post-auth default-model handling now route through `src/extension-host/contributions/provider-auth-flow.ts` ahead of broader catalog-backed provider-setup ownership +- provider post-selection hook lookup and invocation now route through `src/extension-host/contributions/provider-model-selection.ts` ahead of broader catalog-backed provider-setup ownership + +How it has been implemented: + +- by moving package metadata parsing behind `src/extension-host/manifests/schema.ts` +- by keeping the existing catalog behavior intact while shifting metadata ownership into normalized host-owned records +- by reusing the resolved-extension registry for static operator/documentation surfaces instead of creating separate metadata caches +- by beginning runtime registration migration with host-owned normalization helpers before attempting full canonical catalog publication +- by beginning actual low-risk runtime write ownership for channel, provider, gateway-method, HTTP-route, tool, CLI, service, command, context-engine, and hook registrations before attempting full canonical catalog publication +- by moving cache-key construction and registry cache control behind host-owned helpers before attempting canonical catalog publication +- by beginning loader-path migration with host-owned compatibility, candidate-planning, import-flow, policy, runtime, register-flow, candidate-orchestration, top-level load orchestration, record-state with compatibility lifecycle mapping, and finalization helpers before attempting canonical catalog publication +- by extracting lazy runtime proxy creation and alias-wired Jiti module-loader creation into host-owned helpers before catalog publication work +- by extracting discovery, manifest loading, manifest diagnostics, discovery-policy logging, provenance building, and candidate ordering into a host-owned loader-bootstrap helper before catalog publication work +- by extracting candidate iteration, manifest lookup, per-candidate session processing, and finalization handoff into a host-owned loader-run helper before catalog publication work +- by converting the compatibility record-state layer into an enforced loader lifecycle state machine before catalog publication work +- by extracting shared discovery warning-cache state and loader reset behavior into a host-owned loader-host-state helper before catalog publication work +- by extracting test-default application, config normalization, cache-key construction, cache-hit activation, and command-clear setup into a host-owned loader-preflight helper before catalog publication work +- by extracting post-preflight execution setup and session-run composition into a host-owned loader-pipeline helper before catalog publication work +- by extracting runtime creation, registry creation, bootstrap setup, module-loader creation, and session creation into a host-owned loader-execution helper before catalog publication work +- by moving mutable activation state into a host-owned loader session before catalog publication work +- by extracting shared provenance path matching and install-rule evaluation into `src/extension-host/policy/loader-provenance.ts` so activation and finalization policy seams reuse one host-owned implementation +- by turning open-allowlist discovery warnings into explicit host-owned discovery-policy results before catalog publication work +- by moving duplicate precedence, config enablement, and early memory-slot gating into explicit host-owned activation-policy outcomes before catalog publication work +- by turning provenance-based untracked-extension warnings and final memory-slot warnings into explicit host-owned finalization-policy results before catalog publication work +- by extracting legacy internal-hook bridging and typed prompt-injection compatibility policy into a host-owned hook-compat helper while leaving actual hook execution ownership unchanged +- by extracting compatibility `OpenClawPluginApi` composition and logger shaping into a host-owned plugin-api helper while keeping the concrete registration callbacks in the legacy registry surface +- by extracting the remaining compatibility plugin-registry facade into a host-owned helper so `src/plugins/registry.ts` becomes a thin wrapper instead of the real owner +- by extracting provider normalization, command duplicate enforcement, and registry-local diagnostic shaping into a host-owned registry-compat helper while leaving the underlying provider-validation and plugin-command subsystems unchanged +- by extracting low-risk registry registration actions into a host-owned registry-registrations helper so the compatibility facade composes host-owned actions instead of implementing them inline +- by extracting service startup, stop ordering, service-context creation, and failure logging into a host-owned service-lifecycle helper before broader catalog-backed service ownership +- by extracting CLI duplicate detection, registrar invocation, and async failure logging into a host-owned CLI-lifecycle helper before broader catalog-backed CLI ownership +- by extracting gateway method-id aggregation, plugin diagnostic shaping, and extra-handler composition into a host-owned gateway-methods helper before broader catalog-backed gateway ownership +- by extracting plugin tool resolution, conflict handling, optional-tool gating, and plugin-tool metadata tracking into a host-owned tool-runtime helper before broader catalog-backed tool ownership +- by extracting provider projection from registry entries into runtime provider objects into a host-owned provider-runtime helper before broader catalog-backed provider ownership +- by extracting provider discovery filtering, order grouping, and result normalization into a host-owned provider-discovery helper before broader catalog-backed provider-discovery ownership +- by extracting provider matching, auth-method selection, config-patch merging, and default-model application into a host-owned provider-auth helper before broader catalog-backed provider-auth ownership +- by extracting provider onboarding option building, model-picker entry building, and provider-method choice resolution into a host-owned provider-wizard helper before broader catalog-backed provider-setup ownership +- by extracting loaded-provider auth application, plugin-enable gating, auth-method execution, and post-auth default-model handling into a host-owned provider-auth-flow helper before broader catalog-backed provider-setup ownership +- by extracting provider post-selection hook lookup and invocation into a host-owned provider-model-selection helper before broader catalog-backed provider-setup ownership +- by extracting provider-id normalization into `src/agents/provider-id.ts` so provider-only host seams do not inherit the heavier agent and browser dependency graph from `src/agents/model-selection.ts` +- by extracting model-ref parsing into `src/agents/model-ref.ts` and Google model-id normalization into `src/agents/google-model-id.ts` so provider auth and setup seams can be tested without pulling the heavier provider-loader and browser dependency graph +- by introducing host-owned runtime-registry accessors for low-risk runtime consumers first, then moving channel, provider, tool, command, HTTP-route, gateway-method, CLI, and service storage into that host-owned state while keeping mirrored legacy compatibility arrays and handler maps before broader catalog publication or arbitration work +- by moving plugin command duplicate enforcement, registration, matching, execution, listing, native command-spec projection, and loader reload clearing into `src/extension-host/contributions/command-runtime.ts` before broader catalog publication or arbitration work + +What remains pending: + +- canonical capability ids +- runtime-derived kernel catalogs +- host-owned operator catalogs beyond the existing lightweight static paths +- arbitration modes and selection logic +- tool/provider/slot migration into one canonical catalog and arbitration model + +## Goals + +- agents see a stable, context-aware catalog of what they can do +- multiple active providers for the same functional area are supported +- collisions are detected and resolved deterministically +- operator commands and runtime backends stay separate from agent tools +- the catalog covers the broader current action surface, not only send and reply +- slot-backed providers such as context engines are selected explicitly +- setup and install metadata stay in host-managed catalogs instead of leaking into runtime catalogs + +## Migration Framing + +This spec replaces existing partial catalog and arbitration behavior already present on `main`. + +It is not a standalone greenfield system. + +Current behavior already exists in at least these places: + +- agent-visible plugin tool grouping in `src/gateway/server-methods/tools-catalog.ts:71` +- provider auth and setup selection in `src/commands/auth-choice.apply.plugin-provider.ts:106` +- slot selection in `src/plugins/slots.ts:39` +- channel picker and onboarding metadata in `src/channels/plugins/catalog.ts:26` + +Implementation rule: + +- Phase 5 and Phase 6 are only complete when those legacy paths have been absorbed into the canonical or host-owned catalog model rather than left as a second source of truth + +## Catalog Types + +The system should maintain separate catalogs for: + +- agent-visible capabilities +- operator-visible capabilities +- runtime-internal providers + +These catalogs may draw from the same contributions but have different visibility and arbitration rules. + +Ownership split: + +- the kernel publishes runtime-derived internal and agent-visible catalogs +- the extension host publishes operator-visible catalogs, including host-only surfaces and any runtime-derived entries the operator surface needs + +## Host-Managed Setup And Install Catalogs + +Current `main` also has host-managed metadata that is not a kernel capability catalog: + +- install metadata from `src/plugins/install.ts:48` +- channel picker and onboarding metadata from `src/channels/plugins/catalog.ts:26` +- lightweight shared channel behavior from `src/channels/dock.ts:228` + +The extension host should keep publishing these static catalogs for setup and operator UX. + +They should not be folded into the agent capability catalog. + +This host-managed layer should also publish: + +- local operator CLI commands from `surface.cli` +- setup and onboarding flows from `surface.setup` +- static channel picker metadata and lightweight dock-derived operator hints without activating heavy runtimes + +Sequencing rule: + +- these host-managed static catalogs should migrate before broad runtime catalog publication because they depend on static metadata, not heavy activation + +## Canonical Capability Model + +Each catalog entry should contain: + +- `capabilityId` +- `kind` +- `canonicalAction` +- `displayName` +- `description` +- `providerKey` +- `scope` +- `availability` +- `requiresSelection` +- `inputSchema` +- `outputSchema` +- `policy` +- `telemetryTags` + +### `capabilityId` + +Stable runtime id for the contribution-backed capability. + +### `canonicalAction` + +A stable action family such as: + +- `message.send` +- `message.reply` +- `directory.lookup` +- `provider.authenticate` +- `provider.configure` +- `memory.search` +- `memory.store` +- `message.broadcast` +- `message.poll` +- `message.react` +- `message.edit` +- `message.delete` +- `message.pin` +- `message.thread.manage` +- `voice.call.start` +- `diff.render` + +The agent planner reasons over canonical actions first. + +Governance decision: + +- canonical action ids are open, namespaced strings +- core action families should still live in one source-of-truth registry in code +- if a new capability fits an existing family, reuse it +- if semantics are new, add a reviewed canonical action id to that registry +- contributions may not define new arbitration modes or planner semantics outside the core catalog and arbitration schema + +### `providerKey` + +Identifies the concrete provider instance behind the action. + +Examples: + +- `messaging:slack:work` +- `messaging:telegram:personal` +- `memory:lancedb:default` +- `runtime-backend:acp:acpx` + +## Visibility Rules + +### Agent-visible + +Used for agent planning and tool calling. + +Includes: + +- agent tools +- channel messaging actions such as send, reply, broadcast, poll, react, edit, delete, pin, and thread actions when available in context +- memory actions when policy allows them +- voice or telephony actions +- selected interaction or workflow actions + +Important interaction rule: + +- interaction-driven actions must be filtered by the current binding and route context +- a bound conversation should only surface interaction actions that are valid for the owning extension and current adapter capabilities + +### Operator-visible + +Used for admin, control, setup, CLI, and diagnostic surfaces. + +Includes: + +- control commands +- setup flows +- provider integration and auth flows +- status surfaces +- CLI commands + +Important distinction: + +- `capability.control-command` is for chat or native commands that bypass the model +- `surface.cli` and `surface.setup` are host-managed local operator surfaces and are not kernel runtime capabilities + +Operator-visible control-command surfaces should preserve current command metadata such as: + +- whether the command accepts arguments +- provider-specific native command names when a provider supports native slash or menu registration + +### Runtime-internal + +Not shown to agents or operators as catalog actions. + +Includes: + +- runtime backends +- context engines +- pure event observers +- route augmenters + +## Conflict Classes + +The host must resolve different conflict types differently. + +### 1. Runtime id conflict + +Fatal during validation. + +### 2. Canonical action overlap + +Multiple providers implement the same action family. + +This is expected for messaging, auth, or directory. + +### 3. Planner-visible name collision + +Two agent-visible capabilities want the same public name. + +This must be resolved before catalog publication. + +### 4. Singleton slot conflict + +Two contributions claim a slot that is intentionally exclusive. + +Examples: + +- default memory backend +- default context engine + +### 5. Route surface conflict + +Two contributions require the same target or routing ownership semantics. + +### 6. Backend selector conflict + +Two runtime backends claim the same selector with incompatible exclusivity. + +## Arbitration Modes + +### `exclusive` + +Exactly one active provider may exist for the slot. + +Examples: + +- one default context engine +- one default memory store, unless the operator opts into parallel memory providers + +### `ranked` + +Many providers may exist, but one default is chosen by rank. + +Examples: + +- multiple auth methods for one provider +- multiple backends for the same subsystem + +### `parallel` + +Many providers may remain simultaneously available. + +Examples: + +- Slack, Discord, and Telegram messaging providers for the same agent +- multiple directory sources + +### `composed` + +Many providers contribute to a single pipeline. + +Examples: + +- context augmentation +- prompt guidance +- telemetry enrichment + +## Agent Catalog Compilation + +The kernel compiles the agent-visible catalog from: + +- active contributions +- current workspace +- current agent +- active session bindings +- route and account context +- current adapter action support +- policy restrictions +- contribution visibility rules + +Catalog compilation is context-sensitive. + +The same agent may see different capability sets in: + +- Slack thread context +- Telegram DM context +- voice call context +- local CLI session + +First-cut migration targets: + +- plugin tools currently exposed by plugin grouping +- messaging actions for the first channel pilot +- route-affecting behaviors that influence whether an action is available at all + +## Capability Selection Rules + +When the agent or runtime needs one provider for a canonical action, selection should use this order: + +1. explicit target or provider selector +2. explicit session binding +3. current conversation or thread route binding +4. current adapter or account capability support +5. policy-forced default +6. ranked default provider +7. deterministic fallback by extension id and contribution id + +This is especially important for `message.send` and `message.reply`. + +It also applies to interaction and conversation-control actions, which should prefer: + +- current binding owner +- current adapter support +- explicit target selection only when ownership or adapter support is ambiguous + +## Messaging Example + +One agent may have: + +- Discord adapter on work account +- Slack adapter on work account +- Telegram adapter on personal account + +The agent should not see three unrelated tools named “send message”. + +Instead it should see canonical action families, with provider resolution handled by: + +- current conversation route +- current session binding +- explicit target selector when needed + +Examples: + +- `message.send` +- `message.reply` +- `message.broadcast` +- `message.poll` +- `message.react` + +If disambiguation is required, the planner or runtime can use structured selectors such as: + +- target channel kind +- account id +- conversation ref + +## Agent Naming Rules + +Agent-visible names must be stable and minimally ambiguous. + +Rules: + +- canonical names belong to action families +- provider labels are attached only when needed for disambiguation +- aliases do not create additional planner-visible tools unless explicitly requested +- the host rejects duplicate planner-visible names when the runtime cannot disambiguate them + +This avoids exposing raw extension names unless necessary. + +## Operator Command Separation + +Control commands are not agent tools. + +Examples today: + +- `src/extension-host/contributions/command-runtime.ts:1` +- `extensions/phone-control/index.ts:330` + +They belong only in operator catalogs and control surfaces. + +## Provider Integration Selection + +Provider integration flows should be modeled as operator-visible capabilities, not agent-visible tools. + +Selection rules: + +- provider id first +- method id second +- rank or policy third + +Multiple auth methods for one provider may coexist. + +The selected provider integration may also contribute: + +- discovery order +- onboarding metadata +- token refresh behavior +- model-selected hooks + +It should not silently absorb unrelated subsystem runtimes such as embeddings, transcription, media understanding, or TTS. +It should also not silently absorb agent-visible search surfaces, which belong in the agent-tool catalog even when they call remote search services. + +## Memory Arbitration + +Memory needs both backend arbitration and agent action arbitration. + +### Backend arbitration + +Usually `exclusive` or `ranked`. + +### Agent action arbitration + +May still expose: + +- `memory.search` +- `memory.store` + +If parallel memory providers are enabled, the planner should either target the default store or use explicit selectors. + +## Context Engine Arbitration + +Context engines are runtime-internal providers selected through an explicit exclusive slot. + +Selection rules: + +- explicit configured engine id wins +- otherwise use the slot default +- if the selected engine is unavailable, fail with a typed configuration error rather than silently picking an arbitrary fallback + +## Runtime Backend Arbitration + +Runtime backends such as ACP are runtime-internal providers. + +Selection rules: + +- explicit backend id wins +- otherwise use healthy highest-ranked backend +- if a subsystem declares an exclusive slot, the host enforces it before kernel startup + +This is why `capability.runtime-backend` must be a first-class family. + +The same model should be available for other subsystem runtimes discovered during migration: + +- embeddings +- audio transcription +- image understanding +- video understanding +- text-to-speech + +Selection rules for these subsystem runtimes should preserve these required behaviors: + +- capability-based selection +- normalized provider ids +- explicit built-in fallback policy +- typed host-injected request envelopes + +Architecture rule: + +- keep those selection and envelope rules inside host-owned subsystem runtime registries for typed backend families +- do not widen provider-integration or legacy plugin-provider APIs into a universal surface for unrelated runtime subsystems +- if search is agent-visible, publish it through canonical tool catalogs; reserve `capability.runtime-backend` for search backends that are consumed internally by the host or another subsystem + +## Catalog Publication + +The kernel should publish: + +- a full internal catalog +- a filtered agent catalog + +The extension host should publish: + +- a filtered operator catalog + +Publication should occur after: + +- dependency resolution +- policy approval +- contribution activation +- route and account context binding + +Host-managed install and onboarding descriptors may move into host ownership earlier because they come from static metadata, not runtime activation. + +Full catalog publication, consolidation, and legacy-path replacement still belong to the catalog-migration phase. + +Performance requirement: + +- publishing host-managed setup and install catalogs must not require activating heavy adapter runtimes +- publishing operator-visible static catalogs must preserve current dock-style cheap-path behavior, including prompt hints and shared formatting helpers where those are consumed without runtime activation + +## Telemetry And Auditing + +Capability selection must emit structured events for: + +- conflict detection +- provider selection +- fallback selection +- planner-visible disambiguation +- veto or cancellation caused by route augmenters +- slot selection for context engines or other exclusive runtime providers + +## Migration Mapping From Today + +- channel capabilities from `extensions/discord/src/channel.ts:74`, `extensions/slack/src/channel.ts:107`, and `extensions/telegram/src/channel.ts:120` collapse into canonical messaging action families +- diffs becomes an agent-visible tool family plus a host-managed route surface from `extensions/diffs/index.ts:27` +- provider integration from `extensions/google-gemini-cli-auth/index.ts:24` becomes operator-visible setup and auth capabilities +- embedding, media-understanding, and TTS provider overrides should become runtime-internal subsystem registries rather than remaining part of a universal plugin-provider API +- extension-backed web search should become an agent-visible tool family unless it is only a runtime-internal backend feeding another host-owned surface +- voice-call from `extensions/voice-call/index.ts:230` becomes a mix of agent-visible actions, runtime providers, and operator surfaces +- ACP backend registration from `extensions/acpx/src/service.ts:55` becomes runtime-internal backend arbitration +- context-engine registration becomes runtime-internal slot arbitration from `src/context-engine/registry.ts:60` +- native command registration remains an operator or transport surface concern rather than an agent-visible catalog concern + +## Immediate Implementation Work + +1. Add canonical action ids and provider keys to resolved contributions. +2. Implement host-side conflict detection for planner-visible names and singleton slots. +3. Implement kernel-side context-aware catalog compilation. +4. Add host-managed static catalogs for install and onboarding metadata alongside the runtime catalogs. +5. Migrate the existing plugin tool grouping path onto canonical agent catalog entries. +6. Migrate the existing provider auth and setup selection path onto host-owned setup catalogs and canonical provider metadata. +7. Add provider selection logic for the broader messaging action family before migrating all channels. +8. Add runtime-backend and context-engine arbitration using the same rank and slot model where appropriate. +9. Add host-owned embedding, media-understanding, and TTS subsystem registries with explicit capability routing and built-in fallback policy. +10. Decide whether extension-backed search needs only canonical tool publication or also a host-owned runtime registry for internal search backends, and keep those two cases distinct. +11. Ensure lightweight setup catalogs can be built from static descriptors alone. +12. Add a reviewed core registry for canonical action families and document how new ids are introduced. +13. Record catalog and arbitration parity for `thread-ownership` first and `telegram` second before broader rollout. diff --git a/docs/.internal/extension-host-migration/openclaw-extension-contribution-schema-spec.md b/docs/.internal/extension-host-migration/openclaw-extension-contribution-schema-spec.md new file mode 100644 index 00000000000..dc8cce2429c --- /dev/null +++ b/docs/.internal/extension-host-migration/openclaw-extension-contribution-schema-spec.md @@ -0,0 +1,1042 @@ +Temporary internal migration note: remove this document once the extension-host migration is complete. + +# OpenClaw Extension Contribution Schema Spec + +Date: 2026-03-15 + +## Purpose + +This document defines the concrete schema the extension host uses to convert extension packages into resolved runtime contributions for the kernel. + +The kernel must never parse plugin manifests or interpret package layout directly. It only receives validated contribution objects. + +## TODOs + +- [ ] Finalize TypeScript source-of-truth types for `ResolvedExtension`, `ResolvedContribution`, and `ContributionPolicy`. +- [ ] Implement manifest and contribution validators from this schema. +- [ ] Lock the static distribution metadata shape, including full channel catalog parity fields. +- [ ] Lock the package metadata and static distribution parsing contract used by install, onboarding, status, and lightweight UX flows. +- [ ] Lock the `surface.config`, `surface.setup`, and `capability.control-command` descriptor shapes. +- [ ] Preserve minimal SDK compatibility loading while this schema replaces legacy runtime assumptions. +- [ ] Record pilot parity and schema adjustments for `thread-ownership` first and `telegram` second. +- [ ] Record any schema changes discovered during the first pilot migration. + +## Implementation Status + +Current status against this spec: + +- the initial source-of-truth types have landed in code, but they are not final +- static normalization work has started +- validators and explicit compatibility translation work have not landed + +What has been implemented: + +- an initial Phase 0 cutover inventory now exists in `src/extension-host/cutover-inventory.md` +- `ResolvedExtension`, `ResolvedContribution`, and `ContributionPolicy` now exist in `src/extension-host/manifests/schema.ts` +- a legacy-to-normalized adapter now builds `ResolvedExtension` records from current plugin manifests and package metadata +- package metadata parsing for discovery, install, and channel catalog paths now routes through host-owned schema helpers +- manifest-registry records now carry a normalized `resolvedExtension` +- a host-owned resolved-extension registry now exists for static consumers +- config doc baseline generation now reads bundled extension metadata through the resolved-extension registry +- the first runtime registration normalization helpers now exist in `src/extension-host/contributions/runtime-registrations.ts` for channel, provider, HTTP-route, gateway-method, tool, CLI, service, command, context-engine, and hook writes +- low-risk runtime compatibility writes for channel, provider, gateway-method, HTTP-route, tool, CLI, service, command, context-engine, and hook registrations now route through `src/extension-host/contributions/registry-writes.ts` +- legacy internal-hook bridging and typed prompt-injection compatibility policy now route through `src/extension-host/compat/hook-compat.ts` +- compatibility `OpenClawPluginApi` composition and logger shaping now route through `src/extension-host/compat/plugin-api.ts` +- compatibility plugin-registry facade ownership now routes through `src/extension-host/compat/plugin-registry.ts` +- compatibility plugin-registry policy now routes through `src/extension-host/compat/plugin-registry-compat.ts` +- compatibility plugin-registry registration actions now route through `src/extension-host/compat/plugin-registry-registrations.ts` +- service startup, stop ordering, service-context creation, and failure logging now route through `src/extension-host/contributions/service-lifecycle.ts` +- CLI duplicate detection, registrar invocation, and async failure logging now route through `src/extension-host/contributions/cli-lifecycle.ts` +- gateway method-id aggregation, plugin diagnostic shaping, and extra-handler composition now route through `src/extension-host/contributions/gateway-methods.ts` +- plugin tool resolution, conflict handling, optional-tool gating, and plugin-tool metadata tracking now route through `src/extension-host/contributions/tool-runtime.ts` +- plugin provider projection from registry entries into runtime provider objects now route through `src/extension-host/contributions/provider-runtime.ts` +- channel registrations now also keep host-owned runtime-registry storage with mirrored legacy compatibility views, and channel readers now consume the same host-owned boundary +- provider, tool, and command registrations now also keep host-owned runtime-registry storage with mirrored legacy compatibility views +- plugin command registration, matching, execution, listing, and native command-spec projection now route through `src/extension-host/contributions/command-runtime.ts` while `src/plugins/commands.ts` remains the compatibility facade +- plugin provider discovery filtering, order grouping, and result normalization now route through `src/extension-host/contributions/provider-discovery.ts` +- provider matching, auth-method selection, config-patch merging, and default-model application now route through `src/extension-host/contributions/provider-auth.ts` +- provider onboarding option building, model-picker entry building, and provider-method choice resolution now route through `src/extension-host/contributions/provider-wizard.ts` +- loaded-provider auth application, plugin-enable gating, auth-method execution, and post-auth default-model handling now route through `src/extension-host/contributions/provider-auth-flow.ts` +- provider post-selection hook lookup and invocation now route through `src/extension-host/contributions/provider-model-selection.ts` +- plugin SDK alias resolution now routes through `src/extension-host/compat/loader-compat.ts` +- loader alias-wired module loader creation now routes through `src/extension-host/activation/loader-module-loader.ts` +- loader cache key construction and registry cache control now route through `src/extension-host/activation/loader-cache.ts` +- loader lazy runtime proxy creation now routes through `src/extension-host/activation/loader-runtime-proxy.ts` +- loader provenance helpers now route through `src/extension-host/policy/loader-provenance.ts` +- loader duplicate-order and record/error policy now route through `src/extension-host/policy/loader-policy.ts` +- loader discovery policy outcomes now route through `src/extension-host/policy/loader-discovery-policy.ts` +- loader initial candidate planning and record creation now route through `src/extension-host/activation/loader-records.ts` +- loader entry-path opening and module import now route through `src/extension-host/activation/loader-import.ts` +- loader module-export resolution, config validation, and memory-slot load decisions now route through `src/extension-host/activation/loader-runtime.ts` +- loader post-import planning and `register(...)` execution now route through `src/extension-host/activation/loader-register.ts` +- loader per-candidate orchestration now routes through `src/extension-host/activation/loader-flow.ts` +- loader top-level load orchestration now routes through `src/extension-host/activation/loader-orchestrator.ts` +- loader host process state now routes through `src/extension-host/activation/loader-host-state.ts` +- loader preflight and cache-hit setup now routes through `src/extension-host/activation/loader-preflight.ts` +- loader post-preflight pipeline composition now routes through `src/extension-host/activation/loader-pipeline.ts` +- loader execution setup composition now routes through `src/extension-host/activation/loader-execution.ts` +- loader discovery and manifest bootstrap now routes through `src/extension-host/activation/loader-bootstrap.ts` +- loader mutable activation state now routes through `src/extension-host/activation/loader-session.ts` +- loader session run and finalization composition now routes through `src/extension-host/activation/loader-run.ts` +- loader activation policy outcomes now route through `src/extension-host/policy/loader-activation-policy.ts` +- loader record-state transitions now route through `src/extension-host/activation/loader-state.ts`, which now enforces an explicit loader lifecycle state machine while preserving compatibility `PluginRecord.status` values +- loader finalization policy results now route through `src/extension-host/policy/loader-finalization-policy.ts` +- loader final cache, readiness promotion, and activation finalization now routes through `src/extension-host/activation/loader-finalize.ts` + +How it has been implemented: + +- by wrapping current manifest and package metadata rather than replacing the plugin loader outright +- by introducing a compatibility-oriented `resolveLegacyExtensionDescriptor(...)` path first +- by moving static metadata consumers onto the normalized model before attempting runtime contribution migration +- by keeping legacy manifest records available only as compatibility projections while new readers move to the normalized shape +- by starting runtime contribution migration with normalization helpers that preserve the legacy plugin API surface +- by starting actual low-risk runtime write ownership for channel, provider, gateway-method, HTTP-route, tool, CLI, service, command, context-engine, and hook registrations only after normalization helpers preserved the legacy plugin API surface +- by making cache-key construction and registry cache control explicit host-owned seams before changing loader activation-state ownership +- by making the first loader compatibility, candidate-planning, import-flow, runtime-decision, register-flow, candidate-orchestration, top-level load orchestration, record-state with compatibility lifecycle mapping, and finalization helpers explicit host-owned seams before introducing a versioned compatibility layer +- by extracting lazy runtime proxy creation and alias-wired Jiti module-loader creation into host-owned helpers before broader schema-driven lifecycle ownership changes +- by extracting discovery, manifest loading, manifest diagnostics, discovery-policy logging, provenance building, and candidate ordering into a host-owned loader-bootstrap helper before broader schema-driven lifecycle ownership changes +- by extracting candidate iteration, manifest lookup, per-candidate session processing, and finalization handoff into a host-owned loader-run helper before broader schema-driven lifecycle ownership changes +- by turning the compatibility record-state layer into an enforced loader lifecycle state machine before broadening the schema-driven host lifecycle model +- by extracting shared discovery warning-cache state and loader reset behavior into a host-owned loader-host-state helper before broadening the schema-driven host lifecycle model +- by extracting test-default application, config normalization, cache-key construction, cache-hit activation, and command-clear setup into a host-owned loader-preflight helper before broadening the schema-driven host lifecycle model +- by extracting post-preflight execution setup and session-run composition into a host-owned loader-pipeline helper before broadening the schema-driven host lifecycle model +- by extracting runtime creation, registry creation, bootstrap setup, module-loader creation, and session creation into a host-owned loader-execution helper before broadening the schema-driven host lifecycle model +- by moving mutable activation state into a host-owned loader session before broadening the schema-driven host lifecycle model +- by extracting shared provenance path matching and install-rule evaluation into `src/extension-host/policy/loader-provenance.ts` so activation and finalization policy seams reuse one host-owned implementation +- by turning open-allowlist discovery warnings into explicit host-owned discovery-policy results before broadening the schema-driven host lifecycle model +- by moving duplicate precedence, config enablement, and early memory-slot gating into explicit host-owned activation-policy outcomes before broadening the schema-driven host lifecycle model +- by turning provenance-based untracked-extension warnings and final memory-slot warnings into explicit host-owned finalization-policy results before broadening the schema-driven host lifecycle model +- by extracting legacy internal-hook bridging and typed prompt-injection compatibility policy into a host-owned hook-compat helper while leaving actual hook execution ownership unchanged +- by extracting compatibility `OpenClawPluginApi` composition and logger shaping into a host-owned plugin-api helper while keeping the concrete registration callbacks in the legacy registry surface +- by extracting the remaining compatibility plugin-registry facade into a host-owned helper so `src/plugins/registry.ts` becomes a thin wrapper instead of the real owner +- by extracting provider normalization, command duplicate enforcement, and registry-local diagnostic shaping into a host-owned registry-compat helper while leaving the underlying provider-validation and plugin-command subsystems unchanged +- by extracting low-risk registry registration actions into a host-owned registry-registrations helper so the compatibility facade composes host-owned actions instead of implementing them inline +- by extracting service startup, stop ordering, service-context creation, and failure logging into a host-owned service-lifecycle helper while `src/plugins/services.ts` remains the compatibility entry point +- by extracting CLI duplicate detection, registrar invocation, and async failure logging into a host-owned CLI-lifecycle helper while `src/plugins/cli.ts` remains the compatibility entry point +- by extracting gateway method-id aggregation, plugin diagnostic shaping, and extra-handler composition into a host-owned gateway-methods helper while request dispatch semantics remain in the gateway server code +- by extracting plugin tool resolution, conflict handling, optional-tool gating, and plugin-tool metadata tracking into a host-owned tool-runtime helper while `src/plugins/tools.ts` remains the loader and config-normalization facade +- by extracting provider projection from registry entries into runtime provider objects into a host-owned provider-runtime helper while `src/plugins/providers.ts` remains the loader and config-normalization facade +- by moving channel registration storage into host-owned runtime-registry state after channel lookup readers were already on the host boundary, while keeping mirrored legacy compatibility arrays +- by moving provider and tool registration storage into host-owned runtime-registry state after their runtime readers were already host-owned, while keeping mirrored legacy compatibility arrays +- by extracting provider discovery filtering, order grouping, and result normalization into a host-owned provider-discovery helper while `src/plugins/provider-discovery.ts` remains the compatibility facade around the legacy provider loader path +- by extracting provider matching, auth-method selection, config-patch merging, and default-model application into a host-owned provider-auth helper while `src/commands/provider-auth-helpers.ts` remains the command-facing compatibility facade +- by extracting provider onboarding option building, model-picker entry building, and provider-method choice resolution into a host-owned provider-wizard helper while `src/plugins/provider-wizard.ts` remains the compatibility facade around loader-backed provider access and post-selection hooks +- by extracting loaded-provider auth application, plugin-enable gating, auth-method execution, and post-auth default-model handling into a host-owned provider-auth-flow helper while `src/commands/auth-choice.apply.plugin-provider.ts` remains the compatibility entry point +- by extracting provider post-selection hook lookup and invocation into a host-owned provider-model-selection helper while `src/plugins/provider-wizard.ts` remains the compatibility facade and existing command consumers continue migrating onto the host-owned surface +- by extracting provider-id normalization into `src/agents/provider-id.ts` so provider-only host seams do not inherit the heavier agent and browser dependency graph from `src/agents/model-selection.ts` +- by extracting model-ref parsing into `src/agents/model-ref.ts` and Google model-id normalization into `src/agents/google-model-id.ts` so provider auth and setup seams can be tested without pulling the heavier provider-loader and browser dependency graph + +What remains pending: + +- final schema shape +- manifest and contribution validators +- explicit `surface.setup` and `capability.control-command` descriptor work +- minimal SDK compatibility loading as an intentional, versioned compatibility layer rather than the current host-owned helper layering around the old loader path + +## Design Goals + +- one schema for bundled and external extensions +- one contribution model for channels, auth, memory, tools, ACP, voice, diffs, and future extension types +- explicit ids, scopes, dependencies, permissions, and arbitration metadata +- lightweight static descriptors for install, onboarding, and shared UX paths +- truthful permission semantics that do not imply sandboxing where none exists +- preserve prompt-mutation policy and adapter UX descriptors that exist outside simple send and receive +- enough structure for the host to detect conflicts before activation + +## Sequencing Constraints + +This schema must be introduced without breaking current extension loading. + +Therefore: + +- the first implementation cut must preserve current `openclaw/plugin-sdk/*` imports through compatibility loading +- static distribution metadata must be modeled as first-class schema, not deferred until after runtime contribution migration +- package-level metadata and manifest-level metadata must converge into one normalized `ResolvedExtension` model +- the first pilots should be `thread-ownership` first and `telegram` second, because they validate different schema surfaces with limited extra migration noise + +## Runtime Boundary + +The package or bundle unit is an extension. + +The runtime unit is a contribution. + +One extension may emit many contributions. The extension host is responsible for: + +- loading the extension manifest +- validating all contribution descriptors +- rejecting or isolating invalid contributions +- constructing resolved contribution objects for the kernel +- preserving static host-owned descriptors used by install, onboarding, and status UX + +## Manifest Shape + +Recommended manifest shape: + +```json +{ + "id": "openclaw.discord", + "name": "Discord", + "version": "2026.3.0", + "apiVersion": "1.0", + "entry": "./index.ts", + "description": "Discord transport and interaction support", + "bundled": true, + "permissionMode": "advisory", + "tags": ["channel", "messaging"], + "dependencies": { + "requires": [], + "optional": [] + }, + "permissions": [ + "runtime.adapter", + "network.outbound", + "credentials.read", + "credentials.write", + "http.route.gateway" + ], + "config": { + "schema": {}, + "uiHints": {} + }, + "distribution": { + "install": { + "entries": ["./dist/index.js"], + "npmSpec": "@openclaw/discord", + "defaultChoice": "npm" + }, + "catalog": { + "channels": [ + { + "id": "discord", + "label": "Discord", + "docsPath": "/channels/discord" + } + ] + } + }, + "contributions": [ + { + "id": "discord.adapter", + "kind": "adapter.runtime", + "title": "Discord messaging adapter" + } + ] +} +``` + +## Required Top-Level Fields + +- `id` + Stable extension package id. Never reused for a different extension. +- `name` + Human-facing name for operator surfaces. +- `version` + Extension package version. +- `apiVersion` + Extension-host contract version the package was built against. +- `entry` + Entry module the host activates. +- `distribution` + Static metadata for install, onboarding, config, status, and lightweight operator UX. The block may be empty, but the field family is part of the source-of-truth shape. +- `contributions` + List of contribution descriptors emitted by the extension. + +## Recommended Top-Level Fields + +- `description` +- `bundled` +- `permissionMode` +- `tags` +- `dependencies` +- `permissions` +- `config.schema` +- `config.uiHints` +- `distribution` +- `docs` +- `homepage` +- `support` + +`bundled` is host metadata only. The kernel must never receive or depend on it. + +Implementation rule: + +- `distribution`, config metadata, and package metadata must be parseable without activating the extension entry module + +## Permission Semantics + +`permissions` describe requested host-managed powers and operator risk. + +They do not automatically imply a hard runtime sandbox. + +Recommended top-level field: + +- `permissionMode` + - `advisory` + - `host-enforced` + - `sandbox-enforced` + +For the first extension-host cut, the default is `advisory` because extensions still run as trusted in-process code. + +## Contribution Descriptor + +Every contribution descriptor must contain: + +- `id` + Stable within the extension. The host resolves the runtime id as `/`. +- `kind` + Contribution family. +- `title` + Human-facing label. + +Recommended common fields: + +- `description` +- `aliases` +- `tags` +- `enabledByDefault` +- `scope` +- `arbitration` +- `dependsOn` +- `permissions` +- `visibility` +- `capabilities` +- `selectors` +- `priority` +- `policy` + +## Common Contribution Fields + +### `scope` + +Describes where the contribution is valid. + +Supported scope fields: + +- `global` +- `workspace` +- `agent` +- `account` +- `channel` +- `conversation` +- `provider` + +Examples: + +- a Slack adapter contribution is typically scoped by `account` and `channel` +- a memory backend is usually `workspace` or `agent` +- a provider integration contribution is scoped by `provider` + +### `arbitration` + +Declares how the host and kernel should treat overlapping providers. + +Supported modes: + +- `exclusive` +- `ranked` +- `parallel` +- `composed` + +Supported attributes: + +- `mode` +- `defaultRank` +- `singletonSlot` +- `selectionKey` +- `composeOrder` + +### `visibility` + +Declares whether the contribution is visible to: + +- agents +- operators +- both +- neither + +This matters because many contributions are runtime-only and must never appear in the agent tool catalog. + +### `policy` + +Declares host-managed policy gates that are more specific than broad permissions. + +Examples: + +- prompt mutation allowed, constrained, or denied +- fail-open versus fail-closed routing behavior +- whether a contribution may run on sync transcript hot paths + +Decision for the first foundation cut: + +```ts +type ContributionPolicy = { + promptMutation?: "none" | "append-only" | "replace-allowed"; + routeEffect?: "observe-only" | "augment" | "veto" | "resolve"; + failureMode?: "fail-open" | "fail-closed"; + executionMode?: "parallel" | "sequential" | "sync-sequential"; +}; +``` + +These fields should be typed, not left as arbitrary metadata. + +First-cut rule: + +- keep `policy` limited to parity-driving behaviors unless the pilot migrations prove a broader typed model is necessary + +### `dependsOn` + +Contribution-level dependencies. + +Supported dependency types: + +- `requires` +- `optional` +- `conflicts` +- `supersedes` + +Dependencies reference contribution ids, not package names, because runtime behavior is contribution-driven. + +## Contribution Families + +### Kernel-facing families + +- `adapter.runtime` +- `capability.agent-tool` +- `capability.control-command` +- `capability.provider-integration` +- `capability.memory` +- `capability.context-engine` +- `capability.context-augmenter` +- `capability.event-handler` +- `capability.route-augmenter` +- `capability.interaction` +- `capability.rpc` +- `capability.runtime-backend` + +### Host-managed families + +- `service.background` +- `surface.cli` +- `surface.config` +- `surface.status` +- `surface.setup` +- `surface.http-route` + +## Family Contracts + +### `adapter.runtime` + +Used for messaging transports and any ingress or egress runtime. + +Required runtime contract: + +- `startAccount(accountRef)` +- `stopAccount(accountRef)` +- `decodeIngress(rawEvent)` +- `send(outboundEnvelope)` +- `health()` + +Optional runtime contract: + +- `handleAction(actionRef, payload)` +- `edit(outboundEnvelope)` +- `delete(targetRef)` +- `react(targetRef, reaction)` +- `poll(targetRef, pollPayload)` +- `fetchThread(threadRef)` +- `fetchMessage(messageRef)` +- `resolveDirectory(query)` +- `openConversation(target)` + +Required descriptor metadata: + +- supported conversation kinds +- identity scheme +- account binding model +- supported message action set +- supported interaction classes +- whether the adapter supports edits, deletes, reactions, polls, threads, buttons, cards, modals, moderation, or admin actions +- lightweight dock metadata for shared code paths that must not load the heavy runtime +- optional shared UX descriptors for typing, delivery feedback, reply context, history hints, and streaming behavior +- optional reload descriptors for config-driven hot-restart or no-op behavior +- optional gateway feature descriptors for method advertisement or transport-owned control surfaces + +Important distinction: + +- callable gateway or runtime methods belong in `capability.rpc` +- adapter-level gateway feature descriptors are metadata only +- those descriptors may advertise compatibility features, native transport affordances, or transport-owned control surfaces during migration, but they do not define a second callable RPC surface + +The dock metadata is host-only. It is the normalized replacement for the current lightweight channel dock behavior in `src/channels/dock.ts:228`. + +The lightweight dock contract should be specific enough to preserve current host-shared behavior from `main`, including: + +- command gating hints +- allow-from formatting and default-target helpers +- threading defaults and reply-context helpers +- elevated allow-from fallback behavior +- agent prompt hints such as `messageToolHints` + +### `capability.agent-tool` + +Represents an agent-visible action. + +This is the correct family for extension-backed search when the search surface is directly exposed to the agent, for example a canonical `web.search` or workspace-search action. + +Required descriptor metadata: + +- canonical action id +- planner-visible name +- input schema +- output schema or result contract +- visibility rules +- targeting requirements + +### `capability.control-command` + +Represents operator-facing commands that bypass the agent. + +Examples today: + +- `extensions/phone-control/index.ts:330` +- current plugin command registrations routed through `src/extension-host/contributions/command-runtime.ts:1` with `src/plugins/commands.ts:1` kept as the compatibility facade + +Required descriptor metadata: + +- command name +- description +- auth requirement +- surface availability +- whether the command accepts arguments +- optional provider-specific native command names for native slash or menu surfaces + +Behavior rule: + +- if a command does not accept arguments and arguments are supplied, the host should treat that invocation as a non-match and allow normal built-in or agent handling to continue + +This preserves current behavior in `src/extension-host/contributions/command-runtime.ts:127`. + +These are not agent tools. + +### `capability.provider-integration` + +Represents provider discovery, setup, auth, and post-selection lifecycle for model providers. + +Required descriptor metadata: + +- provider id +- auth method ids +- auth kinds +- discovery order +- wizard or onboarding metadata +- credential outputs +- optional config patch outputs +- optional refresh contract +- optional model-selected lifecycle hooks + +This family exists because today's provider plugin contract includes more than auth, as shown in `src/plugins/types.ts:158`. + +Scope rule: + +- this family is specifically for chat or model-provider discovery, setup, auth, and post-selection lifecycle +- agent-visible search should not be folded into this family only because it may call remote providers under the hood +- non-chat subsystem providers such as embeddings, transcription, image understanding, video understanding, and TTS should not be folded into this family only because they also use remote providers +- those subsystem runtimes should use typed runtime contributions registered in host-owned runtime registries, usually under `capability.runtime-backend` with subsystem-specific capability metadata + +### `capability.memory` + +Represents a memory store or memory query runtime. + +Required descriptor metadata: + +- store kind +- supported query modes +- write policy +- default arbitration mode + +### `capability.context-engine` + +Represents a context-engine factory selected through an exclusive slot. + +Required descriptor metadata: + +- engine id +- singleton slot id +- factory contract +- default rank +- config selector key + +### `capability.context-augmenter` + +Represents a contribution that enriches prompt, tool, or session context without taking routing ownership. + +Examples today: + +- `extensions/diffs/index.ts:38` +- auto-recall style prompt/context contributions in `extensions/memory-lancedb/index.ts:548` + +Recommended policy metadata: + +- `promptMutation` + - `none` + - `append-only` + - `replace-allowed` + +This preserves behavior currently gated by `plugins.entries..hooks.allowPromptInjection`. + +### `capability.event-handler` + +Represents observers or side-effect handlers on canonical kernel events. + +This family cannot mutate routing or veto delivery unless it is explicitly declared as `capability.route-augmenter`. + +Required descriptor metadata: + +- target event families +- handler class +- execution mode +- optional bridge source when the contribution originates from legacy hook or event systems + +### `capability.route-augmenter` + +Represents runtime handlers that can influence routing, binding, or egress decisions. + +Examples today: + +- send veto behavior in `extensions/thread-ownership/index.ts:63` +- subagent delivery target selection in `extensions/discord/src/subagent-hooks.ts:103` + +Required descriptor metadata: + +- allowed decision classes +- target event families +- fail-open or fail-closed behavior +- whether the handler must run on a sync hot path + +Additional first-cut cases that should be modeled here: + +- ingress claim or bound-route ownership decisions +- explicit conversation binding requests or detaches +- send veto decisions + +Additional metadata when binding or ingress claim is involved: + +- binding mode (`claim-only`, `bind`, `detach`, or `mixed`) +- whether operator approval is required before a bind can take effect +- persistence mode for approved or detached bindings +- restore-on-restart behavior +- whether the handler is allowed to short-circuit downstream command or agent dispatch + +Important migration rule: + +- do not preserve `inbound_claim` as a permanent plugin-era hook name in the final model +- bridge it during migration if needed, but normalize it into canonical ingress-stage route-augmentation behavior + +### `capability.interaction` + +Represents canonical interaction handlers such as slash commands, buttons, form submissions, or modal actions. + +Required descriptor metadata: + +- interaction namespace or routing key +- supported interaction classes +- supported channels or adapter families +- dedupe class or id strategy when callbacks may be retried +- fallback policy when no handler claims the interaction +- whether the handler may request conversation binding or detachment + +First-cut constraint: + +- the first interactive runtime cut should be validated on Telegram and Discord if they remain the highest-priority channels for parity +- that is rollout order only; the contract itself must stay generic, host-owned, and kernel-agnostic rather than becoming Telegram- or Discord-specific handler APIs + +Ownership rule: + +- extensions may own interaction logic and channel-specific rendering details +- the host owns namespace registration, dedupe, callback routing, approval persistence, and binding policy + +Terminology clarification: + +- plugin-specific behavior lives in the extension package +- transport-specific runtime behavior lives in that extension's `adapter.runtime` contribution +- the kernel only consumes generic contracts and must not learn Telegram-, Discord-, or plugin-specific behavior directly + +### `capability.rpc` + +Represents internal callable methods that are not agent tools. + +Examples today: + +- voice-call gateway methods in `extensions/voice-call/index.ts:230` + +This family is the only place callable gateway-style methods should live. + +If an adapter or transport wants to advertise that such methods exist, it may do so through metadata only, but the callable contract itself still belongs to `capability.rpc`. + +### `capability.runtime-backend` + +Represents a backend runtime provider used by another subsystem rather than directly by the agent. + +ACP is the reference example: + +- `extensions/acpx/src/service.ts:55` +- `src/acp/runtime/registry.ts:4` + +Required descriptor metadata: + +- backend class id +- selector key +- health probe contract +- default selection rank +- exclusivity or parallelism policy + +This family exists because not all runtime providers are user-facing adapters. + +This family is also the right home for plugin-provided subsystem runtimes when the runtime is consumed by a host or subsystem rather than directly by the agent. + +Examples to support during migration: + +- embeddings +- audio transcription +- image understanding +- video understanding +- text-to-speech +- search backends only when they are runtime-internal and not directly exposed as agent tools + +Required metadata for these subsystem runtimes: + +- subsystem id such as `embedding`, `media.audio`, `media.image`, `media.video`, or `tts` +- supported capability list +- typed request envelope contract +- provider-id normalization rules +- fallback policy +- override policy when a built-in implementation already exists + +Retained behavior requirements: + +- capability-based routing is worth keeping +- typed host-injected request fields such as `apiKey`, `baseUrl`, `headers`, `timeoutMs`, and `fetchFn` are worth keeping +- graceful fallback to built-in implementations is worth keeping + +Important rule: + +- keep these inside host-owned runtime registries for typed backend families +- do not widen `registerProvider(...)` into the permanent universal surface for every runtime subsystem +- if search is directly agent-visible, model it as `capability.agent-tool` instead of treating it as a generic provider family + +### Adapter-runtime helper contracts + +Some interactive and bound-conversation extensions need a bounded set of runtime helper contracts from the active adapter. + +Examples of first-cut helpers: + +- typing leases +- message edit or delete +- component or reply-markup updates +- pin or unpin +- thread or topic rename when already supported by the adapter + +Important rule: + +- these helpers should be modeled as adapter-runtime contracts or host-injected capabilities +- they should not be frozen into permanent Telegram- or Discord-shaped kernel APIs + +### `service.background` + +Represents long-running extension-managed processes owned by the host. + +Examples today: + +- `extensions/acpx/index.ts:10` +- `extensions/voice-call/index.ts:510` +- `extensions/diagnostics-otel/index.ts:10` + +Required descriptor metadata: + +- state scope +- desired state subdirectory +- startup ordering +- optional health contract + +### `surface.http-route` + +Represents host-managed HTTP or webhook surfaces. + +Examples today: + +- `extensions/diffs/index.ts:28` +- current plugin route registration in `src/plugins/http-registry.ts:12` + +Required descriptor metadata: + +- path +- auth mode +- match mode +- route owner id +- route class +- lifecycle mode (`static` or `dynamic`) +- scope metadata for account- or workspace-scoped routes + +### `surface.config`, `surface.status`, `surface.setup`, `surface.cli` + +These are operator surfaces, not kernel runtime behavior. + +They must remain host-managed. + +#### `surface.config` + +Represents extension-provided config schema and config UI metadata consumed by host config APIs and operator UIs. + +Required descriptor metadata: + +- config schema +- UI hints +- sensitivity metadata for secret-bearing fields +- redaction and restoration compatibility requirements for round-tripping edited config + +Important rule: + +- `uiHints.sensitive` is not cosmetic metadata only +- the host must preserve current redaction and restore semantics used by config read and write flows, as in `src/gateway/server-methods/config.ts:151` and `src/config/redact-snapshot.ts:349` + +#### `surface.cli` + +Represents local operator CLI commands and subcommands registered under host-owned command trees. + +Supported use cases: + +- standalone diagnostic or admin commands +- install or update helpers +- provider-specific local operator commands +- entrypoints into interactive setup flows + +Required descriptor metadata: + +- command path or command id +- short description +- invocation mode (`standalone`, `subcommand`, or `flow-entry`) +- whether the command is interactive, non-interactive, or both + +#### `surface.setup` + +Represents host-managed setup and onboarding flows owned by an extension. + +Supported use cases: + +- interactive onboarding wizards +- non-interactive setup for automation or CI +- provider auth and configuration flows +- channel onboarding and account setup + +Required descriptor metadata: + +- flow id +- target surface (`cli`, `status`, `setup-ui`, or similar host surface) +- supported modes (`interactive`, `non-interactive`, or both) +- typed outputs such as config patches, credential results, install requests, status notes, or follow-up actions +- optional status phase for setup discovery and quickstart ranking +- optional reconfigure or already-configured flow +- optional disable flow +- optional DM policy prompts or policy patch outputs +- optional account-recording callback outputs for host-owned persistence + +Ownership rule: + +- extensions may own the flow logic +- the host owns prompting, persistence, credential writes, and command-tree integration + +The setup contract should be able to represent the current onboarding adapter phases in `src/channels/plugins/onboarding-types.ts:59`, including: + +- `getStatus` +- `configure` +- `configureInteractive` +- `configureWhenConfigured` +- `disable` + +Recommended status metadata: + +- whether the target is configured +- status lines +- optional selection hint +- optional quickstart score + +## Static Distribution Metadata + +Current `main` still relies on package metadata and lightweight descriptors outside the runtime contribution graph. + +Examples: + +- install entries in `src/plugins/install.ts:48` +- channel catalog metadata in `src/channels/plugins/catalog.ts:26` +- onboarding/status fallbacks in `src/commands/onboard-channels.ts:117` +- lightweight docks in `src/channels/dock.ts:228` + +The host should therefore parse a separate static metadata block. + +Recommended shape: + +```ts +type DistributionMetadata = { + install?: { + entries?: string[]; + npmSpec?: string; + localPath?: string; + defaultChoice?: "npm" | "local"; + }; + catalog?: { + channels?: Array<{ + id: string; + label: string; + selectionLabel?: string; + detailLabel?: string; + docsPath?: string; + docsLabel?: string; + blurb?: string; + order?: number; + aliases?: string[]; + preferOver?: string[]; + systemImage?: string; + selectionDocsPrefix?: string; + selectionDocsOmitLabel?: boolean; + selectionExtras?: string[]; + showConfigured?: boolean; + quickstartAllowFrom?: boolean; + forceAccountBinding?: boolean; + preferSessionLookupForAnnounceTarget?: boolean; + }>; + }; + docks?: Array<{ + adapterId: string; + capabilities: string[]; + metadata: Record; + }>; +}; +``` + +These descriptors are host-only and may be read before runtime activation. + +The catalog shape should preserve current host-visible channel metadata from `src/plugins/manifest.ts:121` and `src/channels/plugins/catalog.ts:117`, rather than collapsing it into a smaller generic shape. + +Performance requirement: + +- the host must be able to parse static distribution metadata without instantiating the heavy runtime entry module + +## Resolved Extension And Contribution Objects + +The host should normalize each package into one `ResolvedExtension` object, then derive static and runtime registries from it. + +Recommended shape: + +```ts +type ResolvedExtension = { + id: string; + version: string; + apiVersion: string; + source: { + origin: "bundled" | "global" | "workspace" | "config"; + path: string; + provenance?: string; + }; + static: { + install?: DistributionMetadata["install"]; + catalog?: DistributionMetadata["catalog"]; + docks?: DistributionMetadata["docks"]; + docs?: Record; + setup?: Array>; + config?: { + schema?: Record; + uiHints?: Record; + }; + }; + runtime: { + contributions: ResolvedContribution[]; + services: Array>; + routes: Array>; + policies: Array>; + stateOwnership: Record; + }; +}; +``` + +After validation, the host produces resolved contribution objects with normalized ids and runtime metadata. + +Recommended shape: + +```ts +type ResolvedContribution = { + runtimeId: string; + extensionId: string; + contributionId: string; + kind: string; + title: string; + description?: string; + arbitration: ArbitrationDescriptor; + scope: ScopeDescriptor; + permissions: string[]; + dependencies: ResolvedDependencyGraph; + visibility: VisibilityDescriptor; + permissionMode: "advisory" | "host-enforced" | "sandbox-enforced"; + runtime: unknown; + metadata: Record; +}; +``` + +The kernel only receives resolved contribution objects. + +## Naming Rules + +- extension ids are globally unique +- contribution ids are unique within an extension +- runtime ids are globally unique +- agent-visible names are not assumed unique and must be checked by the host +- aliases are advisory only; they never override canonical ids + +Canonical action ids are open, namespaced strings, but core action families should be maintained in one reviewed source-of-truth registry. + +Plugins may introduce new actions only by: + +- reusing an existing canonical family +- or adding a newly reviewed canonical action id through the host or kernel registry update process + +Plugins must not define new arbitration semantics outside the core schema. + +## Migration Mapping From Today + +- `registerChannel(...)` becomes one or more `adapter.runtime` contributions plus host surfaces +- `registerProvider(...)` becomes `capability.provider-integration` +- `registerTool(...)` becomes `capability.agent-tool` +- `registerCommand(...)` becomes `capability.control-command` +- `on(...)` becomes either `capability.event-handler`, `capability.context-augmenter`, or `capability.route-augmenter` +- `registerGatewayMethod(...)` becomes `capability.rpc` +- ACP backend registration becomes `capability.runtime-backend` +- `registerContextEngine(...)` becomes `capability.context-engine` +- `registerService(...)` becomes `service.background` +- `registerHttpRoute(...)` becomes `surface.http-route` +- package install and channel metadata become host-owned static distribution descriptors +- `configSchema` and `uiHints` become `surface.config` + +Legacy runtime compatibility namespaces should also map intentionally into the new SDK instead of being carried forward wholesale. + +Recommended module mapping: + +- legacy `channelRuntime.text` -> SDK text and formatting helpers +- legacy `channelRuntime.reply` -> SDK reply dispatch and envelope helpers +- legacy `channelRuntime.routing` -> SDK route resolution helpers +- legacy `channelRuntime.pairing` -> SDK pairing helpers +- legacy `channelRuntime.media` -> SDK media helpers +- legacy `channelRuntime.activity` and `channelRuntime.session` -> SDK session and activity helpers +- legacy `channelRuntime.mentions`, `groups`, and `commands` -> SDK shared channel-policy helpers +- legacy `channelRuntime.debounce` -> SDK inbound debounce helpers +- provider-specific runtime namespaces should become provider-scoped compatibility shims only, not long-term core SDK modules + +## Immediate Implementation Work + +1. Add a new manifest parser in the extension host rather than extending `src/plugins/manifest.ts:11`. +2. Define TypeScript source-of-truth types for `ResolvedExtension`, `ResolvedContribution`, and `ContributionPolicy`. +3. Create validators for top-level manifest fields and per-family descriptors. +4. Add static distribution and package metadata parsers for install, onboarding, config, status, and dock descriptors. +5. Preserve minimal SDK compatibility loading while the new schema is introduced. +6. Build a compatibility translator from current plugin registrations into contribution descriptors. +7. Keep the legacy manifest as an input format only during migration. +8. Record parity gaps discovered while migrating `thread-ownership` first. +9. Record parity gaps discovered while migrating `telegram` second. diff --git a/docs/.internal/extension-host-migration/openclaw-extension-host-implementation-guide.md b/docs/.internal/extension-host-migration/openclaw-extension-host-implementation-guide.md new file mode 100644 index 00000000000..97d493c48ba --- /dev/null +++ b/docs/.internal/extension-host-migration/openclaw-extension-host-implementation-guide.md @@ -0,0 +1,659 @@ +Temporary internal migration note: remove this document once the extension-host migration is complete. + +# OpenClaw Extension Host Implementation Guide + +Date: 2026-03-15 + +## Purpose + +This is the main execution guide for implementing the extension-host and kernel transition. + +Use it as the top-level implementation document. + +## How We Fix It + +Fix this as a staged architectural migration, not a broad refactor. + +1. Lock the boundary first by writing the cutover inventory and adding anti-corruption interfaces so no new plugin-specific behavior leaks into the kernel. +2. Introduce source-of-truth extension schema types and the `ResolvedExtension` model while preserving current `openclaw/plugin-sdk/*` loading through minimal compatibility support. +3. Move discovery, policy, provenance, static metadata, and registration ownership into the extension host, including hooks, channels, providers, tools, routes, CLI, setup, services, and slot-backed providers. +4. Prove the path with pilot migrations: `thread-ownership` first for non-channel hook behavior, then `telegram` for channel compatibility. +5. After pilot parity is established, move runtime behavior onto canonical event stages and replace the fragmented tool, provider, and slot-selection paths with one catalog and arbitration model. +6. Remove the legacy plugin runtime as the default path only after the host path has parity and the duplicate legacy systems are gone or explicitly downgraded to compatibility-only shims. + +The other docs remain the source of truth for their domains: + +- `openclaw-extension-contribution-schema-spec.md` +- `openclaw-extension-host-lifecycle-and-security-spec.md` +- `openclaw-kernel-event-pipeline-spec.md` +- `openclaw-capability-catalog-and-arbitration-spec.md` +- `openclaw-kernel-extension-host-transition-plan.md` + +## TODOs + +- [ ] Confirm the implementation order and owners for each phase. +- [x] Create the initial code skeleton for kernel and extension-host boundaries. +- [x] Write the initial boundary cutover inventory for every current plugin-owned surface. +- [ ] Keep the boundary cutover inventory updated as surfaces move. +- [ ] Track PRs, migrations, and follow-up gaps by phase. +- [ ] Keep the linked spec TODO sections in sync with implementation progress. +- [ ] Define the detailed pilot migration matrix and parity checks before Phase 3 starts. +- [ ] Mark this guide complete only when the legacy plugin path is no longer the primary runtime path. + +## Implementation Status + +Current status against this guide: + +- Phase 0 has started but is not complete. +- Phase 1 has started but is not complete. +- Phase 2 has started in a broad, compatibility-preserving form but is not complete. +- Phases 3 through 7 have not started in a meaningful way yet. + +What has been implemented so far: + +- a new `src/extension-host/*` boundary now exists in code +- active runtime registry ownership moved into `src/extension-host/static/active-registry.ts` +- `src/plugins/runtime.ts` now acts as a compatibility facade over the host-owned active registry +- registry activation now routes through `src/extension-host/activation.ts` +- initial source-of-truth types landed in `src/extension-host/manifests/schema.ts`, including `ResolvedExtension`, `ResolvedContribution`, and `ContributionPolicy` +- static manifest and package metadata are now normalized through host-owned helpers rather than being interpreted only inside plugin-era modules +- `src/plugins/manifest-registry.ts` now carries a normalized `resolvedExtension` alongside the legacy flat manifest record +- `src/extension-host/manifests/resolved-registry.ts` now exposes a host-owned resolved-extension registry view +- an initial Phase 0 inventory now exists in `src/extension-host/cutover-inventory.md` +- plugin SDK alias resolution now routes through `src/extension-host/compat/loader-compat.ts` +- loader alias-wired module loader creation now routes through `src/extension-host/activation/loader-module-loader.ts` +- loader cache key construction and registry cache control now route through `src/extension-host/activation/loader-cache.ts` +- loader lazy runtime proxy creation now routes through `src/extension-host/activation/loader-runtime-proxy.ts` +- loader provenance helpers now route through `src/extension-host/policy/loader-provenance.ts` +- loader duplicate-order and record/error policy now route through `src/extension-host/policy/loader-policy.ts` +- loader discovery policy outcomes now route through `src/extension-host/policy/loader-discovery-policy.ts` +- loader initial candidate planning and record creation now route through `src/extension-host/activation/loader-records.ts` +- loader entry-path opening and module import now route through `src/extension-host/activation/loader-import.ts` +- loader module-export resolution, config validation, and memory-slot load decisions now route through `src/extension-host/activation/loader-runtime.ts` +- loader post-import planning and `register(...)` execution now route through `src/extension-host/activation/loader-register.ts` +- loader per-candidate orchestration now routes through `src/extension-host/activation/loader-flow.ts` +- loader top-level load orchestration now routes through `src/extension-host/activation/loader-orchestrator.ts` +- loader host process state now routes through `src/extension-host/activation/loader-host-state.ts` +- loader preflight and cache-hit setup now routes through `src/extension-host/activation/loader-preflight.ts` +- loader post-preflight pipeline composition now routes through `src/extension-host/activation/loader-pipeline.ts` +- loader execution setup composition now routes through `src/extension-host/activation/loader-execution.ts` +- loader discovery and manifest bootstrap now routes through `src/extension-host/activation/loader-bootstrap.ts` +- loader mutable activation state now routes through `src/extension-host/activation/loader-session.ts` +- loader session run and finalization composition now routes through `src/extension-host/activation/loader-run.ts` +- loader activation policy outcomes now route through `src/extension-host/policy/loader-activation-policy.ts` +- loader record-state transitions now route through `src/extension-host/activation/loader-state.ts`, which now enforces an explicit loader lifecycle state machine while preserving compatibility `PluginRecord.status` values +- loader finalization policy results now route through `src/extension-host/policy/loader-finalization-policy.ts` +- loader final cache, readiness promotion, and activation finalization now routes through `src/extension-host/activation/loader-finalize.ts` +- runtime registration normalization has started in `src/extension-host/contributions/runtime-registrations.ts` for channel, provider, HTTP-route, gateway-method, tool, CLI, service, command, context-engine, and hook registrations +- low-risk runtime compatibility writes for channel, provider, gateway-method, HTTP-route, tool, CLI, service, command, context-engine, and hook registrations now route through `src/extension-host/contributions/registry-writes.ts` +- legacy internal-hook bridging and typed prompt-injection compatibility policy now route through `src/extension-host/compat/hook-compat.ts` +- compatibility `OpenClawPluginApi` composition and logger shaping now route through `src/extension-host/compat/plugin-api.ts` +- compatibility plugin-registry facade ownership now routes through `src/extension-host/compat/plugin-registry.ts` +- compatibility plugin-registry policy now routes through `src/extension-host/compat/plugin-registry-compat.ts` +- compatibility plugin-registry registration actions now route through `src/extension-host/compat/plugin-registry-registrations.ts` +- host-owned runtime registry accessors now route through `src/extension-host/contributions/runtime-registry.ts`, and the channel, provider, tool, command, HTTP-route, gateway-method, CLI, and service slices now keep host-owned storage there with mirrored legacy compatibility views +- service startup, stop ordering, service-context creation, and failure logging now route through `src/extension-host/contributions/service-lifecycle.ts` +- CLI duplicate detection, registrar invocation, and async failure logging now route through `src/extension-host/contributions/cli-lifecycle.ts` +- gateway method-id aggregation, plugin diagnostic shaping, and extra-handler composition now route through `src/extension-host/contributions/gateway-methods.ts` +- plugin tool resolution, conflict handling, optional-tool gating, and plugin-tool metadata tracking now route through `src/extension-host/contributions/tool-runtime.ts` +- plugin provider projection from registry entries into runtime provider objects now routes through `src/extension-host/contributions/provider-runtime.ts` +- plugin provider discovery filtering, order grouping, and result normalization now route through `src/extension-host/contributions/provider-discovery.ts` +- provider matching, auth-method selection, config-patch merging, and default-model application now route through `src/extension-host/contributions/provider-auth.ts` +- provider onboarding option building, model-picker entry building, and provider-method choice resolution now route through `src/extension-host/contributions/provider-wizard.ts` +- loaded-provider auth application, plugin-enable gating, auth-method execution, and post-auth default-model handling now route through `src/extension-host/contributions/provider-auth-flow.ts` +- provider post-selection hook lookup and invocation now route through `src/extension-host/contributions/provider-model-selection.ts` +- several static and lookup consumers now read through the host boundary or resolved-extension model: + - channel registry and dock lookups + - message-channel normalization + - plugin HTTP route registry default lookup + - discovery and install package metadata parsing + - channel catalog package metadata parsing + - plugin skill discovery + - plugin auto-enable + - config doc baseline generation + - config validation indexing +- several runtime consumers now also read through host-owned runtime-registry accessors instead of touching raw plugin-registry arrays or handler maps directly: + - channel lookup + - provider projection + - tool resolution + - service lifecycle startup + - CLI registration + - command runtime entry detection + - gateway method aggregation + - gateway plugin HTTP route matching +- plugin command execution and command-status listing now read through `src/extension-host/contributions/command-runtime.ts` instead of the legacy `src/plugins/commands.ts` implementation +- the channel, provider, tool, command, HTTP-route, gateway-method, CLI, and service slices now also keep host-owned runtime-registry storage with mirrored legacy compatibility arrays and handler maps +- `src/cli/plugin-registry.ts` now treats any pre-seeded runtime entry surface as already loaded, not just plugins, channels, or tools + +How it has been done: + +- by extracting narrow host-owned modules first and making existing plugin modules delegate to them +- by preserving current behavior and import surfaces wherever possible instead of attempting a broad rewrite +- by introducing normalized static records before touching heavy runtime activation paths +- by converting one static consumer at a time so each call site can move without forcing a loader rewrite +- by extracting low-risk runtime registration helpers next and letting `src/plugins/registry.ts` delegate to them as a compatibility facade +- by starting actual low-risk runtime write ownership next for channel, provider, gateway-method, HTTP-route, tool, CLI, service, command, context-engine, and hook registrations while keeping lifecycle semantics in legacy owners where that behavior still lives +- by moving plugin command duplicate enforcement, registration, matching, execution, listing, native command-spec projection, and loader reload clearing behind `src/extension-host/contributions/command-runtime.ts` while keeping `src/plugins/commands.ts` as the compatibility facade +- by starting loader and lifecycle migration with compatibility helpers for activation and SDK alias resolution before changing discovery or policy behavior +- by moving cache-key construction, cache reads, cache writes, and cache clearing behind host-owned helpers before changing activation-state ownership +- by extracting lazy runtime proxy creation and alias-wired Jiti module-loader creation into host-owned helpers before broader bootstrap or lifecycle ownership changes +- by extracting discovery, manifest loading, manifest diagnostics, discovery-policy logging, provenance building, and candidate ordering into a host-owned loader-bootstrap helper before broader lifecycle ownership changes +- by extracting candidate iteration, manifest lookup, per-candidate session processing, and finalization handoff into a host-owned loader-run helper before broader lifecycle ownership changes +- by moving loader-owned policy helpers next, while keeping module loading and enablement flow behavior unchanged +- by moving initial candidate planning and record construction behind host-owned helpers before changing import and registration flow +- by moving entry-path opening and module import behind host-owned helpers before changing cache wiring or lifecycle orchestration +- by moving loader runtime decisions behind host-owned helpers while preserving lazy loading, config validation behavior, and memory-slot policy behavior +- by moving post-import planning and `register(...)` execution behind host-owned helpers before changing entry-path and import flow +- by composing those seams into one host-owned per-candidate orchestrator before changing cache and lifecycle finalization behavior +- by moving loader record-state transitions into host-owned helpers before enforcing them as a loader lifecycle state machine +- by moving cache writes, provenance warnings, final memory-slot warnings, and activation into a host-owned loader finalizer before introducing an explicit lifecycle state machine +- by adding explicit compatibility `lifecycleState` mapping on loader-owned plugin records before enforcing the loader lifecycle state machine +- by turning that compatibility `lifecycleState` field into an enforced loader lifecycle state machine with readiness promotion during finalization +- by moving the remaining top-level loader orchestration into a host-owned module so `src/plugins/loader.ts` becomes a compatibility facade instead of the real owner +- by extracting shared discovery warning-cache state and loader reset behavior into a host-owned loader-host-state helper before shrinking the remaining orchestrator surface +- by extracting test-default application, config normalization, cache-key construction, cache-hit activation, and command-clear setup into a host-owned loader-preflight helper before shrinking the remaining orchestrator surface +- by extracting post-preflight execution setup and session-run composition into a host-owned loader-pipeline helper before shrinking the remaining orchestrator surface +- by extracting runtime creation, registry creation, bootstrap setup, module-loader creation, and session creation into a host-owned loader-execution helper before shrinking the remaining orchestrator surface +- by moving mutable activation state such as seen-id tracking, memory-slot selection, and finalization inputs into a host-owned loader session instead of leaving them in top-level loader variables +- by extracting shared provenance path matching and install-rule evaluation into `src/extension-host/policy/loader-provenance.ts` so activation and finalization policy seams reuse one host-owned implementation +- by turning open-allowlist discovery warnings into explicit host-owned discovery-policy results before the orchestrator logs them +- by moving duplicate precedence, config enablement, and early memory-slot gating into explicit host-owned activation-policy outcomes instead of leaving them inline in the loader flow +- by turning provenance-based untracked-extension warnings and final memory-slot warnings into explicit host-owned finalization-policy results before the finalizer applies them +- by extracting legacy internal-hook bridging and typed prompt-injection compatibility policy into a host-owned hook-compat helper while leaving actual hook execution ownership unchanged +- by extracting compatibility `OpenClawPluginApi` composition and logger shaping into a host-owned plugin-api helper while keeping the concrete registration callbacks in the legacy registry surface +- by extracting the remaining compatibility plugin-registry facade into a host-owned helper so `src/plugins/registry.ts` becomes a thin wrapper instead of the real owner +- by extracting provider normalization, command duplicate enforcement, and registry-local diagnostic shaping into a host-owned registry-compat helper while leaving the underlying provider-validation and plugin-command subsystems unchanged +- by extracting low-risk registry registration actions into a host-owned registry-registrations helper so the compatibility facade composes host-owned actions instead of implementing them inline +- by extracting service startup, stop ordering, service-context creation, and failure logging into a host-owned service-lifecycle helper while `src/plugins/services.ts` remains the compatibility entry point +- by extracting CLI duplicate detection, registrar invocation, and async failure logging into a host-owned CLI-lifecycle helper while `src/plugins/cli.ts` remains the compatibility entry point +- by extracting gateway method-id aggregation, plugin diagnostic shaping, and extra-handler composition into a host-owned gateway-methods helper while request dispatch semantics remain in the gateway server code +- by extracting plugin tool resolution, conflict handling, optional-tool gating, and plugin-tool metadata tracking into a host-owned tool-runtime helper while `src/plugins/tools.ts` remains the loader and config-normalization facade +- by extracting provider projection from registry entries into runtime provider objects into a host-owned provider-runtime helper while `src/plugins/providers.ts` remains the loader and config-normalization facade +- by extracting provider discovery filtering, order grouping, and result normalization into a host-owned provider-discovery helper while `src/plugins/provider-discovery.ts` remains the compatibility facade around the legacy provider loader path +- by extracting provider matching, auth-method selection, config-patch merging, and default-model application into a host-owned provider-auth helper while `src/commands/provider-auth-helpers.ts` remains the command-facing compatibility facade +- by extracting provider onboarding option building, model-picker entry building, and provider-method choice resolution into a host-owned provider-wizard helper while `src/plugins/provider-wizard.ts` remains the compatibility facade around loader-backed provider access and post-selection hooks +- by extracting loaded-provider auth application, plugin-enable gating, auth-method execution, and post-auth default-model handling into a host-owned provider-auth-flow helper while `src/commands/auth-choice.apply.plugin-provider.ts` remains the compatibility entry point +- by extracting provider post-selection hook lookup and invocation into a host-owned provider-model-selection helper while `src/plugins/provider-wizard.ts` remains the compatibility facade and existing command consumers continue migrating onto the host-owned surface +- by extracting provider-id normalization into `src/agents/provider-id.ts` so provider-only host seams do not inherit the heavier agent and browser dependency graph from `src/agents/model-selection.ts` +- by extracting model-ref parsing into `src/agents/model-ref.ts` and Google model-id normalization into `src/agents/google-model-id.ts` so provider auth and setup seams can be tested without pulling the heavier provider-loader and browser dependency graph +- by introducing host-owned runtime-registry accessors for low-risk runtime consumers first, then moving channel, provider, tool, command, HTTP-route, gateway-method, CLI, and service storage into that host-owned state while keeping mirrored legacy compatibility arrays and handler maps +- by tightening the CLI pre-load fast path to treat any host-known runtime entry surface as already loaded rather than only plugins, channels, or tools +- by moving central readers first, so later lifecycle and compatibility work can land on one boundary instead of many ad hoc call sites +- by adding focused tests for each extracted seam before widening the boundary further + +Committed implementation slices so far: + +- `6abf6750ee` `Plugins: add extension host registry boundary` +- `1aab89e820` `Plugins: extract loader host seams` +- `7bc3135082` `Plugins: extract loader candidate planning` +- `3a122c95fa` `Plugins: extract loader register flow` +- `fc81454038` `Plugins: extract loader import flow` +- `e1b207f4cf` `Plugins: extract loader candidate orchestration` +- `0c44d8049b` `Plugins: extract loader finalization` +- `33ef55a9ee` `Plugins: add loader lifecycle state mapping` +- `6590e19095` `Plugins: extract loader cache control` +- `c8d82a8f19` `Plugins: extract loader orchestration` +- `d32f65eb5e` `Plugins: add loader lifecycle state machine` +- `da9aad0c0f` `Plugins: add loader activation session` +- `fc51ce2867` `Plugins: add loader activation policy` +- `fd7488e10a` `Plugins: add loader finalization policy` +- `97e2af7f97` `Plugins: add loader discovery policy` +- `83b18eab72` `Plugins: share loader provenance helpers` +- `52495d23d5` `Plugins: extract loader runtime factories` +- `6e187ffb62` `Plugins: extract loader bootstrap` +- `234a540720` `Plugins: extract loader session runner` +- `a98443c39d` `Plugins: extract loader execution setup` +- `c9323aa016` `Plugins: extract loader preflight` +- `0df51ae6b4` `Plugins: extract loader pipeline` +- `e557b39cb2` `Plugins: extract loader host state` +- `07c3ae9c87` `Plugins: extract low-risk registry writes` +- `bc71592270` `Plugins: extend registry write helpers` +- `27fc645484` `Plugins: extend registry writes for hooks` +- `b407d7f476` `Plugins: extract hook compatibility` +- `a1e1dcc01a` `Plugins: extract plugin api facade` +- `0e190d64d4` `Plugins: extract registry compatibility facade` +- `944d787df1` `Plugins: extract registry compatibility policy` +- `4ca9cd7e5e` `Plugins: extract registry registration actions` +- `6b24e65719` `Plugins: extract service lifecycle` +- `b5757a6625` `Plugins: extract CLI lifecycle` +- `e0e3229bcb` `Gateway: extract extension host method surface` +- `af7ac14eed` `Plugins: extract tool runtime` +- `19087405d2` `Plugins: extract provider runtime` +- `1303419471` `Plugins: extract provider discovery` +- `afb6e4b185` `Plugins: extract provider auth and wizard flows` +- `cc3d59d59e` `Plugins: extract provider auth application flow` +- `e6cd834f8e` `Plugins: extract provider model selection hook` +- `11cbe08ec6` `Plugins: add host-owned route and gateway storage` +- `89e6b38152` `Docs: refresh runtime registry storage status` +- `ad0c235d16` `Plugins: add host-owned CLI and service storage` +- `d34a5aa870` `Docs: refresh runtime registry storage progress` +- `2be54e9861` `Plugins: add host-owned tool and provider storage` +- `235021766c` `Docs: refresh tool and provider storage status` +- `e109d5ef1b` `Plugins: add host-owned channel storage` +- `24fca48453` `Docs: refresh channel storage status` +- `961015f08c` `Channels: finish message-channel host lookup` +- `4c7f62649b` `Plugins: extract command runtime` +- `89414ed857` `Docs: track extension host migration internally` +- `d8af1eceaf` `Docs: refresh extension host migration status` + +What is still missing for these phases: + +- keeping the cutover inventory current as more surfaces move +- broader lifecycle ownership beyond the loader state machine, service-lifecycle boundary, CLI-lifecycle boundary, session-owned activation state, and explicit discovery-policy, activation-policy, and finalization-policy outcomes, remaining policy gate ownership, and broad host-owned registries described for Phase 2 +- minimal SDK compatibility work beyond preserving current behavior indirectly through existing loading +- host-owned conversation binding, interaction routing, ingress claim, and generic interactive control surfaces +- host-owned subsystem runtime registries for embeddings, media understanding, and TTS +- explicit support for extension-backed search, with a generic split between agent-visible tool publication and optional runtime-internal search backends +- any pilot migration, event pipeline, canonical catalog, or arbitration implementation + +Recent plan refinements: + +- the plan now explicitly treats conversation binding ownership, approval persistence, restore-on-restart behavior, and detached-binding cleanup as first-class migration surfaces +- it now explicitly treats interactive callback routing, namespace ownership, dedupe, and fallback behavior as first-class migration surfaces +- it now explicitly treats inbound claim as a canonical ingress-stage concern rather than a permanent plugin-era hook shape +- it now explicitly treats Telegram and Discord as the first validated rollout targets for interactive control surfaces while keeping the underlying contracts generic, host-owned, and kernel-agnostic +- it now explicitly treats embeddings, media understanding, and TTS as host-owned subsystem runtimes with capability routing, typed request envelopes, provider-id normalization, and fallback policy +- it now explicitly rejects widening the legacy `registerProvider(...)` or `ProviderPlugin` surface into a universal runtime API while retaining capability routing, typed request envelopes, provider-id normalization, and fallback behavior where those are part of the target model +- it now explicitly treats extension-backed search as either a canonical tool contribution or a host-owned runtime backend depending on whether the search surface is agent-visible + +## Implementation Order + +Implement phases in this order: + +1. Phase 0: boundary inventory and anti-corruption layer +2. Phase 1: contribution schema, package metadata, and minimal SDK compatibility +3. Phase 2: extension host lifecycle and registries +4. Phase 3: broader legacy compatibility bridges +5. Phase 4: canonical event pipeline +6. Phase 5: capability catalog migration +7. Phase 6: arbitration migration +8. Phase 7: broader migration and legacy removal + +This order matters because each layer depends on the previous one: + +- catalogs depend on normalized contributions +- normalized contributions depend on host discovery and validation +- existing extensions must keep loading while the schema and SDK boundary changes +- migrated hooks depend on the canonical event pipeline +- install, onboarding, and status flows depend on static metadata before runtime activation +- catalogs and arbitration already exist in partial forms, so their phases are migrations, not greenfield work +- useful ideas from implementation review should be harvested as parity requirements and host-owned capabilities, not by broadening legacy `src/plugins/*` or `src/plugin-sdk/*` surfaces as the target architecture +- safe removal of legacy paths depends on compatibility coverage and parity checks + +## Implementation Guardrails + +Do not implement every abstraction in the docs in the first cut. + +Treat some parts of the design as ceilings rather than immediate scope: + +- event taxonomy should start with three execution modes only: + - parallel observers + - sequential merge or decision handlers + - sync transcript hot paths +- permission modes should implement `advisory` and `host-enforced` first +- `sandbox-enforced` should remain a future contract until real isolation exists +- catalog publication should start small: + - kernel internal catalog + - kernel agent catalog + - host operator and static registries +- adapter metadata should stay minimal and parity-driven +- setup flow typing should start with a small result set: + - config patch + - credential result + - status note + - follow-up action +- canonical action governance should start as one source file plus tests, not a larger process framework +- arbitration should start with: + - exclusive slot + - ranked provider + - parallel provider + +The first implementation goal is parity for pilot migrations, not maximum generality. + +If a design choice is not required to migrate one channel extension and one non-channel extension safely, defer it. + +## Current Runtime Surfaces That Must Be Accounted For + +The current plugin system already owns more than runtime activation. + +Before implementation starts, write and maintain a cutover inventory for these surfaces: + +- manifest loading and static metadata +- package-level install and onboarding metadata +- discovery, provenance, and origin precedence +- config schema and UI hint loading +- typed hooks and legacy hook bridges +- channels and channel lookup +- providers and provider auth/setup flows +- tools and agent-visible tool catalogs +- HTTP routes and gateway methods +- CLI registrars and plugin commands +- services and context-engine registrations +- slot selection and other existing arbitration paths +- status, reload, install, update, and diagnostics surfaces + +Do not treat Phase 5 and Phase 6 as new systems built in isolation. + +They must absorb and replace the existing partial catalog and arbitration behaviors rather than creating a second source of truth. + +## Phase Guide + +### Phase 0: Lock the boundary + +Goal: + +- define the kernel versus extension-host boundary in code and imports +- inventory every current plugin-owned surface that crosses that boundary + +Deliverables: + +- boundary cutover inventory +- anti-corruption interfaces for host-owned registration surfaces +- initial feature flags for host-path versus legacy-path execution +- directory and import boundaries for kernel and extension-host code + +Primary docs: + +- `openclaw-kernel-extension-host-transition-plan.md` +- `openclaw-extension-contribution-schema-spec.md` + +Exit criteria: + +- kernel code does not take new dependencies on legacy plugin shapes +- extension-host directory structure exists +- compatibility-only surfaces are identified +- each current plugin-owned surface is tagged as kernel-owned, host-owned, or compatibility-only +- no new direct writes to global registries are introduced without going through the new boundary + +Current implementation status: + +- partially implemented +- the code boundary exists in `src/extension-host/*` +- central active-registry ownership now routes through the host boundary +- several central runtime readers now consume the host-owned boundary instead of reading directly from `src/plugins/runtime.ts` +- the initial cutover inventory now exists in `src/extension-host/cutover-inventory.md` and is being updated as surfaces move, but the phase is still incomplete because loader orchestration, lifecycle ownership, and later compatibility phases have not moved yet + +### Phase 1: Define the schema + +Goal: + +- implement the source-of-truth manifest and contribution types +- preserve existing extension loading while the schema and SDK boundary changes + +Primary doc: + +- `openclaw-extension-contribution-schema-spec.md` + +Deliverables: + +- manifest parser +- package metadata parser +- contribution validators +- `ResolvedExtension` +- `ResolvedContribution` +- typed `ContributionPolicy` +- static metadata parser +- new versioned SDK contract surface +- minimal SDK compatibility loading surface +- normalized install and onboarding metadata model + +Exit criteria: + +- extensions can be normalized into static and runtime sections without activating heavy runtime code +- existing extension SDK imports still resolve through the compatibility loading path + +Current implementation status: + +- partially implemented +- `ResolvedExtension`, `ResolvedContribution`, and `ContributionPolicy` landed as initial code types +- legacy manifest and package metadata now converge into a normalized `resolvedExtension` record carried by the manifest registry +- discovery, install, and catalog metadata parsing now go through host-owned schema helpers +- partial explicit compatibility now exists through host-owned loader-compat and loader-runtime helpers, but full manifest or contribution validators and a versioned SDK compatibility layer are not implemented yet + +### Phase 2: Build the extension host + +Goal: + +- implement discovery, validation, policy, registries, and lifecycle ownership + +Primary doc: + +- `openclaw-extension-host-lifecycle-and-security-spec.md` + +Deliverables: + +- discovery pipeline +- activation state machine +- policy evaluator +- host-owned registries +- host-owned adapters for hooks, channels, providers, tools, HTTP routes, gateway methods, CLI, services, commands, and context engines +- per-extension state ownership +- provenance and origin handling +- config redaction-aware schema loading +- reload and route ownership handling + +Exit criteria: + +- the host can load bundled and external extensions into normalized registries +- the host can populate normalized registries without direct kernel writes except through explicit compatibility adapters + +Current implementation status: + +- partially implemented in a compatibility-preserving form +- the host owns the active registry state +- the host exposes a resolved-extension registry view for static consumers +- plugin skills, plugin auto-enable, and config validation indexing now consume host-owned resolved-extension data +- activation, loader cache control, loader policy, loader discovery-policy outcomes, loader activation-policy outcomes, loader finalization-policy outcomes, loader candidate planning, loader import flow, loader runtime decisions, loader post-import register flow, loader candidate orchestration, loader top-level load orchestration, loader session state, loader record-state helpers, and loader finalization now route through `src/extension-host/*` +- broader lifecycle state ownership beyond the loader state machine, activation states, policy evaluation, and broad host-owned registries are still not implemented + +### Phase 3: Build compatibility bridges + +Goal: + +- keep current extensions working through the host without leaking legacy contracts into the kernel + +Primary docs: + +- `openclaw-kernel-extension-host-transition-plan.md` +- `openclaw-extension-contribution-schema-spec.md` + +Deliverables: + +- `ChannelPlugin` compatibility translators +- plugin SDK compatibility loading +- runtime-channel namespace translation into the new SDK modules +- legacy setup and CLI translation +- legacy config schema and UI hint translation +- pilot migration matrix with explicit parity labels + +Exit criteria: + +- `thread-ownership` runs through the host path as the first non-channel pilot +- `telegram` runs through the host path as the first channel pilot +- both pilots have explicit parity results for discovery, config, activation, diagnostics, and runtime behavior + +### Phase 4: Implement the canonical event pipeline + +Goal: + +- move runtime hook behavior onto explicit canonical events + +Primary doc: + +- `openclaw-kernel-event-pipeline-spec.md` + +Deliverables: + +- event type definitions +- stage runner +- sync transcript-write stages +- bridges from legacy hook buses +- mapping table from existing typed and legacy hooks to canonical stages + +Exit criteria: + +- migrated extensions can use canonical events without relying directly on old plugin hook execution +- pilot hook behaviors have parity coverage against the pre-host path + +### Phase 5: Implement catalogs + +Goal: + +- compile runtime-derived agent and internal catalogs, plus host-owned operator catalogs +- replace existing plugin-identity-driven catalog surfaces with canonical family-based catalogs + +Primary doc: + +- `openclaw-capability-catalog-and-arbitration-spec.md` + +Deliverables: + +- kernel internal catalog +- kernel agent catalog +- host operator catalog +- static setup and install catalogs +- canonical action registry +- migration plan for existing tool, provider, and setup catalog surfaces + +Exit criteria: + +- agent-visible tools are compiled from canonical action families instead of plugin identity +- setup and install catalogs no longer depend on duplicated legacy metadata paths + +### Phase 6: Implement arbitration + +Goal: + +- resolve overlap, ranking, selection, and slot conflicts deterministically +- absorb the existing slot and provider selection behavior into canonical arbitration + +Primary doc: + +- `openclaw-capability-catalog-and-arbitration-spec.md` + +Deliverables: + +- conflict detection +- provider selection +- slot arbitration +- planner-visible name collision handling +- migration plan for existing slot and name-collision behaviors + +Exit criteria: + +- at least one multi-provider family works through canonical arbitration +- legacy slot and provider-selection paths no longer act as separate arbitration systems + +### Phase 7: Migrate and remove legacy paths + +Goal: + +- finish migration and shrink compatibility-only surfaces + +Primary docs: + +- `openclaw-kernel-extension-host-transition-plan.md` +- all other docs as parity references + +Deliverables: + +- channel migrations +- non-channel extension migrations +- parity tests +- deprecation markers +- removal plan for obsolete compatibility shims + +Exit criteria: + +- legacy plugin runtime is no longer the default execution path + +## Pilot Matrix + +Initial pilot set: + +- non-channel pilot: `thread-ownership` +- channel pilot: `telegram` + +Why these pilots: + +- `thread-ownership` exercises typed hook loading without introducing CLI, HTTP route, or service migration at the same time +- `telegram` exercises the `ChannelPlugin` compatibility path with a minimal top-level plugin registration surface + +Second-wave compatibility candidates after the pilots are stable: + +- `line` for channel plus command registration +- `device-pair` for command, service, and setup flow coverage + +Each pilot must record parity for: + +- discovery and precedence +- manifest and static metadata loading +- config schema and UI hints +- enabled and disabled state handling +- activation and reload behavior +- diagnostics and status output +- runtime behavior on the migrated path +- compatibility-only gaps that still remain + +## Recommended First Implementation Slice + +If you want the lowest-risk start, do this first: + +1. write the boundary cutover inventory +2. add source-of-truth types +3. add the static metadata and package metadata parsers +4. add `ResolvedExtension` +5. add minimal SDK compatibility loading +6. add host discovery and validation +7. bring `thread-ownership` through the host path first +8. bring `telegram` through the host path second + +Status of this slice: + +- steps 2 through 6 are underway +- step 1 has landed as `src/extension-host/cutover-inventory.md` +- steps 7 and 8 have not started + +Concrete landings from this slice: + +- the host boundary exists +- source-of-truth schema types exist +- package metadata parsing now routes through the host schema layer +- `ResolvedExtension` exists in code and is attached to manifest-registry records +- host-owned active-registry and resolved-registry views exist +- early static consumers have moved onto the new host-owned data path + +Do not start with catalogs or arbitration first. + +Also avoid these first-cut traps: + +- do not build a broad event scheduling framework before the canonical stages exist +- do not turn permission descriptors into fake sandbox guarantees +- do not build a large operator catalog publication layer before the host registries are real +- do not over-type setup flows before the pilot migrations prove the minimum result model is insufficient + +## Tracking Rules + +When implementation begins: + +- update this guide first with phase status +- update the matching spec TODOs when a domain changes +- record where the implementation intentionally diverged from the spec +- record which behaviors are full parity, partial parity, or compatibility-only +- update the pilot parity matrix whenever a migrated surface changes + +## Suggested Status Format + +Use this format in each doc when work starts: + +- `not started` +- `in progress` +- `implemented` +- `verified` +- `deferred` + +For example: + +- `ResolvedExtension` registry: `implemented` +- setup fallback removal: `deferred` +- sync transcript-write parity tests: `in progress` diff --git a/docs/.internal/extension-host-migration/openclaw-extension-host-lifecycle-and-security-spec.md b/docs/.internal/extension-host-migration/openclaw-extension-host-lifecycle-and-security-spec.md new file mode 100644 index 00000000000..1ff2d719c0b --- /dev/null +++ b/docs/.internal/extension-host-migration/openclaw-extension-host-lifecycle-and-security-spec.md @@ -0,0 +1,750 @@ +Temporary internal migration note: remove this document once the extension-host migration is complete. + +# OpenClaw Extension Host Lifecycle And Security Spec + +Date: 2026-03-15 + +## Purpose + +This document defines how the extension host discovers, validates, activates, isolates, and stops extensions while applying operator policy, permission metadata, persistence boundaries, and contribution dependencies. + +The kernel does not participate in these concerns directly. + +## TODOs + +- [x] Write the initial boundary cutover inventory for every current plugin-owned surface. +- [ ] Keep the boundary cutover inventory updated as surfaces move. +- [ ] Extend the loader lifecycle state machine into full extension-host lifecycle ownership and document the concrete runtime states in code. +- [ ] Implement advisory versus enforced permission handling exactly as specified here. +- [ ] Implement host-owned registries for config, setup, CLI, routes, services, slots, and backends. +- [ ] Implement per-extension state ownership and migration from current shared plugin state. +- [ ] Record pilot parity for `thread-ownership` first and `telegram` second before broad legacy rollout. +- [ ] Track which hardening, reload, and provenance rules have reached parity with `main`. + +## Implementation Status + +Current status against this spec: + +- registry ownership and the first compatibility-preserving loader slices have landed +- a loader-scoped lifecycle state machine has landed +- broader lifecycle orchestration, policy gates, and activation-state management beyond the current loader, service, and CLI seams have not landed + +What has been implemented: + +- an initial Phase 0 cutover inventory now exists in `src/extension-host/cutover-inventory.md` +- active registry ownership now lives in the extension host boundary rather than only in plugin-era runtime state +- central lookup surfaces now consume the host-owned active registry +- registry activation now routes through `src/extension-host/activation.ts` +- a host-owned resolved-extension registry exists for static consumers +- static config-baseline generation now reads bundled extension metadata through the host-owned resolved-extension registry +- channel, provider, HTTP-route, gateway-method, tool, CLI, service, command, context-engine, and hook registration normalization now delegates through `src/extension-host/contributions/runtime-registrations.ts` +- low-risk runtime compatibility writes for channel, provider, gateway-method, HTTP-route, tool, CLI, service, command, context-engine, and hook registrations now delegate through `src/extension-host/contributions/registry-writes.ts` +- legacy internal-hook bridging and typed prompt-injection compatibility policy now delegate through `src/extension-host/compat/hook-compat.ts` +- compatibility `OpenClawPluginApi` composition and logger shaping now delegate through `src/extension-host/compat/plugin-api.ts` +- compatibility plugin-registry facade ownership now delegates through `src/extension-host/compat/plugin-registry.ts` +- compatibility plugin-registry policy now delegates through `src/extension-host/compat/plugin-registry-compat.ts` +- compatibility plugin-registry registration actions now delegate through `src/extension-host/compat/plugin-registry-registrations.ts` +- host-owned runtime registry accessors now delegate through `src/extension-host/contributions/runtime-registry.ts`, and the channel, provider, tool, command, HTTP-route, gateway-method, CLI, and service slices now keep host-owned storage there with mirrored legacy compatibility views +- plugin command registration, matching, execution, listing, native command-spec projection, and loader reload clearing now delegate through `src/extension-host/contributions/command-runtime.ts` +- service startup, stop ordering, service-context creation, and failure logging now delegate through `src/extension-host/contributions/service-lifecycle.ts` +- CLI duplicate detection, registrar invocation, and async failure logging now delegate through `src/extension-host/contributions/cli-lifecycle.ts` +- gateway method-id aggregation, plugin diagnostic shaping, and extra-handler composition now delegate through `src/extension-host/contributions/gateway-methods.ts` +- plugin tool resolution, conflict handling, optional-tool gating, and plugin-tool metadata tracking now delegate through `src/extension-host/contributions/tool-runtime.ts` +- plugin provider projection from registry entries into runtime provider objects now delegates through `src/extension-host/contributions/provider-runtime.ts` +- plugin provider discovery filtering, order grouping, and result normalization now delegate through `src/extension-host/contributions/provider-discovery.ts` +- provider matching, auth-method selection, config-patch merging, and default-model application now delegate through `src/extension-host/contributions/provider-auth.ts` +- provider onboarding option building, model-picker entry building, and provider-method choice resolution now delegate through `src/extension-host/contributions/provider-wizard.ts` +- loaded-provider auth application, plugin-enable gating, auth-method execution, and post-auth default-model handling now delegate through `src/extension-host/contributions/provider-auth-flow.ts` +- provider post-selection hook lookup and invocation now delegate through `src/extension-host/contributions/provider-model-selection.ts` +- loader alias-wired module loader creation now routes through `src/extension-host/activation/loader-module-loader.ts` +- loader cache key construction and registry cache control now route through `src/extension-host/activation/loader-cache.ts` +- loader lazy runtime proxy creation now routes through `src/extension-host/activation/loader-runtime-proxy.ts` +- loader provenance helpers now route through `src/extension-host/policy/loader-provenance.ts` +- loader duplicate-order and record/error policy now route through `src/extension-host/policy/loader-policy.ts` +- loader discovery policy outcomes now route through `src/extension-host/policy/loader-discovery-policy.ts` +- loader initial candidate planning and record creation now route through `src/extension-host/activation/loader-records.ts` +- loader entry-path opening and module import now route through `src/extension-host/activation/loader-import.ts` +- loader module-export resolution, config validation, and memory-slot load decisions now route through `src/extension-host/activation/loader-runtime.ts` +- loader post-import planning and `register(...)` execution now route through `src/extension-host/activation/loader-register.ts` +- loader per-candidate orchestration now routes through `src/extension-host/activation/loader-flow.ts` +- loader top-level load orchestration now routes through `src/extension-host/activation/loader-orchestrator.ts` +- loader host process state now routes through `src/extension-host/activation/loader-host-state.ts` +- loader preflight and cache-hit setup now routes through `src/extension-host/activation/loader-preflight.ts` +- loader post-preflight pipeline composition now routes through `src/extension-host/activation/loader-pipeline.ts` +- loader execution setup composition now routes through `src/extension-host/activation/loader-execution.ts` +- loader discovery and manifest bootstrap now routes through `src/extension-host/activation/loader-bootstrap.ts` +- loader mutable activation state now routes through `src/extension-host/activation/loader-session.ts` +- loader session run and finalization composition now routes through `src/extension-host/activation/loader-run.ts` +- loader activation policy outcomes now route through `src/extension-host/policy/loader-activation-policy.ts` +- loader record-state transitions now route through `src/extension-host/activation/loader-state.ts`, which now enforces an explicit loader lifecycle state machine while preserving compatibility `PluginRecord.status` values +- loader finalization policy results now route through `src/extension-host/policy/loader-finalization-policy.ts` +- loader final cache, readiness promotion, and activation finalization now routes through `src/extension-host/activation/loader-finalize.ts` + +How it has been implemented: + +- by extracting `src/extension-host/static/active-registry.ts` and making `src/plugins/runtime.ts` delegate to it +- by leaving lifecycle behavior unchanged for now and only moving ownership of the shared registry boundary +- by moving low-risk readers first, such as channel lookup, dock lookup, message-channel lookup, and default HTTP route registry access +- by extending that same host-owned boundary into static consumers instead of introducing separate one-off metadata loaders +- by starting runtime-registry migration with low-risk validation and normalization helpers while leaving lifecycle ordering and activation behavior unchanged +- by starting actual low-risk runtime write ownership for channel, provider, gateway-method, HTTP-route, tool, CLI, service, command, context-engine, and hook registrations only after normalization helpers existed, while leaving lifecycle ordering and activation behavior unchanged +- by leaving start/stop ordering and duplicate-enforcement behavior in legacy subsystems where those subsystems are still the real owner +- by treating hook execution and hook registration as separate migration concerns so event-pipeline work does not get conflated with record normalization +- by starting loader/lifecycle migration with activation and SDK alias compatibility helpers while leaving discovery and policy flow unchanged +- by moving cache-key construction, cache reads, cache writes, and cache clearing next while leaving activation-state ownership unchanged +- by moving provenance and duplicate-order policy next, so lifecycle migration can land on host-owned policy helpers instead of loader-local utilities +- by extracting lazy runtime proxy creation and alias-wired Jiti module-loader creation into host-owned helpers before broader bootstrap or lifecycle ownership changes +- by extracting discovery, manifest loading, manifest diagnostics, discovery-policy logging, provenance building, and candidate ordering into a host-owned loader-bootstrap helper before broader lifecycle ownership changes +- by extracting candidate iteration, manifest lookup, per-candidate session processing, and finalization handoff into a host-owned loader-run helper before broader lifecycle ownership changes +- by moving initial candidate planning and record construction next while leaving module import and registration flow unchanged +- by moving entry-path opening and module import next while leaving cache wiring and lifecycle orchestration unchanged +- by moving loader runtime decisions next while preserving the current lazy-load, config-validation, and memory-slot behavior +- by moving post-import planning and `register(...)` execution next while leaving entry-path and import flow unchanged +- by composing those seams into one host-owned per-candidate loader orchestrator before moving final lifecycle-state behavior +- by moving the remaining top-level loader orchestration into a host-owned module before enforcing the loader lifecycle state machine +- by extracting shared discovery warning-cache state and loader reset behavior into a host-owned loader-host-state helper before shrinking the remaining orchestrator surface +- by extracting test-default application, config normalization, cache-key construction, cache-hit activation, and command-clear setup into a host-owned loader-preflight helper before shrinking the remaining orchestrator surface +- by extracting post-preflight execution setup and session-run composition into a host-owned loader-pipeline helper before shrinking the remaining orchestrator surface +- by extracting runtime creation, registry creation, bootstrap setup, module-loader creation, and session creation into a host-owned loader-execution helper before shrinking the remaining orchestrator surface +- by moving record-state transitions first into a compatibility layer and then into an enforced loader lifecycle state machine +- by moving cache writes, provenance warnings, final memory-slot warnings, and activation into a host-owned loader finalizer before introducing an explicit lifecycle state machine +- by adding explicit compatibility `lifecycleState` mapping on loader-owned plugin records before enforcing the loader lifecycle state machine +- by promoting successfully registered plugins to `ready` during host-owned finalization while leaving broader activation-state semantics for later phases +- by moving mutable activation state such as seen-id tracking, memory-slot selection, and finalization inputs into a host-owned loader session before broader activation-state semantics move +- by extracting shared provenance path matching and install-rule evaluation into `src/extension-host/policy/loader-provenance.ts` so activation and finalization policy seams reuse one host-owned implementation +- by turning open-allowlist discovery warnings into explicit host-owned discovery-policy results before the orchestrator logs them +- by moving duplicate precedence, config enablement, and early memory-slot gating into explicit host-owned activation-policy outcomes before broader policy semantics move +- by turning provenance-based untracked-extension warnings and final memory-slot warnings into explicit host-owned finalization-policy results before the finalizer applies them +- by extracting legacy internal-hook bridging and typed prompt-injection compatibility policy into a host-owned hook-compat helper while leaving actual hook execution ownership unchanged +- by extracting compatibility `OpenClawPluginApi` composition and logger shaping into a host-owned plugin-api helper while keeping the concrete registration callbacks in the legacy registry surface +- by extracting the remaining compatibility plugin-registry facade into a host-owned helper so `src/plugins/registry.ts` becomes a thin wrapper instead of the real owner +- by extracting provider normalization, command duplicate enforcement, and registry-local diagnostic shaping into a host-owned registry-compat helper while leaving the underlying provider-validation and plugin-command subsystems unchanged +- by extracting low-risk registry registration actions into a host-owned registry-registrations helper so the compatibility facade composes host-owned actions instead of implementing them inline +- by extracting service startup, stop ordering, service-context creation, and failure logging into a host-owned service-lifecycle helper while `src/plugins/services.ts` remains the compatibility entry point +- by extracting CLI duplicate detection, registrar invocation, and async failure logging into a host-owned CLI-lifecycle helper while `src/plugins/cli.ts` remains the compatibility entry point +- by extracting gateway method-id aggregation, plugin diagnostic shaping, and extra-handler composition into a host-owned gateway-methods helper while request dispatch semantics remain in the gateway server code +- by extracting plugin tool resolution, conflict handling, optional-tool gating, and plugin-tool metadata tracking into a host-owned tool-runtime helper while `src/plugins/tools.ts` remains the loader and config-normalization facade +- by extracting provider projection from registry entries into runtime provider objects into a host-owned provider-runtime helper while `src/plugins/providers.ts` remains the loader and config-normalization facade +- by extracting provider discovery filtering, order grouping, and result normalization into a host-owned provider-discovery helper while `src/plugins/provider-discovery.ts` remains the compatibility facade around the legacy provider loader path +- by extracting provider matching, auth-method selection, config-patch merging, and default-model application into a host-owned provider-auth helper while `src/commands/provider-auth-helpers.ts` remains the command-facing compatibility facade +- by extracting provider onboarding option building, model-picker entry building, and provider-method choice resolution into a host-owned provider-wizard helper while `src/plugins/provider-wizard.ts` remains the compatibility facade around loader-backed provider access and post-selection hooks +- by extracting loaded-provider auth application, plugin-enable gating, auth-method execution, and post-auth default-model handling into a host-owned provider-auth-flow helper while `src/commands/auth-choice.apply.plugin-provider.ts` remains the compatibility entry point +- by extracting provider post-selection hook lookup and invocation into a host-owned provider-model-selection helper while `src/plugins/provider-wizard.ts` remains the compatibility facade and existing command consumers continue migrating onto the host-owned surface +- by extracting provider-id normalization into `src/agents/provider-id.ts` so provider-only host seams do not inherit the heavier agent and browser dependency graph from `src/agents/model-selection.ts` +- by extracting model-ref parsing into `src/agents/model-ref.ts` and Google model-id normalization into `src/agents/google-model-id.ts` so provider auth and setup seams can be tested without pulling the heavier provider-loader and browser dependency graph +- by introducing host-owned runtime-registry accessors for channel, provider, tool, service, CLI, command, gateway-method, and HTTP-route consumers first, then moving channel, provider, tool, command, HTTP-route, gateway-method, CLI, and service storage into that host-owned state while keeping mirrored legacy compatibility arrays and handler maps +- by moving plugin command duplicate enforcement, registration, matching, execution, listing, native command-spec projection, and loader reload clearing into `src/extension-host/contributions/command-runtime.ts` while keeping the legacy command module as a compatibility facade +- by tightening the CLI pre-load fast path to treat any host-known runtime entry surface as already loaded rather than only plugins, channels, or tools + +What is still pending from this spec: + +- broader extension-host lifecycle ownership beyond the loader state machine, service-lifecycle boundary, CLI-lifecycle boundary, session-owned activation state, and explicit discovery-policy, activation-policy, and finalization-policy outcomes +- activation pipeline ownership +- host-owned registries for setup, CLI, routes, services, slots, and backends +- host-owned subsystem runtime registries for embeddings, media understanding, and TTS, including explicit fallback and override policy instead of plugin-era capability reads +- a clear host-owned split for extension-backed search between agent-visible tool publication and any optional runtime-internal search backend registry +- permission-mode enforcement +- per-extension state ownership and migration +- provenance, reload, and hardening parity tracking + +## Goals + +- deterministic activation and shutdown +- explicit failure states +- no hidden privilege escalation +- stable persistence ownership rules +- truthful security semantics for the current trusted in-process model +- safe support for bundled and external extensions under the same model +- preserve existing hardening and prompt-mutation policy behavior during the migration + +## Implementation Sequencing Constraints + +This spec is not a greenfield host design. + +The host must absorb existing behavior that already lives in: + +- plugin discovery and manifest loading +- config schema and UI hint handling +- route and gateway registration +- channels and channel lookup +- providers and provider auth or setup flows +- tools, commands, and CLI registration +- services, backends, and slot-backed providers +- reload, diagnostics, install, update, and status behavior + +Therefore: + +- Phase 0 must produce a cutover inventory for those surfaces before registry ownership changes begin +- Phase 1 must preserve current SDK loading through minimal compatibility support +- Phase 2 registry work must be broad enough to cover all currently registered surfaces, not only a narrow runtime subset +- Phase 3 must prove parity through `thread-ownership` first and `telegram` second before broader rollout + +## Trust Model Reality + +Current `main` treats installed and enabled extensions as trusted code running in-process: + +- trusted plugin concept in `SECURITY.md:108` +- in-process loading in `src/plugins/loader.ts:621` + +That means the initial extension host has two separate jobs: + +- enforce operator policy for activation, route exposure, host-owned registries, and auditing +- accurately communicate that this is not yet a hard sandbox against arbitrary extension code + +Recommended enforcement levels: + +- `advisory` + Host policy, audit, and compatibility guidance only. This is the current default. Permission mismatch alone should not block activation in this mode, though the host may warn and withhold optional host-published surfaces. +- `host-enforced` + Host-owned capabilities and registries are gated, but extension code still runs in-process. +- `sandbox-enforced` + A future mode with real process, VM, or IPC isolation where permissions become a true security boundary. + +## Lifecycle States + +Every extension instance moves through these states: + +1. `discovered` +2. `manifest-loaded` +3. `validated` +4. `dependency-resolved` +5. `policy-approved` +6. `instantiated` +7. `registered` +8. `starting` +9. `ready` +10. `degraded` +11. `stopping` +12. `stopped` +13. `failed` + +The host owns the state machine. + +## Activation Pipeline + +### 1. Discovery + +The host scans: + +- bundled extension inventory +- configured external extension paths or packages +- disabled extension state + +Discovery is metadata-only. No extension code executes in this phase. + +### 2. Manifest Load + +The host loads and validates manifest syntax. + +Failures here prevent instantiation. + +This phase must cover both: + +- runtime contribution descriptors +- package-level static metadata used for install, onboarding, status, and lightweight operator UX + +### 3. Schema Validation + +The host validates: + +- top-level extension manifest +- contribution descriptors +- config schema +- config UI hints and sensitivity metadata +- permission declarations +- dependency declarations +- policy declarations such as prompt-mutation behavior + +### 4. Dependency Resolution + +The host resolves: + +- extension api compatibility +- SDK compatibility mode and deprecation requirements +- required contribution dependencies +- optional dependencies +- conflict declarations +- singleton slot collisions + +Compatibility decision: + +- the host should support only a short compatibility window, ideally one or two older SDK contract versions at a time +- extensions outside that window must fail validation with a clear remediation path + +Sequencing rule: + +- minimal compatibility loading must exist before broader schema or registry changes depend on the new manifest model + +### 5. Policy Gate + +The host computes the requested permission set and compares it against operator policy. + +In `host-enforced` or `sandbox-enforced` mode, extensions that are not allowed to receive all required permissions do not activate or do not register the gated contributions. + +In `advisory` mode, this gate records warnings, informs operator-visible policy state, and may withhold optional host-published surfaces, but permission mismatch alone does not fail activation. + +It does not sandbox arbitrary filesystem, network, or child-process access from trusted in-process extension code. + +### 6. Instantiation + +The host loads the extension entrypoint and asks it to emit contribution descriptors and runtime factories. + +Unless the host is running in a future isolated mode, instantiation still executes trusted extension code inside the OpenClaw process. + +### 7. Registration + +The host resolves runtime ids, arbitration metadata, and activation order, then registers contributions into host-owned registries. + +This includes host-managed operator registries for: + +- CLI commands +- setup and onboarding flows +- config and status surfaces +- dynamic HTTP routes +- config reload descriptors and gateway feature advertisement where those surfaces remain host-managed during migration + +Callable gateway or runtime methods are separate from this advertisement layer and should continue to register through the runtime contribution model as `capability.rpc`. + +The registration boundary should cover the full current surface area as one migration set: + +- hooks and event handlers +- channels and lightweight channel descriptors +- providers and provider-setup surfaces +- tools and control commands +- CLI, setup, config, and status surfaces +- HTTP routes and gateway methods +- services, runtime backends, and slot-backed providers + +Do not migrate only a subset and leave the rest writing into the legacy registry model indefinitely. + +### 8. Start + +The host starts host-managed services, assigns per-extension state and route ownership, and activates kernel-facing contributions. + +### 9. Ready + +The extension is active and visible to kernel or operator surfaces as appropriate. + +## Failure Modes + +Supported failure classes: + +- `manifest-invalid` +- `api-version-unsupported` +- `dependency-missing` +- `dependency-conflict` +- `policy-denied` +- `instantiation-failed` +- `registration-conflict` +- `startup-failed` +- `runtime-degraded` + +The host must record failure class, extension id, contribution ids, and operator-visible remediation. + +## Dependency Rules + +Dependencies must be explicit and machine-checkable. + +### Extension-level dependencies + +Used when one extension package requires another package to be present. + +### Contribution-level dependencies + +Used when a specific runtime contract depends on another contribution. + +Examples: + +- a route augmenter may require a specific adapter family +- an auth helper may require a provider contribution +- a diagnostics extension may optionally bind to a runtime backend if present + +### Conflict rules + +Extensions may declare: + +- `conflicts` +- `supersedes` +- `replaces` + +The host resolves these before activation. + +## Discovery And Load Hardening + +The extension host must preserve current path-safety, provenance, and duplicate-resolution protections. + +At minimum, preserve parity with: + +- path and boundary checks during load in `src/plugins/loader.ts:744` +- manifest precedence and duplicate-origin handling in `src/plugins/manifest-registry.ts:15` +- provenance warnings during activation in `src/plugins/loader.ts:500` + +Security hardening from the current loader is part of the host contract, not an optional implementation detail. + +Parity requirement: + +- the pilot migrations must show that these hardening rules still apply on the host path, not only on the legacy path + +## Policy And Permission Model + +Permissions are granted to extension instances by the host as policy metadata and host capability grants. + +The kernel must never infer privilege from contribution kind alone. + +The host must track both: + +- requested permissions +- enforcement level (`advisory`, `host-enforced`, or `sandbox-enforced`) +- host-managed policy gates such as prompt mutation and sync hot-path eligibility + +### Recommended permission set + +- `runtime.adapter` +- `runtime.route-augment` +- `runtime.veto-send` +- `runtime.backend-register` +- `agent.tool.expose` +- `control.command.expose` +- `interaction.handle` +- `conversation.bind` +- `conversation.bind.approve` +- `conversation.control` +- `rpc.expose` +- `service.background` +- `http.route.gateway` +- `http.route.plugin` +- `config.read` +- `config.write` +- `state.read` +- `state.write` +- `credentials.read` +- `credentials.write` +- `network.outbound` +- `process.spawn` +- `filesystem.workspace.read` +- `filesystem.workspace.write` + +Permissions should be independently reviewable and denyable. + +In `advisory` mode they also function as: + +- operator review prompts +- activation policy inputs +- audit and telemetry tags +- documentation of why an extension needs sensitive host-owned surfaces + +### Fine-grained policy gates + +Some behavior should remain under dedicated policy gates instead of being flattened into generic permissions. + +Examples: + +- prompt mutation or prompt injection behavior +- sync transcript-write participation +- fail-open versus fail-closed route augmentation +- whether an extension may bind conversations without per-request operator approval +- whether an interaction handler may invoke conversation-control verbs + +This preserves the intent of current controls such as `plugins.entries..hooks.allowPromptInjection`. + +### High-risk permissions + +These should require explicit operator approval or a strong default policy: + +- `runtime.veto-send` +- `runtime.route-augment` +- `conversation.bind` +- `conversation.bind.approve` +- `runtime.backend-register` +- `credentials.write` +- `process.spawn` +- `http.route.plugin` +- `filesystem.workspace.write` + +High-risk permissions should still matter in `advisory` mode because they drive operator trust decisions even before real isolation exists. + +### Binding and interaction ownership + +Conversation binding and interactive callback routing should be treated as host-owned lifecycle surfaces. + +The host must own: + +- namespace registration and dedupe for interactive callbacks +- approval persistence for extension-requested conversation binds +- restore-on-restart behavior for approved bindings +- cleanup behavior for detached or stale bindings +- channel-surface gating for first-cut conversation-control verbs + +Extensions may own: + +- the logic that decides whether to request a bind +- the interaction payload semantics +- channel-specific presentation details that fit inside the host-owned adapter contract + +Important migration rule: + +- do not turn `src/plugins/conversation-binding.ts` or `src/plugins/interactive.ts` into the permanent architecture target +- those behaviors should migrate into host-owned lifecycle and policy surfaces, with compatibility bridges only where needed + +## Persistence Ownership + +Persistence must be partitioned by owner and intent. + +### Config + +Operator-managed configuration belongs to the host. + +Extensions may contribute: + +- config schema +- config UI hints and sensitivity metadata +- defaults +- migration hints +- setup flow outputs such as config patches produced through host-owned setup primitives + +Extensions must not arbitrarily mutate unrelated config keys. + +The host must also preserve current config redaction semantics: + +- config UI hints such as `sensitive` affect host behavior, not only UI decoration +- config read, redact, restore, and validate flows must preserve round-trippable secret handling comparable to `src/gateway/server-methods/config.ts:151` and `src/config/redact-snapshot.ts:349` + +### State + +Each extension gets a host-assigned state directory. + +This is where background services and caches persist local state. + +This is a required migration change from the current shared plugin service state shape in `src/plugins/services.ts:18`. + +The host must also define a migration strategy for existing state: + +- detect old shared plugin state layouts +- migrate or alias data into per-extension directories +- keep rollback behavior explicit + +### Credentials + +Credential persistence is host-owned. + +Provider integration extensions may return credential payloads, but they must not choose final storage shape or bypass the credential store. + +This is required because auth flows like `extensions/google-gemini-cli-auth/index.ts:24` interact with credentials and config together. + +This rule also applies when those flows are invoked through extension-owned CLI or setup flows. + +### Session and transcript state + +Kernel-owned. + +Extensions may observe or augment session state through declared runtime contracts, but they do not own transcript persistence. + +### Backend-owned state + +Runtime backends such as ACP may require separate service state, but ownership still flows through the host-assigned state boundary. + +### Distribution and onboarding metadata + +Install metadata, channel catalog metadata, docs links, and quickstart hints are host-owned static metadata. + +They are not kernel persistence and they are not extension-private state. + +That static metadata should preserve current channel catalog fields from `src/plugins/manifest.ts:121`, including aliases, docs labels, precedence hints, binding hints, picker extras, and announce-target hints. + +## HTTP And Webhook Ownership + +The host owns all HTTP route registration and conflict resolution. + +This is required because routes can conflict across extensions today, as seen in `src/plugins/http-registry.ts:12`. + +### Route classes + +- ingress transport routes +- authenticated plugin routes +- public callback routes +- diagnostic or admin routes +- dynamic account-scoped routes + +### Required route metadata + +- path +- auth mode +- match mode +- owner contribution id +- whether the route is externally reachable +- whether the route is safe to expose when the extension is disabled +- lifecycle mode (`static` or `dynamic`) +- scope metadata such as account, workspace, or provider binding + +### Conflict rules + +- exact path collisions require explicit resolution +- prefix collisions require overlap analysis +- auth mismatches are fatal +- one extension may not replace another extension's route without explicit policy + +Dynamic route registration must also return an unregister handle so route ownership can be cleaned up during reload, account removal, or degraded shutdown. + +## Runtime Backend Contract + +Some extension contributions provide runtime backends consumed by subsystems rather than directly by the agent. + +ACP is the reference case today: + +- backend type in `src/acp/runtime/registry.ts:4` +- registration in `extensions/acpx/src/service.ts:55` + +### Required backend descriptor + +- backend class id +- backend instance id +- selector key +- health probe +- capability list +- selection rank +- arbitration mode + +### Required backend lifecycle + +- register +- unregister +- probe +- health +- degrade +- recover + +### Backend selection rules + +- explicit requested backend id wins +- if none requested, pick the healthiest backend with the best rank +- if multiple healthy backends tie, use deterministic ordering by extension id then contribution id +- if all backends are unhealthy, expose a typed unavailability error + +### Singleton vs parallel + +Not every backend is singleton. + +ACP may remain effectively singleton at first, but the contract should support future parallel backends with explicit selectors. + +## Slot-Backed Provider Contract + +Not every exclusive runtime provider is a generic backend. + +Current `main` already has slot-backed provider selection in: + +- `src/plugins/slots.ts:12` +- `src/context-engine/registry.ts:60` + +The host must model explicit slot-backed providers for cases such as: + +- context engines +- default memory providers +- future execution or planning engines + +Required slot rules: + +- each slot has a stable slot id +- each slot has a host-defined default +- explicit config selection wins +- only one active provider may own an exclusive slot +- migration preserves existing config semantics such as `plugins.slots.memory` and `plugins.slots.contextEngine` + +Migration rule: + +- slot-backed providers must move into host-owned registries before broader catalog and arbitration migration claims are considered complete + +## Isolation Rules + +The host must isolate extension failures from the kernel as much as possible. + +Minimum requirements: + +- one extension failing startup does not block unrelated extensions +- one contribution registration failure does not corrupt host state +- background-service failures transition the extension to `degraded` or `failed` without leaving stale registrations behind +- stop hooks are best-effort and time-bounded + +In the current trusted in-process mode, "isolation" here means lifecycle and registry isolation, not a security sandbox. + +## Reload And Upgrade Rules + +Hot reload is optional. Deterministic restart behavior is required. + +On reload or upgrade: + +1. stop host-managed services +2. unregister contributions +3. clear host-owned route, command, backend, and slot registrations +4. clear dynamic account-scoped routes and stale runtime handles +5. instantiate the new version +6. reactivate only after validation and policy checks succeed + +If the host continues to support config-driven hot reload during migration, it must also preserve: + +- channel-owned reload prefix behavior equivalent to current `configPrefixes` and `noopPrefixes` +- gateway feature advertisement cleanup and re-registration +- setup-flow and native-command registrations that depend on account-scoped runtime state + +This advertisement handling does not replace callable RPC registration. If a migrated extension exposes callable gateway-style methods, those should still be re-registered through `capability.rpc`. + +During migration, keep the current built-in onboarding fallback in place until host-owned setup surfaces cover bundled channels with parity. + +Pilot rule: + +- the fallback stays in place until `telegram` parity has been recorded for setup-adjacent host behavior, even if runtime messaging parity lands earlier + +## Operator Policy + +The host should support policy controls for: + +- allowed extension ids +- denied permissions +- default permission grants for bundled extensions +- allowed extension origins and provenance requirements +- origin precedence and duplicate resolution +- workspace extensions disabled by default unless explicitly allowed +- bundled channel auto-enable rules tied to channel config +- route exposure policy +- network egress policy +- backend selection policy +- whether external extensions are permitted at all +- SDK compatibility level and deprecation mode +- prompt-mutation policy defaults +- whether interactive extension-owned CLI and setup flows are allowed +- whether extension-owned native command registration is allowed on specific providers +- whether config-driven hot reload descriptors are honored or downgraded to restart-only behavior + +## Observability + +The host must emit structured telemetry for: + +- activation timings +- policy denials +- contribution conflicts +- route conflicts +- backend registration and health +- service start and stop +- extension degradation and recovery +- provenance warnings and origin overrides +- state migration outcomes +- compatibility-mode activation and deprecated SDK usage +- setup flow phase transitions and fallback-path usage +- config redaction or restore validation failures +- reload descriptor application and gateway feature re-registration + +## Immediate Implementation Work + +1. Write the boundary cutover inventory for every current plugin-owned surface. +2. Introduce an extension-host lifecycle state machine. +3. Move route registration policy out of plugin internals into host-owned registries. +4. Add a policy evaluator that understands advisory versus enforced permission modes. +5. Add host-owned credential and per-extension state boundaries for extension services. +6. Generalize backend registration into a host-managed `capability.runtime-backend` registry. +7. Add host-owned subsystem runtime registries for embeddings, media understanding, and TTS instead of widening `registerProvider(...)`. +8. Keep extension-backed search generic by publishing agent-visible search through tool contracts and using runtime-backend only for search backends consumed internally by the host or another subsystem. +9. Add slot-backed provider management for context engines and other exclusive runtime providers. +10. Preserve provenance, origin precedence, and current workspace and bundled enablement rules in host policy. +11. Preserve prompt-mutation policy gates and add explicit state migration handling. +12. Add explicit host registries and typed contracts for extension-owned hooks, channels, providers, tools, commands, CLI, setup flows, config surfaces, and status surfaces. +13. Preserve config redaction-aware schema behavior and current reload or gateway feature contracts during migration. +14. Record lifecycle parity for `thread-ownership` first and `telegram` second before broadening the compatibility bridges. diff --git a/docs/.internal/extension-host-migration/openclaw-kernel-event-pipeline-spec.md b/docs/.internal/extension-host-migration/openclaw-kernel-event-pipeline-spec.md new file mode 100644 index 00000000000..cddb4669d09 --- /dev/null +++ b/docs/.internal/extension-host-migration/openclaw-kernel-event-pipeline-spec.md @@ -0,0 +1,835 @@ +Temporary internal migration note: remove this document once the extension-host migration is complete. + +# OpenClaw Kernel Event Pipeline Spec + +Date: 2026-03-15 + +## Purpose + +This document defines the canonical kernel event model, execution stages, handler classes, ordering, mutation rules, and veto semantics. + +The goal is to replace today's mixed plugin hook behavior with one explicit runtime pipeline and a small set of execution modes that match current `main` behavior. + +## TODOs + +- [ ] Implement canonical event types and stage ordering in code. +- [ ] Bridge current plugin hooks, internal hooks, and agent event streams into the pipeline. +- [ ] Implement sync transcript-write stages with parity for current hot paths. +- [ ] Record the legacy-to-canonical mapping table used by the first pilot migrations. +- [ ] Record parity for `thread-ownership` first and `telegram` second before broader event migration. +- [ ] Document which legacy hook sources are still bridged and which have been retired. +- [ ] Add parity tests for veto, resolver, and sync-stage behavior. + +## Implementation Status + +Current status against this spec: + +- no canonical event pipeline work has landed yet +- only the prerequisites from earlier phases are underway + +Relevant prerequisite work that has landed: + +- an initial Phase 0 cutover inventory now exists in `src/extension-host/cutover-inventory.md` +- the extension-host boundary now owns active registry state +- registry activation now routes through `src/extension-host/activation.ts` +- initial normalized extension schema types now exist +- static consumers can now read host-owned resolved-extension data +- config doc baseline generation now uses the same host-owned resolved-extension data path +- channel, provider, HTTP-route, gateway-method, tool, CLI, service, command, context-engine, and hook registration normalization now has a host-owned helper boundary +- loader cache key construction and registry cache control now have a host-owned helper boundary +- loader provenance helpers now have a host-owned helper boundary +- loader duplicate-order policy now has a host-owned helper boundary +- loader alias-wired module loader creation now has a host-owned helper boundary +- loader lazy runtime proxy creation now has a host-owned helper boundary +- loader initial candidate planning and record creation now have a host-owned helper boundary +- loader entry-path opening and module import now have a host-owned helper boundary +- loader module-export resolution, config validation, and memory-slot load decisions now have a host-owned helper boundary +- loader post-import planning and `register(...)` execution now have a host-owned helper boundary +- loader per-candidate orchestration now has a host-owned helper boundary +- loader top-level load orchestration now has a host-owned helper boundary +- loader host process state now has a host-owned helper boundary +- loader preflight and cache-hit setup now has a host-owned helper boundary +- loader post-preflight pipeline composition now has a host-owned helper boundary +- loader execution setup composition now has a host-owned helper boundary +- loader discovery and manifest bootstrap now has a host-owned helper boundary +- loader discovery policy outcomes now have a host-owned helper boundary +- loader mutable activation state now has a host-owned helper boundary +- loader session run and finalization composition now has a host-owned helper boundary +- loader activation policy outcomes now have a host-owned helper boundary +- loader record-state transitions now have a host-owned helper boundary and enforced loader lifecycle state machine, while still preserving compatibility `PluginRecord.status` values +- loader finalization policy outcomes now have a host-owned helper boundary +- loader final cache, readiness promotion, and activation finalization now has a host-owned helper boundary +- low-risk channel, provider, gateway-method, HTTP-route, tool, CLI, service, command, context-engine, and hook compatibility writes now have a host-owned helper boundary in `src/extension-host/contributions/registry-writes.ts` +- legacy internal-hook bridging and typed prompt-injection compatibility policy now have a host-owned helper boundary in `src/extension-host/compat/hook-compat.ts` +- compatibility `OpenClawPluginApi` composition and logger shaping now have a host-owned helper boundary in `src/extension-host/compat/plugin-api.ts` +- compatibility plugin-registry facade ownership now has a host-owned helper boundary in `src/extension-host/compat/plugin-registry.ts` +- compatibility plugin-registry policy now has a host-owned helper boundary in `src/extension-host/compat/plugin-registry-compat.ts` +- compatibility plugin-registry registration actions now have a host-owned helper boundary in `src/extension-host/compat/plugin-registry-registrations.ts` +- host-owned runtime registry accessors now have a host-owned helper boundary in `src/extension-host/contributions/runtime-registry.ts`, and the channel, provider, tool, command, HTTP-route, gateway-method, CLI, and service slices now keep host-owned storage there with mirrored legacy compatibility views +- plugin command registration, matching, execution, listing, native command-spec projection, and loader reload clearing now have a host-owned helper boundary in `src/extension-host/contributions/command-runtime.ts` +- service startup, stop ordering, service-context creation, and failure logging now have a host-owned helper boundary in `src/extension-host/contributions/service-lifecycle.ts` +- CLI duplicate detection, registrar invocation, and async failure logging now have a host-owned helper boundary in `src/extension-host/contributions/cli-lifecycle.ts` +- gateway method-id aggregation, plugin diagnostic shaping, and extra-handler composition now have a host-owned helper boundary in `src/extension-host/contributions/gateway-methods.ts` +- plugin tool resolution, conflict handling, optional-tool gating, and plugin-tool metadata tracking now have a host-owned helper boundary in `src/extension-host/contributions/tool-runtime.ts` +- plugin provider projection from registry entries into runtime provider objects now have a host-owned helper boundary in `src/extension-host/contributions/provider-runtime.ts` +- plugin provider discovery filtering, order grouping, and result normalization now have a host-owned helper boundary in `src/extension-host/contributions/provider-discovery.ts` +- provider matching, auth-method selection, config-patch merging, and default-model application now have a host-owned helper boundary in `src/extension-host/contributions/provider-auth.ts` +- provider onboarding option building, model-picker entry building, and provider-method choice resolution now have a host-owned helper boundary in `src/extension-host/contributions/provider-wizard.ts` +- loaded-provider auth application, plugin-enable gating, auth-method execution, and post-auth default-model handling now have a host-owned helper boundary in `src/extension-host/contributions/provider-auth-flow.ts` +- provider post-selection hook lookup and invocation now have a host-owned helper boundary in `src/extension-host/contributions/provider-model-selection.ts` + +Why this matters for this spec: + +- event work should land on top of a host-owned boundary and normalized contribution model rather than on top of more plugin-era runtime seams +- the current implementation has deliberately not started canonical bridge or stage work before those earlier boundaries were in place, including the first loader-runtime, record-state, discovery-policy, activation-policy, finalization-policy, low-risk registry-write, hook-compat, plugin-api, plugin-registry, plugin-registry-compat, plugin-registry-registrations, runtime-registry storage and accessors, command-runtime, service-lifecycle, CLI-lifecycle, gateway-methods, tool-runtime, provider-runtime, provider-discovery, provider-auth, provider-wizard, provider-auth-flow, and provider-model-selection seams + +## Design Goals + +- every inbound and outbound path goes through one canonical pipeline +- handler behavior is declared, not inferred +- routing-affecting handlers are distinct from passive observers +- ordering and merge rules are deterministic +- extension failures are isolated and visible +- sync transcript-write paths remain explicit rather than being hidden inside generic async stages +- current plugin hooks, internal hooks, and agent event streams can be bridged into one model incrementally +- the migration path for legacy event buses is explicit rather than accidental + +## Sequencing Constraints + +This pipeline is a migration target, not a prerequisite for every other host change. + +Therefore: + +- minimal SDK compatibility and host registry ownership should land before broad hook migration +- the first event migration should prove parity for a small non-channel hook case and a channel case +- do not require every event family to be implemented before pilot migrations can bridge the current hook set +- do not leave legacy hook buses as undocumented permanent peers to the canonical pipeline + +## Canonical Event Families + +The kernel should emit typed event families instead of raw plugin hook names. + +Recommended families: + +- `runtime.started` +- `runtime.stopping` +- `gateway.starting` +- `gateway.started` +- `gateway.stopping` +- `command.received` +- `command.completed` +- `account.started` +- `account.stopped` +- `ingress.received` +- `ingress.normalized` +- `ingress.claiming` +- `routing.resolving` +- `routing.resolved` +- `session.starting` +- `session.started` +- `session.resetting` +- `agent.starting` +- `agent.model.resolving` +- `agent.prompt.building` +- `agent.llm.input` +- `agent.llm.output` +- `agent.tool.calling` +- `agent.tool.called` +- `transcript.tool-result.persisting` +- `transcript.message.writing` +- `compaction.before` +- `compaction.after` +- `agent.completed` +- `egress.preparing` +- `egress.sending` +- `egress.sent` +- `egress.cancelled` +- `egress.failed` +- `interaction.received` +- `subagent.spawning` +- `subagent.spawned` +- `subagent.delivery.resolving` +- `subagent.delivery.resolved` +- `subagent.completed` + +These families intentionally cover the behavior currently spread across `src/plugins/hooks.ts:1`, `src/hooks/internal-hooks.ts:13`, `src/infra/agent-events.ts:3`, and channel monitors. + +`ingress.claiming` exists to absorb behavior that is currently tempting to model as plugin-specific hooks or direct dispatch short-circuits: + +- bound conversation ownership +- first-claim-wins plugin or extension routing +- future route-claim or veto decisions that must run before command or agent dispatch + +## Canonical Event Envelope + +Every event should carry: + +- `eventId` +- `family` +- `occurredAt` +- `workspaceId` +- `agentId` +- `sessionId` +- `accountRef` +- `conversationRef` +- `threadRef` +- `messageRef` +- `sourceContributionId` +- `correlationId` +- `payload` +- `metadata` +- `providerMetadata` +- `hotPath` + +The event envelope is immutable. Mutation happens through stage outputs, not by mutating the event object in place. + +## Handler Classes + +Each handler contribution must declare exactly one class: + +- `observer` +- `augmenter` +- `mutator` +- `veto` +- `resolver` + +### `observer` + +Side effects only. No runtime decision output. + +### `augmenter` + +May attach additional context for downstream stages. + +Examples: + +- prompt context injection +- memory recall summaries +- diagnostics enrichment + +### `mutator` + +May modify a typed working object for the current pipeline stage. + +Examples: + +- prompt build additions +- model override +- tool call decoration + +### `veto` + +May cancel a downstream action with a typed reason. + +Examples today: + +- send cancellation in `extensions/thread-ownership/index.ts:63` + +### `resolver` + +May produce a selected target or route decision. + +Examples today: + +- subagent delivery target selection in `extensions/discord/src/subagent-hooks.ts:103` + +Only `veto` and `resolver` handlers may influence routing or delivery decisions. + +`ingress.claiming` is the first concrete place where a resolver-like route claim is expected to matter during migration. + +First-cut parity rule for `ingress.claiming`: + +- claim handlers run sequentially in deterministic order +- the first successful claim wins ownership of the inbound turn +- passive observers still run in their own stages instead of being skipped accidentally +- the migration bridge may target a single extension when a host-owned binding already resolved the owner + +## Execution Modes + +The semantic handler class is not enough by itself. + +Each stage must also declare one of three execution modes: + +- `parallel` + For read-only observers and low-risk side effects. +- `sequential` + For merge, mutation, veto, and resolver stages. +- `sync-sequential` + For transcript and persistence hot paths where async handlers are not allowed. + +This mirrors current `main` behavior in `src/plugins/hooks.ts:199`, `src/plugins/hooks.ts:226`, `src/plugins/hooks.ts:465`, and `src/plugins/hooks.ts:528`. + +## Deterministic Ordering + +Within a stage, handlers run in this order: + +1. explicit priority descending +2. extension id ascending +3. contribution id ascending + +Priority is optional. Ties must resolve deterministically. + +## Stage Execution Model + +Every pipeline stage declares: + +- which handler classes are allowed +- execution mode +- whether handlers run in parallel or sequentially +- how outputs are merged +- whether errors fail open or fail closed + +## Gateway And Command Pipeline + +### Stage: `gateway.starting`, `gateway.started`, `gateway.stopping` + +Allowed handler classes: + +- `observer` + +Execution mode: + +- `parallel` + +Purpose: + +- lifecycle telemetry +- startup and shutdown side effects + +### Stage: `command.received`, `command.completed` + +Allowed handler classes: + +- `observer` +- `augmenter` + +Execution mode: + +- `sequential` + +Purpose: + +- command audit +- command lifecycle integration +- operator-visible side effects +- preserve source-surface metadata for chat commands, native commands, and host CLI invocations when those flows are bridged into canonical command events + +Bridge requirement: + +- the current internal hook bus in `src/hooks/internal-hooks.ts:13` +- and the current agent event stream in `src/infra/agent-events.ts:3` + +must be mapped deliberately into canonical families during migration. + +Acceptable end states are: + +- they become compatibility sources that emit canonical events +- or they are fully retired after parity is reached + +An undocumented permanent fourth event system is not acceptable. + +## Ingress Pipeline + +### Stage 1: `ingress.received` + +Input: + +- raw adapter payload + +Allowed handler classes: + +- `observer` + +Execution mode: + +- `parallel` + +Purpose: + +- telemetry +- raw audit +- diagnostics + +### Stage 2: `ingress.normalized` + +Input: + +- normalized inbound envelope from `adapter.runtime.decodeIngress` + +Allowed handler classes: + +- `observer` +- `augmenter` +- `mutator` + +Execution mode: + +- `sequential` + +Purpose: + +- add normalized metadata +- enrich source/account context +- attach pre-routing annotations + +This stage must not choose a route. + +### Stage 3: `routing.resolving` + +Allowed handler classes: + +- `augmenter` +- `resolver` +- `veto` + +Execution mode: + +- `sequential` + +Purpose: + +- route lookup +- ownership checks +- subagent delivery target resolution +- policy application before route finalization + +Merge rules: + +- `resolver` outputs produce candidate route decisions +- highest-precedence valid decision wins +- `veto` may cancel route selection + +### Stage 4: `routing.resolved` + +Allowed handler classes: + +- `observer` +- `augmenter` + +Execution mode: + +- `sequential` + +Purpose: + +- emit resolved route metadata +- enrich downstream session context + +### Stage 5: `session.starting` + +Allowed handler classes: + +- `observer` +- `augmenter` +- `mutator` + +Execution mode: + +- `sequential` + +Purpose: + +- bind session context +- attach memory lookup keys +- prepare session-scoped metadata + +### Stage 6: `session.started` + +Allowed handler classes: + +- `observer` + +Execution mode: + +- `parallel` + +Purpose: + +- fire lifecycle observers + +### Stage 7: `agent.starting` + +Allowed handler classes: + +- `observer` +- `augmenter` + +Execution mode: + +- `sequential` + +Purpose: + +- last pre-run annotations + +## Prompt And Model Pipeline + +### Stage: `agent.model.resolving` + +Allowed handler classes: + +- `mutator` + +Execution mode: + +- `sequential` + +Merge rules: + +- first defined model override wins +- first defined provider override wins + +This mirrors current precedence in `src/plugins/hooks.ts:117`. + +### Stage: `agent.prompt.building` + +Allowed handler classes: + +- `augmenter` +- `mutator` + +Execution mode: + +- `sequential` + +Merge rules: + +- static system guidance composes in declared order +- ephemeral prompt additions compose in declared order +- direct system prompt replacement is allowed only for explicitly trusted mutators + +This replaces the ambiguous overlap between `before_prompt_build` and legacy `before_agent_start` in `src/plugins/types.ts:422`. + +### Stage: `agent.llm.input` + +Allowed handler classes: + +- `observer` +- `augmenter` + +Execution mode: + +- `sequential` + +Purpose: + +- provider-call audit +- input usage and prompt metadata capture + +### Stage: `agent.llm.output` + +Allowed handler classes: + +- `observer` +- `augmenter` + +Execution mode: + +- `sequential` + +Purpose: + +- provider response audit +- usage capture +- output enrichment + +## Tool Pipeline + +### Stage: `agent.tool.calling` + +Allowed handler classes: + +- `observer` +- `augmenter` +- `mutator` +- `veto` + +Execution mode: + +- `sequential` + +Purpose: + +- tool policy checks +- argument normalization +- tool-call audit + +### Stage: `agent.tool.called` + +Allowed handler classes: + +- `observer` +- `augmenter` + +Execution mode: + +- `sequential` + +Purpose: + +- result indexing +- memory capture +- diagnostics + +### Stage: `agent.completed` + +Allowed handler classes: + +- `observer` +- `augmenter` + +Execution mode: + +- `sequential` + +Purpose: + +- end-of-run capture +- automatic memory storage +- metrics + +## Persistence Pipeline + +### Stage: `transcript.tool-result.persisting` + +Allowed handler classes: + +- `mutator` + +Execution mode: + +- `sync-sequential` + +Purpose: + +- mutate the tool-result message that will be appended to transcripts + +Rules: + +- async handlers are invalid +- handlers run in deterministic priority order +- each handler sees the previous handler's output + +This is the explicit replacement for today's sync-only `tool_result_persist` hook in `src/plugins/hooks.ts:465`. + +### Stage: `transcript.message.writing` + +Allowed handler classes: + +- `mutator` +- `veto` + +Execution mode: + +- `sync-sequential` + +Purpose: + +- final transcript message mutation +- transcript write suppression when explicitly requested + +Rules: + +- async handlers are invalid +- successful veto decisions are terminal +- mutation happens before the final write + +This is the explicit replacement for today's sync-only `before_message_write` hook in `src/plugins/hooks.ts:528`. + +## Compaction And Reset Pipeline + +Canonical stages: + +- `compaction.before` +- `compaction.after` +- `session.resetting` + +## Egress Pipeline + +### Stage 1: `egress.preparing` + +Input: + +- normalized outbound envelope + +Allowed handler classes: + +- `observer` +- `augmenter` +- `mutator` +- `veto` +- `resolver` + +Execution mode: + +- `sequential` + +Purpose: + +- choose provider or account when not explicit +- attach send metadata +- enforce ownership or safety policy + +This stage replaces today’s mixed send hooks and route checks. + +### Stage 2: `egress.sending` + +Allowed handler classes: + +- `observer` + +Execution mode: + +- `parallel` + +Purpose: + +- telemetry and audit before transport send + +### Stage 3: `egress.sent`, `egress.cancelled`, `egress.failed` + +Allowed handler classes: + +- `observer` +- `augmenter` + +Execution mode: + +- `sequential` + +Purpose: + +- post-send side effects +- delivery-state indexing + +## Interaction Pipeline + +Interaction events should not be routed through message hooks. + +Canonical stages: + +- `interaction.received` +- `interaction.resolved` +- `interaction.completed` + +These handle slash commands, button presses, modal submissions, and similar surfaces. + +## Subagent Pipeline + +The current hook set already proves this needs explicit treatment: + +- `subagent_spawning` +- `subagent_delivery_target` +- `subagent_spawned` +- `subagent_ended` + +The canonical form should be: + +- `subagent.spawning` +- `subagent.spawned` +- `subagent.delivery.resolving` +- `subagent.delivery.resolved` +- `subagent.completed` + +Resolver semantics: + +- multiple candidates may be proposed +- explicit target beats inferred target +- otherwise highest-ranked valid candidate wins + +## Merge Rules + +### Observer + +No merge output. + +### Augmenter + +Produces additive metadata only. + +Conflicts merge by: + +- key append for list-like fields +- last-writer-wins only for fields explicitly marked replaceable + +### Mutator + +Produces typed patch objects. + +Rules: + +- patch schema is stage-specific +- patches apply in deterministic order +- later patches see earlier outputs + +### Veto + +Produces: + +- `allow` +- `cancel` + +Rules: + +- one `cancel` is terminal if the stage is fail-closed +- fail-open stages may ignore veto errors but not successful veto decisions + +### Resolver + +Produces candidate selections. + +Rules: + +- explicit target selectors win +- otherwise rank, policy, and deterministic tie-breakers apply + +## Error Handling + +Per-stage error policy must be explicit. + +Recommended defaults: + +- telemetry and observer stages fail open +- routing and send veto stages fail open unless the contribution declares `failClosed` +- credential or auth mutation stages fail closed +- backend selection stages fail closed when no valid provider remains +- sync transcript stages fail open on handler failure but must still reject accidental async handlers + +## Legacy Hook Mapping + +Current hook names map approximately like this: + +- `before_model_resolve` -> `agent.model.resolving` +- `before_prompt_build` -> `agent.prompt.building` +- `before_agent_start` -> split between `agent.model.resolving` and `agent.prompt.building` +- `llm_input` -> `agent.llm.input` +- `llm_output` -> `agent.llm.output` +- `message_received` -> `ingress.normalized` +- `message_sending` -> `egress.preparing` +- `message_sent` -> `egress.sent` +- `before_tool_call` -> `agent.tool.calling` +- `after_tool_call` -> `agent.tool.called` +- `tool_result_persist` -> `transcript.tool-result.persisting` +- `before_message_write` -> `transcript.message.writing` +- `before_compaction` -> `compaction.before` +- `after_compaction` -> `compaction.after` +- `before_reset` -> `session.resetting` +- `gateway_start` -> `gateway.started` +- `gateway_stop` -> `gateway.stopping` +- `subagent_delivery_target` -> `subagent.delivery.resolving` + +First pilot focus: + +- `thread-ownership` should validate `message_received` and `message_sending` migration into canonical ingress and egress stages +- `telegram` should validate that channel-path runtime behavior can participate in canonical events without reintroducing plugin-shaped kernel seams + +## Immediate Implementation Work + +1. Add canonical event and stage types to the kernel. +2. Build a stage runner with explicit handler-class validation. +3. Add typed patch and veto result contracts per stage, including sync-sequential stages. +4. Bridge legacy plugin hooks, internal hooks, and agent events into canonical stages in the extension host only. +5. Record the exact legacy-to-canonical mapping used by `thread-ownership`. +6. Record the exact legacy-to-canonical mapping used by `telegram`. +7. Refactor one channel and one non-channel extension through the new pipeline before broader migration. +8. Decide and document the retirement plan for any legacy event bus that remains after parity is achieved. diff --git a/docs/.internal/extension-host-migration/openclaw-kernel-extension-host-transition-plan.md b/docs/.internal/extension-host-migration/openclaw-kernel-extension-host-transition-plan.md new file mode 100644 index 00000000000..309894e4b54 --- /dev/null +++ b/docs/.internal/extension-host-migration/openclaw-kernel-extension-host-transition-plan.md @@ -0,0 +1,1854 @@ +Temporary internal migration note: remove this document once the extension-host migration is complete. + +# OpenClaw Kernel + Extension Host Transition Plan + +Date: 2026-03-15 + +## Purpose + +This document defines a stricter transition plan for OpenClaw: + +- the kernel must contain no plugin-specific code +- bundled extensions must be treated the same as externally installed extensions +- agents must see a clean, canonical catalog of what they can do +- conflicts and parallel providers must be handled explicitly, including multiple active messaging channels for the same agent +- the plan must preserve current functionality such as onboarding metadata, slot-backed providers, and transcript-write hooks + +This is a stricter target than the earlier universal adapter plan. The earlier plan still kept plugin-shaped compatibility concerns too close to core. This version moves those concerns into an extension host layer outside the kernel. + +## TODOs + +- [ ] Confirm the implementation phase order still matches current repo priorities and staffing. +- [x] Write the initial boundary cutover inventory for every current plugin-owned surface. +- [ ] Keep the boundary cutover inventory updated as surfaces move. +- [ ] Track which phase has started, is in progress, and is complete. +- [ ] Link each completed phase to the concrete PRs or commits that implemented it. +- [ ] Mark which legacy compatibility shims still exist and which have been removed. +- [ ] Define the detailed pilot migration matrix and parity gates before broader compatibility rollout. +- [ ] Record any intentional scope cuts from the original transition sequence. + +## Implementation Status + +Current status against this transition plan: + +- Phase 0 has started but is not complete. +- Phase 1 has started but is not complete. +- Phase 2 has started in a compatibility-preserving host-boundary form but is not complete. +- Phase 3 onward remains unimplemented. + +What has landed: + +- a new `src/extension-host/*` boundary now exists and owns active registry state +- the legacy plugin runtime now delegates active-registry ownership to the extension host +- registry activation now routes through `src/extension-host/activation.ts` +- initial normalized extension types now exist in code, including `ResolvedExtension`, `ResolvedContribution`, and `ContributionPolicy` +- plugin manifest records now carry a normalized `resolvedExtension` +- a host-owned resolved-extension registry view now exists for static consumers +- an initial Phase 0 cutover inventory now exists in `src/extension-host/cutover-inventory.md` +- plugin SDK alias resolution now routes through `src/extension-host/compat/loader-compat.ts` +- loader alias-wired module loader creation now routes through `src/extension-host/activation/loader-module-loader.ts` +- loader cache key construction and registry cache control now route through `src/extension-host/activation/loader-cache.ts` +- loader lazy runtime proxy creation now routes through `src/extension-host/activation/loader-runtime-proxy.ts` +- loader provenance helpers now route through `src/extension-host/policy/loader-provenance.ts` +- loader duplicate-order and record/error policy now route through `src/extension-host/policy/loader-policy.ts` +- loader discovery policy outcomes now route through `src/extension-host/policy/loader-discovery-policy.ts` +- loader initial candidate planning and record creation now route through `src/extension-host/activation/loader-records.ts` +- loader entry-path opening and module import now route through `src/extension-host/activation/loader-import.ts` +- loader module-export resolution, config validation, and memory-slot load decisions now route through `src/extension-host/activation/loader-runtime.ts` +- loader post-import planning and `register(...)` execution now route through `src/extension-host/activation/loader-register.ts` +- loader per-candidate orchestration now routes through `src/extension-host/activation/loader-flow.ts` +- loader top-level load orchestration now routes through `src/extension-host/activation/loader-orchestrator.ts` +- loader host process state now routes through `src/extension-host/activation/loader-host-state.ts` +- loader preflight and cache-hit setup now routes through `src/extension-host/activation/loader-preflight.ts` +- loader post-preflight pipeline composition now routes through `src/extension-host/activation/loader-pipeline.ts` +- loader execution setup composition now routes through `src/extension-host/activation/loader-execution.ts` +- loader discovery and manifest bootstrap now routes through `src/extension-host/activation/loader-bootstrap.ts` +- loader mutable activation state now routes through `src/extension-host/activation/loader-session.ts` +- loader session run and finalization composition now routes through `src/extension-host/activation/loader-run.ts` +- loader activation policy outcomes now route through `src/extension-host/policy/loader-activation-policy.ts` +- loader record-state transitions now route through `src/extension-host/activation/loader-state.ts`, which now enforces an explicit loader lifecycle state machine while preserving compatibility `PluginRecord.status` values +- loader finalization policy results now route through `src/extension-host/policy/loader-finalization-policy.ts` +- loader final cache, readiness promotion, and activation finalization now routes through `src/extension-host/activation/loader-finalize.ts` +- runtime registration normalization has started in `src/extension-host/contributions/runtime-registrations.ts` for channel, provider, HTTP-route, gateway-method, tool, CLI, service, command, context-engine, and hook registrations +- low-risk runtime compatibility writes for channel, provider, gateway-method, HTTP-route, tool, CLI, service, command, context-engine, and hook registrations now route through `src/extension-host/contributions/registry-writes.ts` +- legacy internal-hook bridging and typed prompt-injection compatibility policy now route through `src/extension-host/compat/hook-compat.ts` +- compatibility `OpenClawPluginApi` composition and logger shaping now route through `src/extension-host/compat/plugin-api.ts` +- compatibility plugin-registry facade ownership now routes through `src/extension-host/compat/plugin-registry.ts` +- compatibility plugin-registry policy now routes through `src/extension-host/compat/plugin-registry-compat.ts` +- compatibility plugin-registry registration actions now route through `src/extension-host/compat/plugin-registry-registrations.ts` +- host-owned runtime registry accessors now route through `src/extension-host/contributions/runtime-registry.ts`, and the channel, provider, tool, command, HTTP-route, gateway-method, CLI, and service slices now keep host-owned storage there with mirrored legacy compatibility views +- plugin command registration, matching, execution, listing, native command-spec projection, and loader reload clearing now route through `src/extension-host/contributions/command-runtime.ts` +- service startup, stop ordering, service-context creation, and failure logging now route through `src/extension-host/contributions/service-lifecycle.ts` +- CLI duplicate detection, registrar invocation, and async failure logging now route through `src/extension-host/contributions/cli-lifecycle.ts` +- gateway method-id aggregation, plugin diagnostic shaping, and extra-handler composition now route through `src/extension-host/contributions/gateway-methods.ts` +- plugin tool resolution, conflict handling, optional-tool gating, and plugin-tool metadata tracking now route through `src/extension-host/contributions/tool-runtime.ts` +- plugin provider projection from registry entries into runtime provider objects now routes through `src/extension-host/contributions/provider-runtime.ts` +- plugin provider discovery filtering, order grouping, and result normalization now route through `src/extension-host/contributions/provider-discovery.ts` +- provider matching, auth-method selection, config-patch merging, and default-model application now route through `src/extension-host/contributions/provider-auth.ts` +- provider onboarding option building, model-picker entry building, and provider-method choice resolution now route through `src/extension-host/contributions/provider-wizard.ts` +- loaded-provider auth application, plugin-enable gating, auth-method execution, and post-auth default-model handling now route through `src/extension-host/contributions/provider-auth-flow.ts` +- provider post-selection hook lookup and invocation now route through `src/extension-host/contributions/provider-model-selection.ts` +- several existing consumers now read host-owned normalized data instead of plugin-era manifest or runtime state directly: + - channel and dock lookup surfaces + - message-channel normalization + - plugin HTTP route registry default lookup + - package metadata parsing in discovery and install flows + - channel catalog package metadata parsing + - plugin skill discovery + - plugin auto-enable + - config doc baseline generation + - config validation indexing +- several runtime consumers now also read through host-owned runtime-registry accessors instead of touching raw plugin-registry arrays or handler maps directly: + - channel lookup + - provider projection + - tool resolution + - service lifecycle startup + - CLI registration + - command runtime entry detection + - gateway method aggregation + - gateway plugin HTTP route matching +- plugin command execution and command-status listing now read through `src/extension-host/contributions/command-runtime.ts` instead of the legacy `src/plugins/commands.ts` implementation +- the channel, provider, tool, command, HTTP-route, gateway-method, CLI, and service slices now also keep host-owned runtime-registry storage with mirrored legacy compatibility arrays and handler maps +- `src/cli/plugin-registry.ts` now treats any pre-seeded runtime entry surface as already loaded, not just plugins, channels, or tools + +How it was done: + +- by extracting a host-owned active-registry module first +- by turning `src/plugins/runtime.ts` into a compatibility facade rather than breaking existing callers +- by introducing normalized static schema types before changing heavy runtime activation paths +- by letting the legacy manifest registry project into a host-owned resolved-extension shape so existing call sites could migrate incrementally +- by migrating static consumers one by one onto resolved-extension data instead of forcing a single cutover +- by moving the first low-risk runtime writes behind host-owned helpers while keeping `src/plugins/registry.ts` as the compatibility call surface +- by starting actual low-risk runtime write ownership for channel, provider, gateway-method, HTTP-route, tool, CLI, service, command, context-engine, and hook registrations once normalization helpers were already in place, while keeping lifecycle semantics in legacy owners where they still apply +- by moving plugin command duplicate enforcement, registration, matching, execution, listing, native command-spec projection, and loader reload clearing behind `src/extension-host/contributions/command-runtime.ts` while keeping `src/plugins/commands.ts` as the compatibility facade +- by moving the first loader-owned compatibility pieces behind host-owned helpers before changing discovery, enablement, or policy flow +- by moving cache-key construction, cache reads, cache writes, and cache clearing behind host-owned helpers before changing activation-state ownership +- by extracting lazy runtime proxy creation and alias-wired Jiti module-loader creation into host-owned helpers before broader bootstrap or lifecycle ownership changes +- by extracting discovery, manifest loading, manifest diagnostics, discovery-policy logging, provenance building, and candidate ordering into a host-owned loader-bootstrap helper before broader lifecycle ownership changes +- by extracting candidate iteration, manifest lookup, per-candidate session processing, and finalization handoff into a host-owned loader-run helper before broader lifecycle ownership changes +- by moving the next loader-owned policy helpers behind host-owned modules while preserving the current load/skip/error behavior +- by moving initial candidate planning and record construction behind host-owned helpers before changing import and registration flow +- by moving entry-path opening and module import behind host-owned helpers before changing cache wiring or lifecycle orchestration +- by moving loader runtime decisions next, while preserving lazy loading, config validation behavior, and memory-slot policy behavior +- by moving post-import planning and `register(...)` execution behind host-owned helpers before changing entry-path and import flow +- by composing those seams into one host-owned per-candidate orchestrator before changing cache and lifecycle finalization behavior +- by moving loader record-state transitions into host-owned helpers before enforcing them as a loader lifecycle state machine +- by moving cache writes, provenance warnings, final memory-slot warnings, and activation into a host-owned loader finalizer before introducing an explicit lifecycle state machine +- by adding explicit compatibility `lifecycleState` mapping on loader-owned plugin records before enforcing the loader lifecycle state machine +- by turning that compatibility `lifecycleState` field into an enforced loader lifecycle state machine with readiness promotion during finalization +- by moving the remaining top-level loader orchestration into a host-owned module so `src/plugins/loader.ts` becomes a compatibility facade instead of the real owner +- by extracting shared discovery warning-cache state and loader reset behavior into a host-owned loader-host-state helper before shrinking the remaining orchestrator surface +- by extracting test-default application, config normalization, cache-key construction, cache-hit activation, and command-clear setup into a host-owned loader-preflight helper before shrinking the remaining orchestrator surface +- by extracting post-preflight execution setup and session-run composition into a host-owned loader-pipeline helper before shrinking the remaining orchestrator surface +- by extracting runtime creation, registry creation, bootstrap setup, module-loader creation, and session creation into a host-owned loader-execution helper before shrinking the remaining orchestrator surface +- by moving mutable activation state such as seen-id tracking, memory-slot selection, and finalization inputs into a host-owned loader session instead of leaving them in top-level loader variables +- by extracting shared provenance path matching and install-rule evaluation into `src/extension-host/policy/loader-provenance.ts` so activation and finalization policy seams reuse one host-owned implementation +- by turning open-allowlist discovery warnings into explicit host-owned discovery-policy results before the orchestrator logs them +- by moving duplicate precedence, config enablement, and early memory-slot gating into explicit host-owned activation-policy outcomes instead of leaving them inline in the loader flow +- by turning provenance-based untracked-extension warnings and final memory-slot warnings into explicit host-owned finalization-policy results before the finalizer applies them +- by extracting legacy internal-hook bridging and typed prompt-injection compatibility policy into a host-owned hook-compat helper while leaving actual hook execution ownership unchanged +- by extracting compatibility `OpenClawPluginApi` composition and logger shaping into a host-owned plugin-api helper while keeping the concrete registration callbacks in the legacy registry surface +- by extracting the remaining compatibility plugin-registry facade into a host-owned helper so `src/plugins/registry.ts` becomes a thin wrapper instead of the real owner +- by extracting provider normalization, command duplicate enforcement, and registry-local diagnostic shaping into a host-owned registry-compat helper while leaving the underlying provider-validation and plugin-command subsystems unchanged +- by extracting low-risk registry registration actions into a host-owned registry-registrations helper so the compatibility facade composes host-owned actions instead of implementing them inline +- by extracting service startup, stop ordering, service-context creation, and failure logging into a host-owned service-lifecycle helper while `src/plugins/services.ts` remains the compatibility entry point +- by extracting CLI duplicate detection, registrar invocation, and async failure logging into a host-owned CLI-lifecycle helper while `src/plugins/cli.ts` remains the compatibility entry point +- by extracting gateway method-id aggregation, plugin diagnostic shaping, and extra-handler composition into a host-owned gateway-methods helper while request dispatch semantics remain in the gateway server code +- by extracting plugin tool resolution, conflict handling, optional-tool gating, and plugin-tool metadata tracking into a host-owned tool-runtime helper while `src/plugins/tools.ts` remains the loader and config-normalization facade +- by extracting provider projection from registry entries into runtime provider objects into a host-owned provider-runtime helper while `src/plugins/providers.ts` remains the loader and config-normalization facade +- by extracting provider discovery filtering, order grouping, and result normalization into a host-owned provider-discovery helper while `src/plugins/provider-discovery.ts` remains the compatibility facade around the legacy provider loader path +- by extracting provider matching, auth-method selection, config-patch merging, and default-model application into a host-owned provider-auth helper while `src/commands/provider-auth-helpers.ts` remains the command-facing compatibility facade +- by extracting provider onboarding option building, model-picker entry building, and provider-method choice resolution into a host-owned provider-wizard helper while `src/plugins/provider-wizard.ts` remains the compatibility facade around loader-backed provider access and post-selection hooks +- by extracting loaded-provider auth application, plugin-enable gating, auth-method execution, and post-auth default-model handling into a host-owned provider-auth-flow helper while `src/commands/auth-choice.apply.plugin-provider.ts` remains the compatibility entry point +- by extracting provider post-selection hook lookup and invocation into a host-owned provider-model-selection helper while `src/plugins/provider-wizard.ts` remains the compatibility facade and existing command consumers continue migrating onto the host-owned surface +- by extracting provider-id normalization into `src/agents/provider-id.ts` so provider-only host seams do not inherit the heavier agent and browser dependency graph from `src/agents/model-selection.ts` +- by extracting model-ref parsing into `src/agents/model-ref.ts` and Google model-id normalization into `src/agents/google-model-id.ts` so provider auth and setup seams can be tested without pulling the heavier provider-loader and browser dependency graph +- by introducing host-owned runtime-registry accessors for low-risk runtime consumers first, then moving channel, provider, tool, command, HTTP-route, gateway-method, CLI, and service storage into that host-owned state while keeping mirrored legacy compatibility arrays and handler maps +- by tightening the CLI pre-load fast path to treat any host-known runtime entry surface as already loaded rather than only plugins, channels, or tools +- by moving static and lookup-heavy consumers first, where the ownership boundary matters but runtime risk is lower + +Committed implementation slices so far: + +- `6abf6750ee` `Plugins: add extension host registry boundary` +- `1aab89e820` `Plugins: extract loader host seams` +- `7bc3135082` `Plugins: extract loader candidate planning` +- `3a122c95fa` `Plugins: extract loader register flow` +- `fc81454038` `Plugins: extract loader import flow` +- `e1b207f4cf` `Plugins: extract loader candidate orchestration` +- `0c44d8049b` `Plugins: extract loader finalization` +- `33ef55a9ee` `Plugins: add loader lifecycle state mapping` +- `6590e19095` `Plugins: extract loader cache control` +- `c8d82a8f19` `Plugins: extract loader orchestration` +- `d32f65eb5e` `Plugins: add loader lifecycle state machine` +- `da9aad0c0f` `Plugins: add loader activation session` +- `fc51ce2867` `Plugins: add loader activation policy` +- `fd7488e10a` `Plugins: add loader finalization policy` +- `97e2af7f97` `Plugins: add loader discovery policy` +- `83b18eab72` `Plugins: share loader provenance helpers` +- `52495d23d5` `Plugins: extract loader runtime factories` +- `6e187ffb62` `Plugins: extract loader bootstrap` +- `234a540720` `Plugins: extract loader session runner` +- `a98443c39d` `Plugins: extract loader execution setup` +- `c9323aa016` `Plugins: extract loader preflight` +- `0df51ae6b4` `Plugins: extract loader pipeline` +- `e557b39cb2` `Plugins: extract loader host state` +- `07c3ae9c87` `Plugins: extract low-risk registry writes` +- `bc71592270` `Plugins: extend registry write helpers` +- `27fc645484` `Plugins: extend registry writes for hooks` +- `b407d7f476` `Plugins: extract hook compatibility` +- `a1e1dcc01a` `Plugins: extract plugin api facade` +- `0e190d64d4` `Plugins: extract registry compatibility facade` +- `944d787df1` `Plugins: extract registry compatibility policy` +- `4ca9cd7e5e` `Plugins: extract registry registration actions` +- `6b24e65719` `Plugins: extract service lifecycle` +- `b5757a6625` `Plugins: extract CLI lifecycle` +- `e0e3229bcb` `Gateway: extract extension host method surface` +- `af7ac14eed` `Plugins: extract tool runtime` +- `19087405d2` `Plugins: extract provider runtime` +- `1303419471` `Plugins: extract provider discovery` +- `afb6e4b185` `Plugins: extract provider auth and wizard flows` +- `cc3d59d59e` `Plugins: extract provider auth application flow` +- `e6cd834f8e` `Plugins: extract provider model selection hook` +- `11cbe08ec6` `Plugins: add host-owned route and gateway storage` +- `89e6b38152` `Docs: refresh runtime registry storage status` +- `ad0c235d16` `Plugins: add host-owned CLI and service storage` +- `d34a5aa870` `Docs: refresh runtime registry storage progress` +- `2be54e9861` `Plugins: add host-owned tool and provider storage` +- `235021766c` `Docs: refresh tool and provider storage status` +- `e109d5ef1b` `Plugins: add host-owned channel storage` +- `24fca48453` `Docs: refresh channel storage status` +- `961015f08c` `Channels: finish message-channel host lookup` +- `4c7f62649b` `Plugins: extract command runtime` +- `89414ed857` `Docs: track extension host migration internally` +- `d8af1eceaf` `Docs: refresh extension host migration status` + +What has not landed: + +- keeping the cutover inventory current as more surfaces move +- broader lifecycle ownership beyond the loader state machine, session-owned activation state, and explicit discovery-policy, activation-policy, and finalization-policy outcomes, plus remaining policy semantics +- host-owned registration surfaces beyond the first normalization helpers and low-risk channel, provider, gateway-method, HTTP-route, tool, CLI, service, command, context-engine, and hook compatibility write slices +- SDK compatibility translation work +- canonical event stages +- canonical capability catalogs +- arbitration migration +- pilot migrations for `thread-ownership` or `telegram` + +## Non-Negotiable End State + +The kernel must not know about: + +- plugins +- plugin manifests +- plugin ids +- plugin installation sources +- bundled versus external origin +- channel-specific runtime namespaces +- legacy `ChannelPlugin` compatibility shapes + +The kernel may only know about: + +- contributions +- capabilities +- adapters +- canonical events +- routing +- policy +- sessions +- agent-visible tools and actions + +The distinction matters: + +- "plugin" is a packaging and lifecycle concern +- "contribution" is a runtime contract + +The extension host handles plugins. The kernel handles contributions. + +## Executive Summary + +Target architecture: + +- `kernel`: pure runtime engine, no plugin-specific concepts +- `extension-host`: discovers extensions, resolves manifests, enforces enablement and conflicts, loads compatibility shims, and emits resolved contributions into the kernel +- `extensions`: all optional functionality, including bundled channels and bundled non-channel features + +Key outcome: + +- built-in and external extensions use the same contribution model +- the kernel sees only resolved runtime contributions +- agent-visible capabilities are compiled centrally from active contributions +- conflicting or overlapping providers are handled by explicit arbitration policies + +How we fix it: + +- lock the boundary first so no new plugin-shaped behavior spreads into the kernel +- add source-of-truth schema and minimal SDK compatibility before broad host migration +- move lifecycle, static metadata, and registry ownership into the extension host +- prove the model with `thread-ownership` first and `telegram` second +- replace fragmented event, catalog, and arbitration behavior with canonical systems +- remove the legacy runtime only after parity is proven and duplicate systems are gone + +Security note: + +- the first host cut is still operating inside OpenClaw's current trusted in-process extension model +- permission descriptors can gate activation, host-owned registries, and operator policy, but they are not a hard sandbox until a real isolation boundary exists + +## Companion Specs + +This plan is the architecture document. The concrete implementation contracts now live in companion specs: + +- `openclaw-extension-contribution-schema-spec.md` +- `openclaw-extension-host-lifecycle-and-security-spec.md` +- `openclaw-kernel-event-pipeline-spec.md` +- `openclaw-capability-catalog-and-arbitration-spec.md` + +Together, these close the remaining implementation gaps: + +- exact contribution and manifest schema +- activation, dependency, permission, and persistence rules +- event-stage ordering, merge, veto, and resolver semantics +- capability naming, disambiguation, arbitration, and agent visibility rules + +## Implementation Order + +Implement in this order: + +1. Phase 0: boundary inventory and anti-corruption layer +2. Phase 1: schema, package metadata, and minimal SDK compatibility +3. Phase 2: extension host lifecycle and registries +4. Phase 3: broader legacy compatibility bridges +5. Phase 4: canonical event pipeline +6. Phase 5: catalog migration +7. Phase 6: arbitration migration +8. Phase 7: broader migration and legacy removal + +Why this order: + +- schema and static metadata must exist before cheap-path install, onboarding, and status flows can move +- minimal SDK compatibility must exist before broader schema or host work can safely load current extensions +- host registries must exist before compatibility shims have somewhere correct to land +- event migration depends on the host and compatibility bridges +- catalogs and arbitration are migrations of existing behavior, not greenfield systems +- legacy removal is only safe after pilot parity and compatibility coverage are proven + +## Design Principles + +1. Plugin-agnostic kernel + +The kernel must compile and function without any code that is semantically specific to plugins. + +2. Contributions over registrations + +The runtime contract is a graph of contributed capabilities, not a set of plugin-specific registration methods. + +3. Bundled equals external + +Bundled extensions must pass through the same host pipeline as external ones. + +4. Capabilities are first-class + +Agent-facing behavior is built from a canonical capability catalog, not from extension identities. + +5. Conflicts are policy, not accidents + +Name collisions, slot collisions, and overlapping providers must be resolved explicitly in the host. + +6. Parallel providers are normal + +Messaging, directory, and other capabilities may have multiple active providers at the same time. + +7. Compatibility belongs outside the kernel + +Legacy `ChannelPlugin` support and existing plugin API bridges must live in the extension host or shim packages, never in the kernel. + +8. Security language must match the implementation + +Permission descriptors are useful, but they must not be described as a security boundary while extensions still run as trusted in-process code. + +9. Static descriptors stay separate from heavy runtime code + +Install metadata, onboarding labels, docs links, and lightweight channel behavior must remain cheap to load and must not require activating a full adapter runtime. + +10. Performance regressions are architectural bugs + +The host must preserve current lazy-loading, caching, and lightweight shared-path behavior rather than rebuilding the same metadata on every startup path. + +11. Replace existing behavior, do not duplicate it + +Catalog, arbitration, setup, and status migration must absorb the existing partial systems rather than layering a second source of truth beside them. + +## Architecture Overview + +## 1. Kernel + +The kernel is the runtime engine. It owns: + +- canonical message and event types +- adapter runtime contracts +- routing +- session storage semantics +- policy evaluation +- capability catalog compilation +- ingress and egress pipelines +- agent tool visibility and arbitration +- telemetry over canonical events + +The kernel does not load extensions directly. + +## 2. Extension Host + +The extension host owns: + +- extension discovery +- bundled extension inventory +- external extension inventory +- manifest parsing +- distribution and install metadata +- lightweight adapter and channel descriptors for onboarding and status UX +- enablement and disablement state +- dependency and version checks +- conflict resolution +- provenance and trust-policy evaluation +- contribution graph assembly +- host-owned route, command, backend, credential, and state registries +- legacy compatibility wrappers +- SDK compatibility and deprecation shims +- error isolation during extension activation + +The host converts extension packages into kernel contributions. + +## 3. Extensions + +Extensions contain actual optional functionality: + +- channels +- provider auth helpers +- memory backends +- agent tools +- action handlers +- directory providers +- monitoring or lifecycle add-ons + +Bundled extensions live in the distribution but are still optional. They are not special from the kernel’s perspective. + +## Current Problems This Plan Solves + +Today, OpenClaw already has a universal registry in `src/plugins/registry.ts:129`, but the runtime still mixes plugin-specific and channel-specific assumptions into core paths. + +Examples: + +- `src/channels/plugins/types.plugin.ts:49` defines a plugin-shaped runtime contract for channels +- `src/plugins/runtime/types-channel.ts:16` exposes a large channel-specific runtime namespace +- many channel monitors manually stitch together route resolution, session recording, context normalization, and reply dispatch, for example `extensions/matrix/src/matrix/monitor/handler.ts:646` + +This creates four problems: + +1. The runtime is not truly uniform. +2. New extensions need deep knowledge of internals. +3. Cross-cutting behavior is duplicated or inconsistently ordered. +4. The kernel is forced to retain channel- and plugin-shaped seams. + +It also leaves important migration constraints that the new design must preserve: + +- prompt-mutation policy controls such as `plugins.entries..hooks.allowPromptInjection` +- path-safety and provenance checks during discovery and load +- lazy startup behavior that avoids loading heavy runtimes on cheap code paths +- existing state layouts that cannot be discarded without migration + +## Target Runtime Model + +## 1. Contributions + +A contribution is the only runtime unit the kernel accepts. + +Suggested categories: + +- `adapter.runtime` +- `capability.messaging` +- `capability.directory` +- `capability.memory` +- `capability.provider-integration` +- `capability.context-engine` +- `capability.agent-tool` +- `capability.interaction` +- `capability.status` +- `capability.setup` +- `capability.policy-augment` +- `capability.event-handler` +- `capability.runtime-backend` + +The kernel consumes contributions through typed contracts. + +## Current Runtime Surfaces That Must Be Cut Over + +Before implementation starts, write and maintain a cutover inventory for every current plugin-owned surface: + +- manifest loading and static metadata +- package-level install and onboarding metadata +- discovery, provenance, duplicate resolution, and origin precedence +- config schema and UI hint loading +- typed hooks and legacy hook bridges +- channels and channel lookup +- providers and provider auth or setup flows +- tools and agent-visible tool catalogs +- HTTP routes and gateway methods +- CLI registrars and plugin commands +- services and context-engine registrations +- conversation binding ownership, approval persistence, and restore-on-restart behavior +- interactive callback routing, namespace ownership, and dedupe +- ingress claim and bound-route short-circuit behavior +- generic interactive control contracts needed by external interactive agents +- slot selection and existing arbitration paths +- status, reload, install, update, and diagnostics surfaces + +Each surface must be tagged as: + +- kernel-owned +- host-owned +- compatibility-only + +No new direct writes to global plugin registries should be added outside the new host boundary once Phase 0 begins. + +## 1a. Extension taxonomy + +The new model must support more than channels. "Extension" is the package or bundle unit. One extension may emit one or many contributions. + +Representative extension classes in OpenClaw today: + +- channel or transport extensions +- provider integration extensions +- agent-tool extensions +- memory extensions +- telephony or voice extensions +- background service extensions +- CLI or operator-surface extensions +- status, setup, or config extensions +- context augmentation extensions + +Examples from the current repo: + +- provider auth in `extensions/google-gemini-cli-auth/index.ts:24` +- agent tool plus route plus context augmentation in `extensions/diffs/index.ts:27` +- telephony, gateway methods, tools, CLI, and services in `extensions/voice-call/index.ts:230` +- memory tools, lifecycle handlers, CLI, and service in `extensions/memory-lancedb/index.ts:314` + +The host must allow an extension to emit a mixed contribution set. Channels are only one case. + +## 1b. Contribution families + +The host should normalize extension outputs into a standard set of contribution families. + +Kernel-facing runtime families: + +- `adapter.runtime` +- `capability.agent-tool` +- `capability.control-command` +- `capability.provider-integration` +- `capability.memory` +- `capability.context-engine` +- `capability.context-augmenter` +- `capability.event-handler` +- `capability.route-augmenter` +- `capability.interaction` +- `capability.rpc` +- `capability.runtime-backend` + +Host-managed families: + +- `service.background` +- `surface.cli` +- `surface.config` +- `surface.status` +- `surface.setup` +- `surface.http-route` + +This split is important: + +- the kernel should own runtime behavior +- the host should own discovery, activation, admin surfaces, and compatibility + +## 1c. Mapping from current APIs + +Current extension APIs can be translated into contribution families by the extension host. + +Suggested mapping: + +- `registerChannel(...)` -> `adapter.runtime` plus lightweight dock metadata and optional `surface.config`, `surface.status`, `surface.setup` +- `registerProvider(...)` -> `capability.provider-integration` plus optional setup and auth surfaces +- plugin-provided embeddings, transcription, image or video understanding, and TTS -> typed subsystem runtime contributions registered in host-owned runtime registries, usually under `capability.runtime-backend`, not a widened `registerProvider(...)` end state +- extension-backed search exposed to the agent -> `capability.agent-tool` +- extension-backed search consumed only by a host or subsystem -> typed runtime contribution registered in a host-owned runtime registry, usually under `capability.runtime-backend` +- `registerTool(...)` -> `capability.agent-tool` +- `registerCommand(...)` -> `capability.control-command` +- `on(...)` returning context or side effects -> `capability.context-augmenter` or `capability.event-handler` +- `on(...)` returning route, session-binding, or send-veto decisions -> `capability.route-augmenter` +- `registerGatewayMethod(...)` -> `capability.rpc` +- backend registration used by core subsystems -> `capability.runtime-backend` +- `registerContextEngine(...)` -> `capability.context-engine` +- `registerService(...)` -> `service.background` +- `registerCli(...)` -> `surface.cli` +- `registerHttpRoute(...)` -> `surface.http-route` +- config schema or UI hints -> `surface.config` +- package metadata used for install, onboarding, or channel catalogs -> host-owned static descriptors + +Concrete examples: + +- `extensions/google-gemini-cli-auth/index.ts:25` becomes `capability.provider-integration` +- plugin-provided embeddings become a host-owned embedding runtime contribution +- plugin-provided transcription, image understanding, and video understanding become host-owned media runtime contributions +- plugin-provided TTS becomes a host-owned TTS runtime contribution +- extension-backed web search becomes a canonical search tool contribution unless it is only a runtime-internal backend +- `extensions/diffs/index.ts:27` becomes `capability.agent-tool` +- `extensions/diffs/index.ts:28` becomes a host-managed route or interaction surface +- `extensions/diffs/index.ts:38` becomes `capability.context-augmenter` +- `extensions/voice-call/index.ts:230` becomes `capability.rpc` and telephony runtime contributions +- `extensions/voice-call/index.ts:377` becomes `capability.agent-tool` +- `extensions/voice-call/index.ts:510` becomes `service.background` +- `extensions/acpx/src/service.ts:55` becomes `capability.runtime-backend` +- `extensions/memory-lancedb/index.ts:314` becomes `capability.agent-tool` +- `extensions/memory-lancedb/index.ts:548` becomes `capability.context-augmenter` +- `extensions/memory-lancedb/index.ts:664` becomes `service.background` +- `extensions/phone-control/index.ts:330` becomes `capability.control-command` +- `extensions/thread-ownership/index.ts:63` becomes `capability.route-augmenter` + +Additional migration rule: + +- conversation binding, interactive callback routing, and inbound claim are real runtime needs, but they must not be solved by turning `src/plugins/*` into the permanent public architecture +- bind approvals, callback namespace routing, and bound-ingress short-circuit behavior belong to host-owned surfaces and canonical pipeline stages +- first-cut interactive channel controls should be validated first on Telegram and Discord if they remain the highest-priority parity targets, but the long-term contract must remain generic, adapter-runtime, host-owned, and kernel-agnostic rather than product-shaped kernel APIs + +## 1d. Lightweight descriptors and distribution metadata + +The host also needs a static descriptor layer that is not the same thing as runtime contributions. + +Current `main` still depends on package metadata and lightweight channel docks for: + +- install and update eligibility +- onboarding and channel picker labels +- docs links and quickstart hints +- status and config surfaces that must stay cheap to load + +Examples on `main`: + +- package metadata in `src/plugins/manifest.ts:121` +- install validation in `src/plugins/install.ts:48` +- channel catalog assembly in `src/channels/plugins/catalog.ts:26` +- lightweight docks in `src/channels/dock.ts:228` + +The revised plan should add host-owned static descriptors for: + +- distribution and install metadata +- onboarding and channel catalog metadata +- lightweight adapter or channel dock metadata +- docs and quickstart hints +- config schema and UI hints used by host config APIs + +These are consumed by the extension host and operator UX only. + +They are not kernel contributions and they must not require loading a heavy adapter runtime. + +Performance requirement: + +- the host should preserve manifest caching, lazy runtime activation, and lightweight dock loading behavior comparable to `src/plugins/manifest-registry.ts:47`, `src/plugins/loader.ts:550`, and `src/channels/dock.ts:228` + +Parity requirement: + +- the static metadata model must preserve the host-visible channel fields used today in `src/plugins/manifest.ts:121` and `src/channels/plugins/catalog.ts:117`, including docs labels, aliases, precedence hints, binding hints, picker extras, and announce-target hints + +## 1e. Event handler classes + +The current plan needs a stronger distinction inside event-driven contributions. Not all handlers are equal. + +The kernel should support these handler classes explicitly: + +- `observer` + Read-only. May emit telemetry or side effects, but cannot affect control flow. +- `augmenter` + Adds context or metadata for later stages. +- `mutator` + May transform payloads. +- `veto` + May block an action such as send or route. +- `resolver` + May authoritatively propose or override a routing or delivery decision. + +This matters because current extensions already rely on these distinctions: + +- `extensions/diffs/index.ts:38` is an augmenter +- `extensions/thread-ownership/index.ts:87` is a veto on send +- `extensions/discord/src/subagent-hooks.ts:103` is a delivery-target resolver + +The plan must define: + +- ordering rules +- merge rules +- veto precedence +- whether multiple resolvers compose or compete + +Without this, the contribution model is too vague to safely replace today’s typed hook behavior. + +The semantic taxonomy is useful, but the runner should stay close to how `main` actually executes hooks. + +The first cut only needs three execution modes: + +- parallel observers +- sequential merge or decision handlers +- sync sequential hot-path handlers for transcript persistence and message writes + +Do not overbuild a more abstract scheduling system until the current hook classes have been migrated. + +Implementation guardrail: + +- keep the richer handler taxonomy as documentation of intent +- do not require separate engine machinery for every handler class in the first implementation + +## 1f. Kernel event families need expansion + +The current event list should be expanded beyond message and session events. + +Additional families needed for parity with the repo: + +- `gateway.started` +- `gateway.stopping` +- `agent.before-start` +- `agent.after-end` +- `agent.llm-input` +- `agent.llm-output` +- `tool.before-call` +- `tool.after-call` +- `transcript.tool-result-persisting` +- `transcript.message-writing` +- `compaction.before` +- `compaction.after` +- `session.resetting` +- `delivery.before-send` +- `delivery.after-send` +- `subagent.spawning` +- `subagent.delivery-target` +- `subagent.ended` +- `command.received` +- `command.completed` + +These are required to cover: + +- memory recall and auto-capture in `extensions/memory-lancedb/index.ts:548` +- send veto or mutation in `extensions/thread-ownership/index.ts:87` +- subagent thread binding in `extensions/discord/src/subagent-hooks.ts:41` +- transcript-write mutations in `src/plugins/hooks.ts:465` +- gateway, command, and agent event streams currently split across `src/hooks/internal-hooks.ts:13` and `src/infra/agent-events.ts:3` + +## 1g. Metadata strategy + +The plan must define how canonical events expose provider-specific metadata without pushing provider branches into the kernel. + +Recommended rule: + +- canonical fields for common semantics +- typed opaque metadata bag for provider-specific details +- metadata access helpers supplied by the contributing adapter or host layer +- lightweight static descriptors for operator-facing install and onboarding metadata + +This is necessary because current extensions depend on provider-specific metadata such as: + +- Slack thread and channel ids in `extensions/thread-ownership/index.ts:67` +- Discord thread-binding details in `extensions/discord/src/subagent-hooks.ts:67` + +## 1h. Stateful and slot-backed runtime providers + +The plan needs explicit categories both for extensions that provide runtime backends consumed by the rest of the system and for exclusive slot-backed providers selected by config. + +ACP is the clearest example: + +- `extensions/acpx/src/service.ts:55` registers a backend consumed elsewhere + +Context engines are the clearest slot-backed example on `main`: + +- slot definitions in `src/plugins/slots.ts:12` +- runtime resolution in `src/context-engine/registry.ts:60` + +These are not ordinary services. They are subsystem providers. The kernel needs a typed way to consume them without knowing about plugins. + +Suggested shape: + +- `capability.runtime-backend` + keyed by backend kind, for example `acp-runtime`, `memory-store`, `queue-owner`, or future execution backends +- `capability.context-engine` + keyed by engine id, with explicit exclusive-slot selection and host-managed defaulting + +Arbitration: + +- usually `exclusive` or `ranked` +- context engines are explicitly `exclusive` +- memory needs both backend arbitration and agent-action arbitration + +Migration requirement: + +- preserve current slot defaults and config semantics during the transition, including `plugins.slots.memory` and `plugins.slots.contextEngine` + +## 1i. HTTP and webhook surfaces + +The current plan did not explicitly separate HTTP route ownership and webhook handling. + +We need both: + +- host-managed HTTP route contributions for extension-owned pages or APIs +- adapter-owned ingress endpoints for transport webhooks + +Examples: + +- `extensions/diffs/index.ts:28` exposes a plugin-owned route +- `extensions/voice-call/index.ts:230` depends on RPC-like gateway methods + +The host must handle: + +- path conflict detection +- auth policy at the route level +- route lifecycle tied to extension activation +- dynamic account-scoped route registration and teardown, not only startup-time static routes + +This is required because `main` already supports runtime route registration and unregister handles in `src/plugins/http-registry.ts:12`. + +## 1j. Operator commands versus agent tools + +The plan must distinguish between: + +- agent tools usable by the model +- operator or user commands that bypass the model +- CLI commands for local operators +- CLI onboarding and setup flows for local operators + +Current examples: + +- `extensions/llm-task/index.ts:5` is an agent tool +- `extensions/phone-control/index.ts:330` is a control command +- `extensions/memory-lancedb/index.ts:500` is CLI + +These should not be collapsed into one generic concept. + +Recommended split: + +- `capability.control-command` + chat or native commands that bypass the model on messaging surfaces +- `surface.cli` + local operator CLI commands and subcommands +- `surface.setup` + interactive or non-interactive onboarding and setup flows invoked by host-owned surfaces + +This preserves the distinction that already exists on `main` between plugin CLI registrars, onboarding adapters, and chat control commands. + +Parity rule: + +- `capability.control-command` must preserve `acceptsArgs` matching behavior from `src/extension-host/contributions/command-runtime.ts:127` +- it must also preserve provider-specific native command names used by native command menus in `src/extension-host/contributions/command-runtime.ts:218` + +## 1k. Provider integration and auth ownership + +The plan needs to explicitly say where provider discovery, credential persistence, auth UX, and post-selection lifecycle hooks live. + +Provider integration contributions need host-injected capabilities for: + +- prompting +- browser or URL opening +- callback handling +- credential/profile persistence +- config patch application +- discovery order participation +- onboarding and wizard metadata +- token refresh or credential renewal +- model-selected lifecycle hooks + +Scope rule: + +- `capability.provider-integration` is for chat or model-provider discovery, setup, auth, and post-selection lifecycle +- agent-visible search should not be folded into that family only because it may call remote services +- embeddings, transcription, image understanding, video understanding, and TTS should not be folded into that family just because they also use remote providers +- those subsystem runtimes should use host-owned capability routing plus typed runtime contributions registered in host-owned runtime registries, usually under `capability.runtime-backend` + +Retained behavior requirements for subsystem runtimes: + +- capability-based selection is good +- typed request envelopes with host-injected `apiKey`, `baseUrl`, `headers`, `timeoutMs`, and `fetchFn` are good +- provider-id normalization is good +- graceful built-in fallback is good +- the same host-owned routing pattern is useful for runtime-internal search backends, but agent-visible search should still surface as a tool family rather than a universal provider API + +Architecture rule: + +- harvest those behaviors into host-owned subsystem runtime contracts +- do not widen legacy `registerProvider(...)` into a universal plugin API for unrelated runtime subsystems +- do not make `src/plugins/runtime.ts` capability filters or global active-registry reads the long-term selection surface for embeddings, media understanding, or TTS + +Example: + +- `extensions/google-gemini-cli-auth/index.ts:25` +- provider plugin contracts in `src/plugins/types.ts:158` + +The kernel should not own interactive auth UX or credential store write policy. + +CLI implication: + +- provider setup, onboarding, and auth flows may be extension-owned in behavior +- but they must execute through host-owned CLI and setup primitives +- the host remains responsible for prompting, credential persistence, config writes, and policy checks + +## 1l. Permission model must match the trust model + +Current `main` treats installed extensions as trusted in-process code: + +- trusted plugin concept in `SECURITY.md:108` +- in-process loading in `src/plugins/loader.ts:621` + +That means the first extension-host cut must not present permission grants as a hard security boundary. + +In the initial host model: + +- permissions gate activation, route exposure, host-managed registries, and operator audit +- permissions do not sandbox arbitrary Node imports, filesystem access, network access, or child processes +- operator UI and docs must describe this as trusted in-process mode + +If OpenClaw later adds a real isolation boundary, keep the same descriptors but add an isolated execution mode where permissions become enforceable. + +Implementation guardrail: + +- phase 1 should implement `advisory` and `host-enforced` +- `sandbox-enforced` should remain a forward-compatible contract until a real isolation boundary exists + +## 1m. Prompt mutation policy parity + +The current runtime has a real policy knob for prompt mutation: + +- `plugins.entries..hooks.allowPromptInjection` in `src/plugins/config-state.ts:14` +- enforcement in `src/plugins/registry.ts:547` + +The new host and kernel split must preserve that behavior explicitly. + +Do not collapse it into a generic permission list and lose the existing distinction between: + +- prompt and model guidance that is allowed +- prompt mutation that is blocked or constrained by operator policy + +Recommended treatment: + +- keep prompt-mutation policy as a dedicated host-managed contribution policy +- apply it when translating legacy hooks and when compiling new event-handler or context-augmenter contributions + +## 1n. SDK compatibility and deprecation plan + +The migration also needs an explicit SDK story. + +Current `main` still depends heavily on: + +- compatibility alias loading in `src/plugin-sdk/root-alias.cjs` +- large runtime compatibility namespaces in `src/plugins/runtime/types-channel.ts:16` + +Decision: + +- introduce one new versioned extension-host SDK as the only target for new extension work +- treat existing `openclaw/plugin-sdk/*` subpaths as compatibility-only +- support at most one or two older SDK contract versions at a time through compatibility shims +- do not add new features to legacy subpaths; only bugfixes and migration bridges are allowed there + +The transition plan should therefore include: + +- a versioned extension-host SDK contract +- compatibility shims for current plugin SDK subpaths +- a deprecation timeline for channel-specific runtime namespaces +- contract tests that prove old extensions still load through the host during migration +- an explicit namespace-by-namespace migration map from `src/plugins/runtime/runtime-channel.ts:119` into the new SDK modules + +Version rule: + +- extensions declare `apiVersion` +- the host validates that version against the supported SDK compatibility window +- legacy compatibility windows should be short and explicit + +## 1o. Resolved extension model + +Decision: + +- the extension host should use one `ResolvedExtension` object as the canonical internal data model +- that object must separate cheap static metadata from runtime-activated state + +Suggested shape: + +```ts +type ResolvedExtension = { + id: string; + version: string; + apiVersion: string; + source: { + origin: "bundled" | "global" | "workspace" | "config"; + path: string; + provenance?: string; + }; + static: { + install?: unknown; + catalog?: unknown; + docks?: unknown; + docs?: unknown; + setup?: unknown; + config?: unknown; + }; + runtime: { + contributions: unknown[]; + services: unknown[]; + routes: unknown[]; + policies: unknown[]; + stateOwnership: unknown; + }; +}; +``` + +Registries are then built from that object: + +- static registry for install, onboarding, and lightweight UX paths +- runtime registry for activated contributions and services + +This keeps lifecycle and provenance coherent while preserving cheap shared-path access. + +The static section should also be able to carry host-consumed config schema and UI hints so config APIs can preserve redaction-aware schema behavior without activating runtime code. + +Implementation guardrail: + +- start with one `ResolvedExtension` model and two registries +- do not build extra registry layers unless a migration step proves they are needed + +## 2. Capability descriptors + +Every contribution must describe: + +- stable contribution key +- capability kind +- public names and aliases +- scope +- exclusivity model +- precedence hints +- selection rules +- dependencies on other contributions +- agent visibility metadata + +Example shape: + +```ts +type ContributionDescriptor = { + key: string; + kind: string; + names?: string[]; + aliases?: string[]; + scope?: "global" | "agent" | "session" | "channel" | "account"; + arbitration: "exclusive" | "ranked" | "parallel" | "composed"; + priority?: number; + dependsOn?: string[]; + agentVisible?: boolean; + description?: string; + policy?: { + promptMutation?: "none" | "append-only" | "replace-allowed"; + routeEffect?: "observe-only" | "augment" | "veto" | "resolve"; + failureMode?: "fail-open" | "fail-closed"; + executionMode?: "parallel" | "sequential" | "sync-sequential"; + }; +}; +``` + +Decision: + +- these policy fields should be typed in the first foundation cut +- do not use an unstructured policy blob for the behaviors that affect safety and runtime semantics + +## 3. Adapters + +In this model, a channel is not a special plugin subtype. It is an adapter contribution plus related optional descriptors. + +Terminology clarification: + +- the extension package is the installable unit +- a contribution is a normalized runtime or host surface emitted by that package +- transport-specific runtime behavior belongs in the package's `adapter.runtime` contribution +- the kernel remains generic and should not own product-specific behavior for any one extension or channel + +An adapter runtime contribution should include only transport behavior: + +- normalize ingress events +- send and manage outbound messages +- optional fetch APIs +- optional interaction surfaces +- optional account lifecycle hooks + +It may also expose typed behavioral descriptors for shared channel UX concerns such as: + +- typing or presence behavior +- status reactions or delivery feedback +- thread defaults and reply context +- streaming and draft delivery behavior +- history or context hints needed by shared pipelines +- reload hints for config-driven hot restart or no-op handling +- gateway feature descriptors for method advertisement when needed during migration + +It must not own routing, session semantics, pairing, or agent dispatch. + +Clarification: + +- adapter-level gateway descriptors are advertisement or compatibility metadata only +- callable gateway-style methods still map to `capability.rpc` +- this keeps `registerGatewayMethod(...)` on one migration path instead of splitting callable behavior across adapter and RPC surfaces + +The host-side dock or adapter descriptor must still be rich enough to preserve current cheap-path behavior from `src/channels/dock.ts:56`, including: + +- command gating hints +- allow-from formatting and default-target helpers +- threading defaults +- elevated fallbacks +- agent prompt hints such as `messageToolHints` + +Implementation guardrail: + +- preserve current cheap-path behavior +- do not design a broad adapter metadata platform beyond the fields needed for parity + +## 4. Canonical events + +Everything in the kernel flows through canonical events. + +Suggested event families: + +- `gateway.started` +- `gateway.stopping` +- `command.received` +- `command.completed` +- `ingress.received` +- `ingress.normalized` +- `routing.resolving` +- `routing.resolved` +- `session.starting` +- `session.started` +- `session.resetting` +- `agent.model.resolving` +- `agent.prompt.building` +- `agent.llm.input` +- `agent.llm.output` +- `agent.tool.calling` +- `agent.tool.called` +- `transcript.tool-result.persisting` +- `transcript.message.writing` +- `agent.completed` +- `egress.preparing` +- `egress.sent` +- `egress.failed` +- `interaction.received` +- `account.started` +- `account.stopped` +- `subagent.spawning` +- `subagent.delivery.resolving` +- `subagent.completed` + +All cross-cutting logic should attach to these events. + +Migration note: + +- the current internal hook bus in `src/hooks/internal-hooks.ts:13` +- and the agent event stream in `src/infra/agent-events.ts:3` + +must either be explicitly bridged into this event model or explicitly retired after parity is reached. Do not leave them as undocumented parallel systems. + +## 5. Agent-visible capability catalog + +Agents must not see plugins. They must see what they can do in the current context. + +The kernel should compile a catalog from active contributions plus runtime context: + +- current adapter +- current adapter action support +- current account +- current route +- current session +- policy +- permission checks +- arbitration outcome + +Canonical action governance: + +- canonical action ids remain open, namespaced strings such as `message.send` or `interaction.modal.open` +- the kernel should keep one source-of-truth registry for reviewed core action families +- if a new feature fits an existing semantic family, reuse that action id +- if the semantics are new, add a reviewed canonical action id to the core registry +- plugins must not invent new kernel-level arbitration semantics on their own + +Examples of catalog entries: + +- `message.send` +- `message.reply` +- `message.broadcast` +- `message.poll` +- `directory.lookup` +- `message.react` +- `message.edit` +- `message.delete` +- `message.pin` +- `memory.store` +- `memory.search` +- `interaction.modal.open` + +The catalog may include provider hints only when needed for disambiguation. + +Example: + +- `message.send` with selectors `target`, `provider`, `account` +- `message.reply` without any provider selector if the route already determines it + +## Conflict and Parallelism Model + +This is a first-class requirement. + +There are two separate conflict domains. + +## 1. Host-level conflicts + +These are resolved before contributions reach the kernel. + +Examples: + +- two extensions claim the same exclusive slot +- two extensions claim the same public command name +- two extensions claim the same contribution key + +Host policy options: + +- reject activation +- require explicit operator selection +- rank by configured priority +- rename or alias one contribution if allowed + +## 2. Kernel-level capability arbitration + +These are runtime selection questions, not activation errors. + +Examples: + +- multiple active messaging providers +- multiple directory providers +- multiple memory providers +- multiple prompt or policy augmenters + +The kernel must support four arbitration modes. + +### Exclusive + +Only one provider may be active. + +Use for: + +- default session store backend +- default memory backend if multiple are not supported + +### Ranked + +Multiple providers may exist, but one becomes the default unless explicitly overridden. + +Use for: + +- tool implementations with sensible fallback ordering + +### Parallel + +Multiple providers are equally valid and may coexist simultaneously. + +Use for: + +- messaging channels +- directory providers +- channel-specific action providers + +### Composed + +Multiple providers contribute to a shared pipeline. + +Use for: + +- prompt augmentation +- event enrichment +- delivery observers + +## Messaging is Parallel by Design + +This is critical. + +OpenClaw must support one agent receiving and sending through multiple channels and accounts simultaneously. Therefore: + +- messaging cannot be modeled as an exclusive capability +- messaging providers must be scoped by adapter id and account id +- routing and target resolution determine which provider is used for a given action + +Suggested provider identity: + +- `messaging:slack:work` +- `messaging:telegram:default` +- `messaging:whatsapp:personal` + +Selection order for outbound: + +1. explicit target or explicit provider +2. current conversation route +3. session last-route +4. configured default binding +5. operator-defined fallback + +Inbound selection is simpler: + +- the ingress event arrives via one adapter provider +- routing resolves which agent and session receive it +- the reply path inherits that provider unless explicitly overridden + +## Naming and Agent Visibility Rules + +The host should separate human-facing extension names from kernel-facing capability names. + +Extension metadata can say: + +- package id +- extension id +- display name + +But the kernel should compile normalized capability names: + +- `message.send` +- `message.reply` +- `directory.lookup` +- `memory.search` + +Conflicts in agent-visible names should be handled by the host and capability compiler. + +Rules: + +1. Prefer one canonical capability name for semantically equivalent actions. +2. Preserve provider identity as metadata, not as the primary tool name. +3. If two providers must both be visible, expose one canonical action with a provider selector instead of two arbitrarily named tools. +4. Only expose separate tools when the semantics are genuinely different. + +Example: + +Bad: + +- `send_slack_message` +- `send_telegram_message` +- `send_discord_message` + +Better: + +- `message.send` + with optional `provider` and `account` + +Best in-context: + +- `message.reply` +- `message.send` + +Where route-derived provider selection means the agent usually does not need to name the concrete messaging provider explicitly. + +## Proposed Module Layout + +### Kernel + +Suggested new top-level structure: + +- `src/kernel/events/` +- `src/kernel/types/` +- `src/kernel/ingress/` +- `src/kernel/egress/` +- `src/kernel/routing/` +- `src/kernel/sessions/` +- `src/kernel/policy/` +- `src/kernel/catalog/` +- `src/kernel/runtime/` + +### Extension Host + +- `src/extension-host/discovery/` +- `src/extension-host/manifests/` +- `src/extension-host/enablement/` +- `src/extension-host/conflicts/` +- `src/extension-host/static/` +- `src/extension-host/install/` +- `src/extension-host/policy/` +- `src/extension-host/contributions/` +- `src/extension-host/compat/` +- `src/extension-host/activation/` + +### Extensions + +- `extensions/*` + +The current plugin system should be gradually absorbed into `extension-host`, not `kernel`. + +## Transition Strategy + +This must be compatibility-first, but compatibility must live outside the kernel. + +## Phase 0: Boundary Inventory And Anti-Corruption Layer + +Objective: + +Make the boundary explicit before implementation begins and prevent further spread of legacy plugin assumptions. + +Tasks: + +- write an ADR defining `kernel` versus `extension-host` +- define a rule that kernel code may not import from `src/plugins`, `src/plugin-sdk`, or `extensions/*` +- write the boundary cutover inventory for every current plugin-owned surface +- define which surfaces are kernel-owned, host-owned, or compatibility-only +- add anti-corruption interfaces so new work cannot write directly into global plugin registries +- document the trusted in-process security model so permission descriptors are not misrepresented +- define the compatibility and deprecation strategy for the existing plugin SDK surface +- add feature flags for host-path versus legacy-path execution where needed for staged rollout + +Exit criteria: + +- the boundary is explicit and testable +- every current plugin-owned surface is tagged with its target owner +- no new direct kernel dependencies on legacy plugin shapes are introduced + +Current implementation status: + +- partially implemented +- the anti-corruption boundary now exists in code through `src/extension-host/static/active-registry.ts` +- several central readers now go through that boundary +- the initial cutover inventory now exists in `src/extension-host/cutover-inventory.md` and is being updated as surfaces move, but the phase is still incomplete because loader orchestration, lifecycle ownership, and later compatibility phases have not moved yet + +## Phase 1: Schema, Static Metadata, And Minimal SDK Compatibility + +Objective: + +Create the host data model and preserve extension loading while the boundary changes. + +Tasks: + +- define the `ResolvedExtension`, `ResolvedContribution`, and `ContributionPolicy` types +- define canonical contribution descriptors, slot-backed provider types, and catalog-facing metadata types +- add source-of-truth manifest parsing for runtime contributions +- add package metadata parsing for install, onboarding, and lightweight operator UX +- define the new versioned SDK contract and supported compatibility window +- add minimal SDK compatibility loading so current `openclaw/plugin-sdk/*` imports still resolve while the host work lands +- define the static versus runtime sections of `ResolvedExtension` +- preserve config schema and UI hint parsing without activating heavy runtimes + +Deliverables: + +- source-of-truth schema types +- static metadata parser +- package metadata parser +- compatibility-loading surface for the current SDK imports + +Exit criteria: + +- extensions can be normalized into static and runtime sections without activating heavy runtime code +- existing extensions still load through the compatibility loading path + +Current implementation status: + +- partially implemented +- a normalized static model exists in code through `ResolvedExtension` +- package metadata and manifest metadata now converge into host-owned normalized records +- discovery and install metadata parsing now go through host schema helpers +- partial explicit compatibility now exists through host-owned loader-compat and loader-runtime helpers, but a versioned minimal SDK compatibility layer still does not exist + +## Phase 2: Extension Host Lifecycle And Registries + +Objective: + +Move lifecycle and ownership concerns into the host. + +Tasks: + +- create extension discovery and manifest loaders +- move enablement logic into host +- move bundled extension inventory into host +- move install and onboarding metadata into host-owned static descriptors +- move contribution assembly into host +- move provenance, origin precedence, and slot policy into host +- implement contribution graph validation +- implement host-owned registries for hooks, channels, providers, tools, HTTP routes, gateway methods, CLI, services, commands, config, setup, status, backends, and slot-backed providers +- implement per-extension state and route registries +- preserve path-safety, provenance, duplicate-origin hardening, startup laziness, and manifest caching behavior +- keep current built-in onboarding fallbacks in place during early migration +- preserve current setup adapter phases such as status, configure, reconfigure, disable, and DM policy handling + +Deliverables: + +- `src/extension-host/*` +- host-owned static registry +- host-owned runtime registry + +Exit criteria: + +- the host can discover bundled and external extensions, preserve static metadata, and populate normalized registries +- registries are populated through host-owned interfaces rather than direct legacy global writes + +Current implementation status: + +- partially implemented in a compatibility-preserving form +- the host now owns active registry state +- the host now exposes resolved static registries for static consumers +- activation, loader cache control, loader policy, loader discovery-policy outcomes, loader activation-policy outcomes, loader finalization-policy outcomes, loader runtime decisions, loader top-level load orchestration, loader session state, loader record-state helpers, and loader finalization now route through `src/extension-host/*` +- broader lifecycle ownership beyond the loader state machine, registration surfaces, policy gates, and activation-state management are still pending + +## Phase 3: Broader Legacy Compatibility Bridges + +Objective: + +Keep current extensions working through the host without leaking legacy contracts into the kernel. + +Tasks: + +- implement `ChannelPlugin` compatibility shims in `src/extension-host/compat/` +- adapt current plugin registrations into contribution descriptors +- translate current config schema and UI hint registration into `surface.config` +- translate existing plugin CLI registrars and onboarding adapters into `surface.cli` and `surface.setup` +- adapt existing gateway and status surfaces into host-level descriptors +- adapt current package metadata and channel docks into host-owned static descriptors +- preserve prompt-mutation policy enforcement when translating legacy hooks +- preserve config redaction semantics driven by `config.uiHints.sensitive` +- preserve hot reload, no-op config prefix behavior, and gateway feature advertisement where those behaviors still exist +- add compatibility translation for current runtime-channel helper namespaces into the new SDK modules +- maintain a parity matrix for each pilot migration + +Important rule: + +No legacy `ChannelPlugin` type or shim code may appear under `src/kernel/`. + +Exit criteria: + +- `thread-ownership` runs through the host path as the first non-channel pilot +- `telegram` runs through the host path as the first channel pilot +- both pilots have explicit parity results for discovery, config, activation, diagnostics, and runtime behavior + +## Phase 4: Canonical Event Pipeline + +Objective: + +Move runtime behavior onto explicit canonical events and stage rules. + +Tasks: + +- define canonical event and stage types +- define sync transcript-write stages explicitly +- bridge current plugin hooks, internal hooks, and agent event streams into canonical stages in the extension host only +- map legacy typed hooks into canonical stage semantics +- keep permission descriptors host-owned and policy-oriented until real isolation exists +- move compatibility facades into extension-host shims rather than adding new kernel leakage + +Exit criteria: + +- pilot extensions use canonical event stages with parity to current behavior +- any remaining legacy event buses are explicitly documented as compatibility-only + +## Phase 5: Catalog Migration + +Objective: + +Replace plugin-identity-driven catalog behavior with canonical family-based catalogs. + +Tasks: + +- compile active contributions into kernel internal and kernel agent catalogs +- publish host-owned operator and static setup catalogs +- migrate existing tool, provider, setup, and onboarding catalog surfaces onto canonical or host-owned catalog paths +- resolve naming conflicts +- collapse equivalent provider-specific actions into canonical agent tools where appropriate +- add explicit provider selection only when needed +- preserve dedicated prompt-mutation policy filtering during catalog compilation where relevant + +Implementation guardrail: + +- start with one kernel internal catalog, one kernel agent catalog, and host-owned operator or static registries +- do not build a larger publication framework until the registries are stable + +Exit criteria: + +- agent-visible tool inventory is generated from contribution metadata and kernel context +- setup and install catalogs no longer depend on duplicated legacy metadata paths + +## Phase 6: Arbitration Migration + +Objective: + +Absorb the existing conflict-resolution behavior into explicit arbitration. + +Tasks: + +- implement host-level activation conflict resolution +- implement kernel-level runtime provider selection +- migrate existing slot selection and provider selection logic onto canonical arbitration +- add explicit selection APIs for provider-scoped actions +- ensure session route and last-route semantics interact correctly with parallel messaging providers +- cover messaging parallelism, directory overlap, memory backend exclusivity, context-engine slot exclusivity, composed prompt or policy augmenters, and dynamic route conflicts + +Exit criteria: + +- at least one multi-provider family works through canonical arbitration +- legacy slot and provider-selection paths no longer operate as separate arbitration systems + +## Phase 7: Broader Migration And Legacy Removal + +Objective: + +Finish the cutover and remove compatibility-only surfaces in a controlled order. + +Tasks: + +- migrate remaining channels and non-channel extensions in batches +- remove legacy plugin registry entry points once no longer needed +- deprecate `runtime.channel` +- deprecate per-channel SDK subpaths where a neutral replacement exists +- retain only thin compatibility packages until extension migration is complete +- remove the built-in onboarding fallback only after host-owned setup surfaces reach parity for bundled channels + +Suggested second-wave compatibility candidates after the initial pilots: + +- `line` for channel plus command registration +- `device-pair` for command, service, and setup-flow coverage + +Exit criteria: + +- built-in channels behave like ordinary extensions through the host +- the legacy plugin runtime is no longer the default execution path +- kernel no longer imports old plugin infrastructure + +## Pilot Matrix + +Initial pilots: + +- non-channel pilot: `thread-ownership` +- channel pilot: `telegram` + +Why this order: + +- `thread-ownership` exercises typed hook behavior with limited surface area +- `telegram` exercises the `ChannelPlugin` compatibility path with a minimal top-level registration surface + +Each pilot must record parity for: + +- discovery and precedence +- manifest and static metadata loading +- config schema and UI hints +- enabled and disabled state handling +- activation and reload behavior +- diagnostics and status output +- runtime behavior on the migrated path +- compatibility-only gaps that still remain + +## Concrete Refactoring Targets + +The following current areas should be moved or replaced. + +### Move out of kernel + +- plugin registry logic now in `src/plugins/registry.ts:129` +- plugin loader logic now in `src/plugins/loader.ts:37` +- plugin runtime channel namespace in `src/plugins/runtime/types-channel.ts:16` +- direct plugin-specific API types in `src/plugins/types.ts:263` + +### Replace with neutral kernel services + +- route resolution entrypoints currently in `src/routing/resolve-route.ts:614` +- outbound pipeline seed in `src/infra/outbound/deliver.ts:141` +- session recording flow currently called by many channels +- canonical hook or event dispatch ordering +- transcript persistence and message-write stages currently embedded in `src/plugins/hooks.ts:465` + +### Keep only in host compatibility + +- `ChannelPlugin` contract in `src/channels/plugins/types.plugin.ts:49` +- plugin SDK subpath facades like `src/plugin-sdk/telegram.ts:1` + +## Verification Strategy + +## Boundary tests + +- kernel has no imports from `src/plugins`, `src/plugin-sdk`, or `extensions/*` +- host may import kernel, but kernel may not import host + +## Contract tests + +- contribution contract tests +- arbitration contract tests +- capability catalog tests +- adapter runtime contract tests + +## Behavior parity tests + +- route resolution parity +- session transcript parity +- message hook or event ordering parity +- outbound payload parity +- multi-account parity +- install and onboarding catalog parity +- context-engine slot parity +- sync transcript-write parity +- prompt-mutation policy parity +- path-safety and provenance parity +- startup-cost parity for lightweight UX paths + +## Parallel provider tests + +- one agent active on Slack and Telegram simultaneously +- reply follows inbound route by default +- explicit cross-channel send works +- session last-route does not break when multiple messaging providers are active + +## Conflict tests + +- duplicate contribution key +- duplicate exclusive slot +- duplicate agent-visible tool alias +- two ranked providers with clear default resolution + +## Operational Plan + +1. Introduce kernel and host boundaries first. +2. Add import guards and the boundary cutover inventory so the boundary cannot regress. +3. Add source-of-truth schema types, static metadata parsing, and minimal SDK compatibility loading. +4. Move plugin lifecycle and registry ownership into the host without behavior changes. +5. Add compatibility shims in the host and record pilot parity as each surface moves. +6. Migrate `thread-ownership` through the host path first. +7. Migrate `telegram` through the host path second. +8. Add canonical event routing for the pilot surfaces. +9. Migrate existing catalog and arbitration paths rather than adding parallel ones. +10. Migrate remaining extensions in batches. +11. Start deprecating old plugin-facing runtime surfaces. + +## Risks + +Risk: + +The contribution model becomes too abstract and hard to use. + +Mitigation: + +Provide good host-side helpers and templates. Keep kernel contracts narrow and transport-focused. + +Risk: + +Agent-visible catalog becomes confusing when many providers are active. + +Mitigation: + +Use canonical actions first, provider selectors second, provider-specific names only as a last resort. + +Risk: + +Parallel messaging providers create routing ambiguity. + +Mitigation: + +Define and test explicit outbound selection order. Route and session metadata must always carry adapter and account identity. + +Risk: + +Compatibility shims silently leak old plugin assumptions back into the kernel. + +Mitigation: + +Enforce import boundaries with CI and keep all legacy code under the host only. + +Risk: + +The cutover inventory misses one of the current plugin-owned surfaces, so behavior quietly stays on the legacy path. + +Mitigation: + +Treat the boundary cutover inventory as a tracked artifact, update it before changing ownership, and require each pilot to mark which surfaces are full parity, partial parity, or still compatibility-only. + +Risk: + +Bundled extensions are treated as privileged again over time. + +Mitigation: + +Run bundled extensions through the same host activation and contribution pipeline as external extensions. + +Risk: + +Permission descriptors overpromise security that the runtime does not yet provide. + +Mitigation: + +Keep permission language explicitly policy-oriented until OpenClaw ships a real isolation boundary. + +Risk: + +The migration drops current onboarding, install, or lightweight dock behavior while focusing only on runtime contributions. + +Mitigation: + +Treat static host descriptors as a first-class part of the migration, with parity tests for channel catalogs and onboarding flows. + +Risk: + +The host adds enough abstraction to regress startup cost or force heavy adapter loads on shared code paths. + +Mitigation: + +Make lazy activation, manifest caching, and lightweight dock descriptors explicit success criteria and test them. + +Risk: + +The migration breaks existing extensions because the SDK compatibility story is under-specified. + +Mitigation: + +Ship a versioned SDK contract, compatibility shims, and an explicit deprecation timeline before removing old subpaths. + +Risk: + +Catalog and arbitration migration leaves legacy tool, provider, or slot-selection systems running in parallel with the new model. + +Mitigation: + +Treat Phase 5 and Phase 6 as replacement work. Track the current tool catalog, provider-selection, and slot-selection paths explicitly and do not declare those phases complete until the duplicate systems are removed or downgraded to documented compatibility-only shims. + +## Suggested First PRs + +PR 1: + +Add `kernel` and `extension-host` directory structure, boundary ADR, import guards, and the boundary cutover inventory. + +PR 2: + +Define `ResolvedExtension`, `ResolvedContribution`, static metadata types, and the minimal SDK compatibility-loading surface. + +PR 3: + +Move existing plugin discovery, manifest parsing, provenance handling, and registry ownership into `extension-host` while preserving behavior. + +PR 4: + +Add host-side compatibility shim for current hook, provider, and `ChannelPlugin` surfaces. + +PR 5: + +Migrate `thread-ownership` through the new host-to-kernel path with explicit parity tracking. + +PR 6: + +Migrate `telegram` through the new host-to-kernel path with explicit parity tracking. + +## Success Criteria + +This transition succeeds when all of the following are true: + +- the kernel contains no plugin-specific concepts +- bundled and external extensions activate through the same host pipeline +- agent-visible capabilities are compiled centrally from active contributions +- duplicate or overlapping providers are resolved through explicit arbitration +- one agent can receive and send across multiple active messaging providers cleanly +- install, onboarding, and lightweight dock metadata still work through host-owned static descriptors +- context-engine and memory slot behavior are preserved through explicit slot-backed contributions +- transcript-write hooks are preserved through explicit canonical stages +- prompt-mutation policy behavior is preserved through explicit host policy +- startup-time lightweight paths do not force heavy runtime activation +- existing extensions have a documented compatibility and deprecation path through the host SDK +- legacy compatibility exists only in the extension host and can be deleted later without changing kernel semantics + +## Final Recommendation + +Adopt the stricter model. + +Do not let the universal adapter effort stop at “better plugin architecture.” The correct end state is a plugin-agnostic kernel with an extension host layered on top. That is the cleanest way to support optional bundled extensions, clean agent capability surfacing, deterministic conflict handling, and true parallel providers for messaging and other runtime capabilities. diff --git a/src/agents/google-model-id.test.ts b/src/agents/google-model-id.test.ts new file mode 100644 index 00000000000..bae8a44a241 --- /dev/null +++ b/src/agents/google-model-id.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; +import { normalizeGoogleModelId } from "./google-model-id.js"; + +describe("normalizeGoogleModelId", () => { + it("preserves compatibility with legacy Gemini aliases", () => { + expect(normalizeGoogleModelId("gemini-3.1-flash")).toBe("gemini-3-flash-preview"); + expect(normalizeGoogleModelId("gemini-3.1-flash-preview")).toBe("gemini-3-flash-preview"); + expect(normalizeGoogleModelId("gemini-3.1-flash-lite")).toBe("gemini-3.1-flash-lite-preview"); + expect(normalizeGoogleModelId("gemini-3-pro")).toBe("gemini-3-pro-preview"); + }); +}); diff --git a/src/agents/google-model-id.ts b/src/agents/google-model-id.ts new file mode 100644 index 00000000000..c7cfac6f891 --- /dev/null +++ b/src/agents/google-model-id.ts @@ -0,0 +1,21 @@ +export function normalizeGoogleModelId(id: string): string { + if (id === "gemini-3-pro") { + return "gemini-3-pro-preview"; + } + if (id === "gemini-3-flash") { + return "gemini-3-flash-preview"; + } + if (id === "gemini-3.1-pro") { + return "gemini-3.1-pro-preview"; + } + if (id === "gemini-3.1-flash-lite") { + return "gemini-3.1-flash-lite-preview"; + } + // Preserve compatibility with earlier OpenClaw docs/config that pointed at a + // non-existent Gemini Flash preview ID. Google's current Flash text model is + // `gemini-3-flash-preview`. + if (id === "gemini-3.1-flash" || id === "gemini-3.1-flash-preview") { + return "gemini-3-flash-preview"; + } + return id; +} diff --git a/src/agents/model-ref.test.ts b/src/agents/model-ref.test.ts new file mode 100644 index 00000000000..b63e3f9ecd8 --- /dev/null +++ b/src/agents/model-ref.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { modelKey, parseModelRef } from "./model-ref.js"; + +describe("modelKey", () => { + it("keeps canonical OpenRouter native ids without duplicating the provider", () => { + expect(modelKey("openrouter", "openrouter/hunter-alpha")).toBe("openrouter/hunter-alpha"); + }); +}); + +describe("parseModelRef", () => { + it("uses the default provider when omitted", () => { + expect(parseModelRef("claude-3-5-sonnet", "anthropic")).toEqual({ + provider: "anthropic", + model: "claude-3-5-sonnet", + }); + }); + + it("normalizes anthropic shorthand aliases", () => { + expect(parseModelRef("anthropic/opus-4.6", "openai")).toEqual({ + provider: "anthropic", + model: "claude-opus-4-6", + }); + }); + + it("preserves nested model ids after the provider prefix", () => { + expect(parseModelRef("nvidia/moonshotai/kimi-k2.5", "anthropic")).toEqual({ + provider: "nvidia", + model: "moonshotai/kimi-k2.5", + }); + }); + + it("normalizes OpenRouter-native model refs without duplicating the provider", () => { + expect(parseModelRef("openrouter/hunter-alpha", "anthropic")).toEqual({ + provider: "openrouter", + model: "openrouter/hunter-alpha", + }); + }); +}); diff --git a/src/agents/model-ref.ts b/src/agents/model-ref.ts new file mode 100644 index 00000000000..3803e6c0798 --- /dev/null +++ b/src/agents/model-ref.ts @@ -0,0 +1,94 @@ +import { normalizeGoogleModelId } from "./google-model-id.js"; +import { normalizeProviderId } from "./provider-id.js"; + +export type ModelRef = { + provider: string; + model: string; +}; + +export function modelKey(provider: string, model: string) { + const providerId = provider.trim(); + const modelId = model.trim(); + if (!providerId) { + return modelId; + } + if (!modelId) { + return providerId; + } + return modelId.toLowerCase().startsWith(`${providerId.toLowerCase()}/`) + ? modelId + : `${providerId}/${modelId}`; +} + +export function legacyModelKey(provider: string, model: string): string | null { + const providerId = provider.trim(); + const modelId = model.trim(); + if (!providerId || !modelId) { + return null; + } + const rawKey = `${providerId}/${modelId}`; + const canonicalKey = modelKey(providerId, modelId); + return rawKey === canonicalKey ? null : rawKey; +} + +function normalizeAnthropicModelId(model: string): string { + const trimmed = model.trim(); + if (!trimmed) { + return trimmed; + } + const lower = trimmed.toLowerCase(); + switch (lower) { + case "opus-4.6": + return "claude-opus-4-6"; + case "opus-4.5": + return "claude-opus-4-5"; + case "sonnet-4.6": + return "claude-sonnet-4-6"; + case "sonnet-4.5": + return "claude-sonnet-4-5"; + default: + return trimmed; + } +} + +function normalizeProviderModelId(provider: string, model: string): string { + if (provider === "anthropic") { + return normalizeAnthropicModelId(model); + } + if (provider === "vercel-ai-gateway" && !model.includes("/")) { + const normalizedAnthropicModel = normalizeAnthropicModelId(model); + if (normalizedAnthropicModel.startsWith("claude-")) { + return `anthropic/${normalizedAnthropicModel}`; + } + } + if (provider === "google" || provider === "google-vertex") { + return normalizeGoogleModelId(model); + } + if (provider === "openrouter" && !model.includes("/")) { + return `openrouter/${model}`; + } + return model; +} + +export function normalizeModelRef(provider: string, model: string): ModelRef { + const normalizedProvider = normalizeProviderId(provider); + const normalizedModel = normalizeProviderModelId(normalizedProvider, model.trim()); + return { provider: normalizedProvider, model: normalizedModel }; +} + +export function parseModelRef(raw: string, defaultProvider: string): ModelRef | null { + const trimmed = raw.trim(); + if (!trimmed) { + return null; + } + const slash = trimmed.indexOf("/"); + if (slash === -1) { + return normalizeModelRef(defaultProvider, trimmed); + } + const providerRaw = trimmed.slice(0, slash).trim(); + const model = trimmed.slice(slash + 1).trim(); + if (!providerRaw || !model) { + return null; + } + return normalizeModelRef(providerRaw, model); +} diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index 0f8f5568618..b0ba0e4e437 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -14,16 +14,18 @@ import { } from "./agent-scope.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js"; import type { ModelCatalogEntry } from "./model-catalog.js"; -import { normalizeGoogleModelId } from "./model-id-normalization.js"; import { splitTrailingAuthProfile } from "./model-ref-profile.js"; +import { + legacyModelKey, + modelKey, + normalizeModelRef, + parseModelRef, + type ModelRef, +} from "./model-ref.js"; +import { normalizeProviderId, normalizeProviderIdForAuth } from "./provider-id.js"; const log = createSubsystemLogger("model-selection"); -export type ModelRef = { - provider: string; - model: string; -}; - export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "adaptive"; export type ModelAliasIndex = { @@ -35,70 +37,6 @@ function normalizeAliasKey(value: string): string { return value.trim().toLowerCase(); } -export function modelKey(provider: string, model: string) { - const providerId = provider.trim(); - const modelId = model.trim(); - if (!providerId) { - return modelId; - } - if (!modelId) { - return providerId; - } - return modelId.toLowerCase().startsWith(`${providerId.toLowerCase()}/`) - ? modelId - : `${providerId}/${modelId}`; -} - -export function legacyModelKey(provider: string, model: string): string | null { - const providerId = provider.trim(); - const modelId = model.trim(); - if (!providerId || !modelId) { - return null; - } - const rawKey = `${providerId}/${modelId}`; - const canonicalKey = modelKey(providerId, modelId); - return rawKey === canonicalKey ? null : rawKey; -} - -export function normalizeProviderId(provider: string): string { - const normalized = provider.trim().toLowerCase(); - if (normalized === "z.ai" || normalized === "z-ai") { - return "zai"; - } - if (normalized === "opencode-zen") { - return "opencode"; - } - if (normalized === "opencode-go-auth") { - return "opencode-go"; - } - if (normalized === "qwen") { - return "qwen-portal"; - } - if (normalized === "kimi-code") { - return "kimi-coding"; - } - if (normalized === "bedrock" || normalized === "aws-bedrock") { - return "amazon-bedrock"; - } - // Backward compatibility for older provider naming. - if (normalized === "bytedance" || normalized === "doubao") { - return "volcengine"; - } - return normalized; -} - -/** Normalize provider ID for auth lookup. Coding-plan variants share auth with base. */ -export function normalizeProviderIdForAuth(provider: string): string { - const normalized = normalizeProviderId(provider); - if (normalized === "volcengine-plan") { - return "volcengine"; - } - if (normalized === "byteplus-plan") { - return "byteplus"; - } - return normalized; -} - export function findNormalizedProviderValue( entries: Record | undefined, provider: string, @@ -138,75 +76,6 @@ export function isCliProvider(provider: string, cfg?: OpenClawConfig): boolean { return Object.keys(backends).some((key) => normalizeProviderId(key) === normalized); } -function normalizeAnthropicModelId(model: string): string { - const trimmed = model.trim(); - if (!trimmed) { - return trimmed; - } - const lower = trimmed.toLowerCase(); - // Keep alias resolution local so bundled startup paths cannot trip a TDZ on - // a module-level alias table while config parsing is still initializing. - switch (lower) { - case "opus-4.6": - return "claude-opus-4-6"; - case "opus-4.5": - return "claude-opus-4-5"; - case "sonnet-4.6": - return "claude-sonnet-4-6"; - case "sonnet-4.5": - return "claude-sonnet-4-5"; - default: - return trimmed; - } -} - -function normalizeProviderModelId(provider: string, model: string): string { - if (provider === "anthropic") { - return normalizeAnthropicModelId(model); - } - if (provider === "vercel-ai-gateway" && !model.includes("/")) { - // Allow Vercel-specific Claude refs without an upstream prefix. - const normalizedAnthropicModel = normalizeAnthropicModelId(model); - if (normalizedAnthropicModel.startsWith("claude-")) { - return `anthropic/${normalizedAnthropicModel}`; - } - } - if (provider === "google" || provider === "google-vertex") { - return normalizeGoogleModelId(model); - } - // OpenRouter-native models (e.g. "openrouter/aurora-alpha") need the full - // "openrouter/" as the model ID sent to the API. Models from external - // providers already contain a slash (e.g. "anthropic/claude-sonnet-4-5") and - // are passed through as-is (#12924). - if (provider === "openrouter" && !model.includes("/")) { - return `openrouter/${model}`; - } - return model; -} - -export function normalizeModelRef(provider: string, model: string): ModelRef { - const normalizedProvider = normalizeProviderId(provider); - const normalizedModel = normalizeProviderModelId(normalizedProvider, model.trim()); - return { provider: normalizedProvider, model: normalizedModel }; -} - -export function parseModelRef(raw: string, defaultProvider: string): ModelRef | null { - const trimmed = raw.trim(); - if (!trimmed) { - return null; - } - const slash = trimmed.indexOf("/"); - if (slash === -1) { - return normalizeModelRef(defaultProvider, trimmed); - } - const providerRaw = trimmed.slice(0, slash).trim(); - const model = trimmed.slice(slash + 1).trim(); - if (!providerRaw || !model) { - return null; - } - return normalizeModelRef(providerRaw, model); -} - export function inferUniqueProviderFromConfiguredModels(params: { cfg: OpenClawConfig; model: string; @@ -726,3 +595,13 @@ export function normalizeModelSelection(value: unknown): string | undefined { } return undefined; } + +export { + legacyModelKey, + modelKey, + normalizeModelRef, + normalizeProviderId, + normalizeProviderIdForAuth, + parseModelRef, +}; +export type { ModelRef }; diff --git a/src/agents/provider-id.test.ts b/src/agents/provider-id.test.ts new file mode 100644 index 00000000000..9fee7012c8c --- /dev/null +++ b/src/agents/provider-id.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { normalizeProviderId, normalizeProviderIdForAuth } from "./provider-id.js"; + +describe("normalizeProviderId", () => { + it("applies provider aliases without pulling heavier model-selection dependencies", () => { + expect(normalizeProviderId("Anthropic")).toBe("anthropic"); + expect(normalizeProviderId("Z.ai")).toBe("zai"); + expect(normalizeProviderId("z-ai")).toBe("zai"); + expect(normalizeProviderId("OpenCode-Zen")).toBe("opencode"); + expect(normalizeProviderId("qwen")).toBe("qwen-portal"); + expect(normalizeProviderId("kimi-code")).toBe("kimi-coding"); + expect(normalizeProviderId("bedrock")).toBe("amazon-bedrock"); + expect(normalizeProviderId("aws-bedrock")).toBe("amazon-bedrock"); + expect(normalizeProviderId("doubao")).toBe("volcengine"); + }); +}); + +describe("normalizeProviderIdForAuth", () => { + it("maps coding-plan variants back to their base auth providers", () => { + expect(normalizeProviderIdForAuth("volcengine-plan")).toBe("volcengine"); + expect(normalizeProviderIdForAuth("byteplus-plan")).toBe("byteplus"); + expect(normalizeProviderIdForAuth("anthropic")).toBe("anthropic"); + }); +}); diff --git a/src/agents/provider-id.ts b/src/agents/provider-id.ts new file mode 100644 index 00000000000..6259e878264 --- /dev/null +++ b/src/agents/provider-id.ts @@ -0,0 +1,38 @@ +export function normalizeProviderId(provider: string): string { + const normalized = provider.trim().toLowerCase(); + if (normalized === "z.ai" || normalized === "z-ai") { + return "zai"; + } + if (normalized === "opencode-zen") { + return "opencode"; + } + if (normalized === "opencode-go-auth") { + return "opencode-go"; + } + if (normalized === "qwen") { + return "qwen-portal"; + } + if (normalized === "kimi-code") { + return "kimi-coding"; + } + if (normalized === "bedrock" || normalized === "aws-bedrock") { + return "amazon-bedrock"; + } + // Backward compatibility for older provider naming. + if (normalized === "bytedance" || normalized === "doubao") { + return "volcengine"; + } + return normalized; +} + +/** Normalize provider ID for auth lookup. Coding-plan variants share auth with base. */ +export function normalizeProviderIdForAuth(provider: string): string { + const normalized = normalizeProviderId(provider); + if (normalized === "volcengine-plan") { + return "volcengine"; + } + if (normalized === "byteplus-plan") { + return "byteplus"; + } + return normalized; +} diff --git a/src/agents/skills/plugin-skills.test.ts b/src/agents/skills/plugin-skills.test.ts index 9edcd463c22..d47510c40c0 100644 --- a/src/agents/skills/plugin-skills.test.ts +++ b/src/agents/skills/plugin-skills.test.ts @@ -1,26 +1,43 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; -import type { PluginManifestRegistry } from "../../plugins/manifest-registry.js"; import { createTrackedTempDirs } from "../../test-utils/tracked-temp-dirs.js"; - -const hoisted = vi.hoisted(() => ({ - loadPluginManifestRegistry: vi.fn(), -})); - -vi.mock("../../plugins/manifest-registry.js", () => ({ - loadPluginManifestRegistry: (...args: unknown[]) => hoisted.loadPluginManifestRegistry(...args), -})); - -const { resolvePluginSkillDirs } = await import("./plugin-skills.js"); +const { collectPluginSkillDirsFromRegistry } = await import("./plugin-skills.js"); const tempDirs = createTrackedTempDirs(); -function buildRegistry(params: { acpxRoot: string; helperRoot: string }): PluginManifestRegistry { +type MockResolvedExtensionRegistry = { + diagnostics: unknown[]; + extensions: Array<{ + extension: { + id: string; + name?: string; + kind?: string; + origin?: "workspace" | "bundled" | "global" | "config"; + rootDir?: string; + manifest: { + id: string; + configSchema: Record; + skills?: string[]; + }; + staticMetadata: { + configSchema: Record; + package: { entries: string[] }; + }; + contributions: unknown[]; + }; + manifestPath: string; + }>; +}; + +function buildRegistry(params: { + acpxRoot: string; + helperRoot: string; +}): MockResolvedExtensionRegistry { return { diagnostics: [], - plugins: [ + extensions: [ { id: "acpx", name: "ACPX Runtime", @@ -56,7 +73,7 @@ function createSinglePluginRegistry(params: { }): PluginManifestRegistry { return { diagnostics: [], - plugins: [ + extensions: [ { id: "helper", name: "Helper", @@ -75,25 +92,21 @@ function createSinglePluginRegistry(params: { } async function setupAcpxAndHelperRegistry() { - const workspaceDir = await tempDirs.make("openclaw-"); const acpxRoot = await tempDirs.make("openclaw-acpx-plugin-"); const helperRoot = await tempDirs.make("openclaw-helper-plugin-"); await fs.mkdir(path.join(acpxRoot, "skills"), { recursive: true }); await fs.mkdir(path.join(helperRoot, "skills"), { recursive: true }); - hoisted.loadPluginManifestRegistry.mockReturnValue(buildRegistry({ acpxRoot, helperRoot })); - return { workspaceDir, acpxRoot, helperRoot }; + return { registry: buildRegistry({ acpxRoot, helperRoot }), acpxRoot, helperRoot }; } async function setupPluginOutsideSkills() { - const workspaceDir = await tempDirs.make("openclaw-"); const pluginRoot = await tempDirs.make("openclaw-plugin-"); const outsideDir = await tempDirs.make("openclaw-outside-"); const outsideSkills = path.join(outsideDir, "skills"); - return { workspaceDir, pluginRoot, outsideSkills }; + return { pluginRoot, outsideSkills }; } afterEach(async () => { - hoisted.loadPluginManifestRegistry.mockReset(); await tempDirs.cleanup(); }); @@ -115,10 +128,10 @@ describe("resolvePluginSkillDirs", () => { ], }, ])("$name", async ({ acpEnabled, expectedDirs }) => { - const { workspaceDir, acpxRoot, helperRoot } = await setupAcpxAndHelperRegistry(); + const { registry, acpxRoot, helperRoot } = await setupAcpxAndHelperRegistry(); - const dirs = resolvePluginSkillDirs({ - workspaceDir, + const dirs = collectPluginSkillDirsFromRegistry({ + registry, config: { acp: { enabled: acpEnabled }, plugins: { @@ -134,17 +147,15 @@ describe("resolvePluginSkillDirs", () => { }); it("rejects plugin skill paths that escape the plugin root", async () => { - const { workspaceDir, pluginRoot, outsideSkills } = await setupPluginOutsideSkills(); + const { pluginRoot, outsideSkills } = await setupPluginOutsideSkills(); await fs.mkdir(path.join(pluginRoot, "skills"), { recursive: true }); await fs.mkdir(outsideSkills, { recursive: true }); const escapePath = path.relative(pluginRoot, outsideSkills); - hoisted.loadPluginManifestRegistry.mockReturnValue( - createSinglePluginRegistry({ - pluginRoot, - skills: ["./skills", escapePath], - }), - ); + const registry = createSinglePluginRegistry({ + pluginRoot, + skills: ["./skills", escapePath], + }); const dirs = resolvePluginSkillDirs({ workspaceDir, @@ -161,7 +172,7 @@ describe("resolvePluginSkillDirs", () => { }); it("rejects plugin skill symlinks that resolve outside plugin root", async () => { - const { workspaceDir, pluginRoot, outsideSkills } = await setupPluginOutsideSkills(); + const { pluginRoot, outsideSkills } = await setupPluginOutsideSkills(); const linkPath = path.join(pluginRoot, "skills-link"); await fs.mkdir(outsideSkills, { recursive: true }); await fs.symlink( @@ -170,12 +181,10 @@ describe("resolvePluginSkillDirs", () => { process.platform === "win32" ? ("junction" as const) : ("dir" as const), ); - hoisted.loadPluginManifestRegistry.mockReturnValue( - createSinglePluginRegistry({ - pluginRoot, - skills: ["./skills-link"], - }), - ); + const registry = createSinglePluginRegistry({ + pluginRoot, + skills: ["./skills-link"], + }); const dirs = resolvePluginSkillDirs({ workspaceDir, diff --git a/src/agents/skills/plugin-skills.ts b/src/agents/skills/plugin-skills.ts index 5a02737e5cd..6a70393ee7a 100644 --- a/src/agents/skills/plugin-skills.ts +++ b/src/agents/skills/plugin-skills.ts @@ -1,30 +1,26 @@ import fs from "node:fs"; import path from "node:path"; import type { OpenClawConfig } from "../../config/config.js"; +import { + loadResolvedExtensionRegistry, + type ResolvedExtensionRegistry, +} from "../../extension-host/manifests/resolved-registry.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { normalizePluginsConfig, resolveEffectiveEnableState, resolveMemorySlotDecision, } from "../../plugins/config-state.js"; -import { loadPluginManifestRegistry } from "../../plugins/manifest-registry.js"; import { isPathInsideWithRealpath } from "../../security/scan-paths.js"; const log = createSubsystemLogger("skills"); -export function resolvePluginSkillDirs(params: { - workspaceDir: string | undefined; +export function collectPluginSkillDirsFromRegistry(params: { + registry: ResolvedExtensionRegistry; config?: OpenClawConfig; }): string[] { - const workspaceDir = (params.workspaceDir ?? "").trim(); - if (!workspaceDir) { - return []; - } - const registry = loadPluginManifestRegistry({ - workspaceDir, - config: params.config, - }); - if (registry.plugins.length === 0) { + const registry = params.registry; + if (registry.extensions.length === 0) { return []; } const normalizedPlugins = normalizePluginsConfig(params.config?.plugins); @@ -34,13 +30,15 @@ export function resolvePluginSkillDirs(params: { const seen = new Set(); const resolved: string[] = []; - for (const record of registry.plugins) { - if (!record.skills || record.skills.length === 0) { + for (const record of registry.extensions) { + const extension = record.extension; + const skillPaths = extension.manifest.skills ?? []; + if (skillPaths.length === 0) { continue; } const enableState = resolveEffectiveEnableState({ - id: record.id, - origin: record.origin, + id: extension.id, + origin: extension.origin ?? "workspace", config: normalizedPlugins, rootConfig: params.config, }); @@ -48,33 +46,34 @@ export function resolvePluginSkillDirs(params: { continue; } // ACP router skills should not be attached when ACP is explicitly disabled. - if (!acpEnabled && record.id === "acpx") { + if (!acpEnabled && extension.id === "acpx") { continue; } const memoryDecision = resolveMemorySlotDecision({ - id: record.id, - kind: record.kind, + id: extension.id, + kind: extension.kind, slot: memorySlot, selectedId: selectedMemoryPluginId, }); if (!memoryDecision.enabled) { continue; } - if (memoryDecision.selected && record.kind === "memory") { - selectedMemoryPluginId = record.id; + if (memoryDecision.selected && extension.kind === "memory") { + selectedMemoryPluginId = extension.id; } - for (const raw of record.skills) { + const rootDir = extension.rootDir ?? path.dirname(record.manifestPath); + for (const raw of skillPaths) { const trimmed = raw.trim(); if (!trimmed) { continue; } - const candidate = path.resolve(record.rootDir, trimmed); + const candidate = path.resolve(rootDir, trimmed); if (!fs.existsSync(candidate)) { - log.warn(`plugin skill path not found (${record.id}): ${candidate}`); + log.warn(`plugin skill path not found (${extension.id}): ${candidate}`); continue; } - if (!isPathInsideWithRealpath(record.rootDir, candidate, { requireRealpath: true })) { - log.warn(`plugin skill path escapes plugin root (${record.id}): ${candidate}`); + if (!isPathInsideWithRealpath(rootDir, candidate, { requireRealpath: true })) { + log.warn(`plugin skill path escapes plugin root (${extension.id}): ${candidate}`); continue; } if (seen.has(candidate)) { @@ -87,3 +86,21 @@ export function resolvePluginSkillDirs(params: { return resolved; } + +export function resolvePluginSkillDirs(params: { + workspaceDir: string | undefined; + config?: OpenClawConfig; +}): string[] { + const workspaceDir = (params.workspaceDir ?? "").trim(); + if (!workspaceDir) { + return []; + } + const registry = loadResolvedExtensionRegistry({ + workspaceDir, + config: params.config, + }); + return collectPluginSkillDirsFromRegistry({ + registry, + config: params.config, + }); +} diff --git a/src/auto-reply/reply/commands-plugin.ts b/src/auto-reply/reply/commands-plugin.ts index e76f0f25e73..1c5f23a0c5f 100644 --- a/src/auto-reply/reply/commands-plugin.ts +++ b/src/auto-reply/reply/commands-plugin.ts @@ -5,7 +5,10 @@ * This handler is called before built-in command handlers. */ -import { matchPluginCommand, executePluginCommand } from "../../plugins/commands.js"; +import { + executeExtensionHostPluginCommand, + matchExtensionHostPluginCommand, +} from "../../extension-host/contributions/command-runtime.js"; import type { CommandHandler, CommandHandlerResult } from "./commands-types.js"; /** @@ -24,13 +27,13 @@ export const handlePluginCommand: CommandHandler = async ( } // Try to match a plugin command - const match = matchPluginCommand(command.commandBodyNormalized); + const match = matchExtensionHostPluginCommand(command.commandBodyNormalized); if (!match) { return null; } // Execute the plugin command (always returns a result) - const result = await executePluginCommand({ + const result = await executeExtensionHostPluginCommand({ command: match.command, args: match.args, senderId: command.senderId, diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index 1b7aa2a87ec..7f718e2ff19 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -20,10 +20,10 @@ import { type SessionEntry, type SessionScope, } from "../config/sessions.js"; +import { listExtensionHostPluginCommands } from "../extension-host/contributions/command-runtime.js"; import { formatTimeAgo } from "../infra/format-time/format-relative.ts"; import { resolveCommitHash } from "../infra/git-commit.js"; import type { MediaUnderstandingDecision } from "../media-understanding/types.js"; -import { listPluginCommands } from "../plugins/commands.js"; import { resolveAgentIdFromSessionKey } from "../routing/session-key.js"; import { getTtsMaxLength, @@ -799,7 +799,7 @@ type CommandsListItem = { function buildCommandItems( commands: ChatCommandDefinition[], - pluginCommands: ReturnType, + pluginCommands: ReturnType, ): CommandsListItem[] { const grouped = groupCommandsByCategory(commands); const items: CommandsListItem[] = []; @@ -865,7 +865,7 @@ export function buildCommandsMessagePaginated( const commands = cfg ? listChatCommandsForConfig(cfg, { skillCommands }) : listChatCommands({ skillCommands }); - const pluginCommands = listPluginCommands(); + const pluginCommands = listExtensionHostPluginCommands(); const items = buildCommandItems(commands, pluginCommands); if (!isTelegram) { diff --git a/src/channels/dock.ts b/src/channels/dock.ts index 2e63583ca1b..14c322790cf 100644 --- a/src/channels/dock.ts +++ b/src/channels/dock.ts @@ -8,6 +8,8 @@ import { resolveChannelGroupRequireMention, resolveChannelGroupToolsPolicy, } from "../config/group-policy.js"; +import { listExtensionHostChannelRegistrations } from "../extension-host/contributions/runtime-registry.js"; +import { requireActiveExtensionHostRegistry } from "../extension-host/static/active-registry.js"; import { formatAllowFromLowercase, formatNormalizedAllowFromEntries, @@ -22,7 +24,6 @@ import { resolveWhatsAppConfigAllowFrom, resolveWhatsAppConfigDefaultTo, } from "../plugin-sdk/channel-config-helpers.js"; -import { requireActivePluginRegistry } from "../plugins/runtime.js"; import { normalizeAccountId } from "../routing/session-key.js"; import { normalizeE164 } from "../utils.js"; import { @@ -582,10 +583,10 @@ function buildDockFromPlugin(plugin: ChannelPlugin): ChannelDock { } function listPluginDockEntries(): Array<{ id: ChannelId; dock: ChannelDock; order?: number }> { - const registry = requireActivePluginRegistry(); + const registry = requireActiveExtensionHostRegistry(); const entries: Array<{ id: ChannelId; dock: ChannelDock; order?: number }> = []; const seen = new Set(); - for (const entry of registry.channels) { + for (const entry of listExtensionHostChannelRegistrations(registry)) { const plugin = entry.plugin; const id = String(plugin.id).trim(); if (!id || seen.has(id)) { @@ -627,8 +628,10 @@ export function getChannelDock(id: ChannelId): ChannelDock | undefined { if (core) { return core; } - const registry = requireActivePluginRegistry(); - const pluginEntry = registry.channels.find((entry) => entry.plugin.id === id); + const registry = requireActiveExtensionHostRegistry(); + const pluginEntry = listExtensionHostChannelRegistrations(registry).find( + (entry) => entry.plugin.id === id, + ); if (!pluginEntry) { return undefined; } diff --git a/src/channels/plugins/catalog.ts b/src/channels/plugins/catalog.ts index a853dcdf805..24e99b05b08 100644 --- a/src/channels/plugins/catalog.ts +++ b/src/channels/plugins/catalog.ts @@ -1,8 +1,11 @@ import fs from "node:fs"; import path from "node:path"; -import { MANIFEST_KEY } from "../../compat/legacy-names.js"; +import { + getExtensionPackageMetadata, + type OpenClawPackageManifest, + type PackageManifest, +} from "../../extension-host/manifests/schema.js"; import { discoverOpenClawPlugins } from "../../plugins/discovery.js"; -import type { OpenClawPackageManifest } from "../../plugins/manifest.js"; import type { PluginOrigin } from "../../plugins/types.js"; import { isRecord, resolveConfigDir, resolveUserPath } from "../../utils.js"; import type { ChannelMeta } from "./types.js"; @@ -46,16 +49,10 @@ const ORIGIN_PRIORITY: Record = { bundled: 3, }; -type ExternalCatalogEntry = { - name?: string; - version?: string; - description?: string; -} & Partial>; +type ExternalCatalogEntry = PackageManifest; const ENV_CATALOG_PATHS = ["OPENCLAW_PLUGIN_CATALOG_PATHS", "OPENCLAW_MPM_CATALOG_PATHS"]; -type ManifestKey = typeof MANIFEST_KEY; - function parseCatalogEntries(raw: unknown): ExternalCatalogEntry[] { if (Array.isArray(raw)) { return raw.filter((entry): entry is ExternalCatalogEntry => isRecord(entry)); @@ -227,7 +224,7 @@ function buildCatalogEntry(candidate: { } function buildExternalCatalogEntry(entry: ExternalCatalogEntry): ChannelPluginCatalogEntry | null { - const manifest = entry[MANIFEST_KEY]; + const manifest = getExtensionPackageMetadata(entry); return buildCatalogEntry({ packageName: entry.name, packageManifest: manifest, diff --git a/src/channels/plugins/index.ts b/src/channels/plugins/index.ts index 43b0aa99452..c091cdc0a54 100644 --- a/src/channels/plugins/index.ts +++ b/src/channels/plugins/index.ts @@ -1,7 +1,8 @@ +import { listExtensionHostChannelRegistrations } from "../../extension-host/contributions/runtime-registry.js"; import { - getActivePluginRegistryVersion, - requireActivePluginRegistry, -} from "../../plugins/runtime.js"; + getActiveExtensionHostRegistryVersion, + requireActiveExtensionHostRegistry, +} from "../../extension-host/static/active-registry.js"; import { CHAT_CHANNEL_ORDER, type ChatChannelId, normalizeAnyChannelId } from "../registry.js"; import type { ChannelId, ChannelPlugin } from "./types.js"; @@ -40,14 +41,16 @@ const EMPTY_CHANNEL_PLUGIN_CACHE: CachedChannelPlugins = { let cachedChannelPlugins = EMPTY_CHANNEL_PLUGIN_CACHE; function resolveCachedChannelPlugins(): CachedChannelPlugins { - const registry = requireActivePluginRegistry(); - const registryVersion = getActivePluginRegistryVersion(); + const registry = requireActiveExtensionHostRegistry(); + const registryVersion = getActiveExtensionHostRegistryVersion(); const cached = cachedChannelPlugins; if (cached.registryVersion === registryVersion) { return cached; } - const sorted = dedupeChannels(registry.channels.map((entry) => entry.plugin)).toSorted((a, b) => { + const sorted = dedupeChannels( + listExtensionHostChannelRegistrations(registry).map((entry) => entry.plugin), + ).toSorted((a, b) => { const indexA = CHAT_CHANNEL_ORDER.indexOf(a.id as ChatChannelId); const indexB = CHAT_CHANNEL_ORDER.indexOf(b.id as ChatChannelId); const orderA = a.meta.order ?? (indexA === -1 ? 999 : indexA); diff --git a/src/channels/plugins/plugins-core.test.ts b/src/channels/plugins/plugins-core.test.ts index 8297a6b7519..778a0f567a4 100644 --- a/src/channels/plugins/plugins-core.test.ts +++ b/src/channels/plugins/plugins-core.test.ts @@ -10,6 +10,7 @@ import type { SlackProbe } from "../../../extensions/slack/src/probe.js"; import type { TelegramProbe } from "../../../extensions/telegram/src/probe.js"; import type { TelegramTokenResolution } from "../../../extensions/telegram/src/token.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { addExtensionHostChannelRegistration } from "../../extension-host/contributions/runtime-registry.js"; import type { LineProbeResult } from "../../line/types.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { @@ -96,13 +97,12 @@ describe("channel plugin registry", () => { setActivePluginRegistry(registry, "registry-test"); expect(listChannelPlugins().map((plugin) => plugin.id)).toEqual(["slack"]); - registry.channels = [ - { - pluginId: "telegram", - plugin: createPlugin("telegram"), - source: "test", - }, - ] as typeof registry.channels; + registry.channels = [] as typeof registry.channels; + addExtensionHostChannelRegistration(registry, { + pluginId: "telegram", + plugin: createPlugin("telegram"), + source: "test", + }); setActivePluginRegistry(registry, "registry-test"); expect(listChannelPlugins().map((plugin) => plugin.id)).toEqual(["telegram"]); diff --git a/src/channels/plugins/registry-loader.ts b/src/channels/plugins/registry-loader.ts index 9f23c5fa09e..5c4fc23be9b 100644 --- a/src/channels/plugins/registry-loader.ts +++ b/src/channels/plugins/registry-loader.ts @@ -1,5 +1,6 @@ +import { listExtensionHostChannelRegistrations } from "../../extension-host/contributions/runtime-registry.js"; +import { getActiveExtensionHostRegistry } from "../../extension-host/static/active-registry.js"; import type { PluginChannelRegistration, PluginRegistry } from "../../plugins/registry.js"; -import { getActivePluginRegistry } from "../../plugins/runtime.js"; import type { ChannelId } from "./types.js"; type ChannelRegistryValueResolver = ( @@ -13,7 +14,7 @@ export function createChannelRegistryLoader( let lastRegistry: PluginRegistry | null = null; return async (id: ChannelId): Promise => { - const registry = getActivePluginRegistry(); + const registry = getActiveExtensionHostRegistry(); if (registry !== lastRegistry) { cache.clear(); lastRegistry = registry; @@ -22,7 +23,9 @@ export function createChannelRegistryLoader( if (cached) { return cached; } - const pluginEntry = registry?.channels.find((entry) => entry.plugin.id === id); + const pluginEntry = registry + ? listExtensionHostChannelRegistrations(registry).find((entry) => entry.plugin.id === id) + : undefined; if (!pluginEntry) { return undefined; } diff --git a/src/channels/registry.ts b/src/channels/registry.ts index 16ba6514397..be7ca05622c 100644 --- a/src/channels/registry.ts +++ b/src/channels/registry.ts @@ -1,4 +1,5 @@ -import { requireActivePluginRegistry } from "../plugins/runtime.js"; +import { listExtensionHostChannelRegistrations } from "../extension-host/contributions/runtime-registry.js"; +import { requireActiveExtensionHostRegistry } from "../extension-host/static/active-registry.js"; import type { ChannelMeta } from "./plugins/types.js"; import type { ChannelId } from "./plugins/types.js"; @@ -169,8 +170,8 @@ export function normalizeAnyChannelId(raw?: string | null): ChannelId | null { return null; } - const registry = requireActivePluginRegistry(); - const hit = registry.channels.find((entry) => { + const registry = requireActiveExtensionHostRegistry(); + const hit = listExtensionHostChannelRegistrations(registry).find((entry) => { const id = String(entry.plugin.id ?? "") .trim() .toLowerCase(); diff --git a/src/cli/plugin-registry.test.ts b/src/cli/plugin-registry.test.ts new file mode 100644 index 00000000000..97387dc5ead --- /dev/null +++ b/src/cli/plugin-registry.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createEmptyPluginRegistry } from "../plugins/registry.js"; + +const loadOpenClawPluginsMock = vi.hoisted(() => vi.fn()); +const getActivePluginRegistryMock = vi.hoisted(() => vi.fn()); + +vi.mock("../agents/agent-scope.js", () => ({ + resolveAgentWorkspaceDir: () => "/tmp/workspace", + resolveDefaultAgentId: () => "default-agent", +})); + +vi.mock("../config/config.js", () => ({ + loadConfig: () => ({}), +})); + +vi.mock("../logging.js", () => ({ + createSubsystemLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})); + +vi.mock("../plugins/loader.js", () => ({ + loadOpenClawPlugins: loadOpenClawPluginsMock, +})); + +vi.mock("../plugins/runtime.js", () => ({ + getActivePluginRegistry: getActivePluginRegistryMock, +})); + +describe("ensurePluginRegistryLoaded", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + }); + + it("skips plugin loading when a provider-only registry is already active", async () => { + const registry = createEmptyPluginRegistry(); + registry.providers.push({ + pluginId: "provider-demo", + source: "test", + provider: { + id: "provider-demo", + label: "Provider Demo", + auth: [], + }, + }); + getActivePluginRegistryMock.mockReturnValue(registry); + + const { ensurePluginRegistryLoaded } = await import("./plugin-registry.js"); + ensurePluginRegistryLoaded(); + + expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); + }); + + it("loads plugins once when the active registry is empty", async () => { + getActivePluginRegistryMock.mockReturnValue(createEmptyPluginRegistry()); + + const { ensurePluginRegistryLoaded } = await import("./plugin-registry.js"); + ensurePluginRegistryLoaded(); + ensurePluginRegistryLoaded(); + + expect(loadOpenClawPluginsMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/cli/plugin-registry.ts b/src/cli/plugin-registry.ts index 22d7ce61abb..0ee389e276d 100644 --- a/src/cli/plugin-registry.ts +++ b/src/cli/plugin-registry.ts @@ -1,5 +1,6 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { loadConfig } from "../config/config.js"; +import { hasExtensionHostRuntimeEntries } from "../extension-host/contributions/runtime-registry.js"; import { createSubsystemLogger } from "../logging.js"; import { loadOpenClawPlugins } from "../plugins/loader.js"; import { getActivePluginRegistry } from "../plugins/runtime.js"; @@ -14,11 +15,8 @@ export function ensurePluginRegistryLoaded(): void { } const active = getActivePluginRegistry(); // Tests (and callers) can pre-seed a registry (e.g. `test/setup.ts`); avoid - // doing an expensive load when we already have plugins/channels/tools. - if ( - active && - (active.plugins.length > 0 || active.channels.length > 0 || active.tools.length > 0) - ) { + // doing an expensive load when we already have runtime entries. + if (hasExtensionHostRuntimeEntries(active)) { pluginRegistryLoaded = true; return; } diff --git a/src/commands/auth-choice.apply.plugin-provider.test.ts b/src/commands/auth-choice.apply.plugin-provider.test.ts index 2557fcd2f5c..ea710971c9a 100644 --- a/src/commands/auth-choice.apply.plugin-provider.test.ts +++ b/src/commands/auth-choice.apply.plugin-provider.test.ts @@ -16,10 +16,12 @@ vi.mock("../plugins/providers.js", () => ({ const resolveProviderPluginChoice = vi.hoisted(() => vi.fn<() => { provider: ProviderPlugin; method: ProviderAuthMethod } | null>(), ); -const runProviderModelSelectedHook = vi.hoisted(() => vi.fn(async () => {})); +const runExtensionHostProviderModelSelectedHook = vi.hoisted(() => vi.fn(async () => {})); vi.mock("../plugins/provider-wizard.js", () => ({ resolveProviderPluginChoice, - runProviderModelSelectedHook, +})); +vi.mock("../extension-host/contributions/provider-model-selection.js", () => ({ + runExtensionHostProviderModelSelectedHook, })); const upsertAuthProfile = vi.hoisted(() => vi.fn()); @@ -130,7 +132,7 @@ describe("applyAuthChoiceLoadedPluginProvider", () => { config: {}, agentModelOverride: "ollama/qwen3:4b", }); - expect(runProviderModelSelectedHook).not.toHaveBeenCalled(); + expect(runExtensionHostProviderModelSelectedHook).not.toHaveBeenCalled(); }); it("applies the default model and runs provider post-setup hooks", async () => { @@ -155,7 +157,7 @@ describe("applyAuthChoiceLoadedPluginProvider", () => { }, agentDir: "/tmp/agent", }); - expect(runProviderModelSelectedHook).toHaveBeenCalledWith({ + expect(runExtensionHostProviderModelSelectedHook).toHaveBeenCalledWith({ config: result?.config, model: "ollama/qwen3:4b", prompter: expect.objectContaining({ note: expect.any(Function) }), @@ -279,7 +281,7 @@ describe("applyAuthChoiceLoadedPluginProvider", () => { }, }, }); - expect(runProviderModelSelectedHook).not.toHaveBeenCalled(); + expect(runExtensionHostProviderModelSelectedHook).not.toHaveBeenCalled(); expect(note).toHaveBeenCalledWith( 'Default model set to ollama/qwen3:4b for agent "worker".', "Model configured", diff --git a/src/commands/auth-choice.apply.plugin-provider.ts b/src/commands/auth-choice.apply.plugin-provider.ts index bd97928db91..285adc28b69 100644 --- a/src/commands/auth-choice.apply.plugin-provider.ts +++ b/src/commands/auth-choice.apply.plugin-provider.ts @@ -1,232 +1,35 @@ -import { resolveOpenClawAgentDir } from "../agents/agent-paths.js"; import { - resolveDefaultAgentId, - resolveAgentDir, - resolveAgentWorkspaceDir, -} from "../agents/agent-scope.js"; -import { upsertAuthProfile } from "../agents/auth-profiles.js"; -import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js"; -import { enablePluginInConfig } from "../plugins/enable.js"; -import { - resolveProviderPluginChoice, - runProviderModelSelectedHook, -} from "../plugins/provider-wizard.js"; -import { resolvePluginProviders } from "../plugins/providers.js"; -import type { ProviderAuthMethod } from "../plugins/types.js"; + applyExtensionHostLoadedPluginProvider, + applyExtensionHostPluginProvider, + runExtensionHostProviderAuthMethod, + type ExtensionHostPluginProviderAuthChoiceOptions, +} from "../extension-host/contributions/provider-auth-flow.js"; import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; -import { isRemoteEnvironment } from "./oauth-env.js"; -import { createVpsAwareOAuthHandlers } from "./oauth-flow.js"; -import { applyAuthProfileConfig } from "./onboard-auth.js"; -import { openUrl } from "./onboard-helpers.js"; -import { - applyDefaultModel, - mergeConfigPatch, - pickAuthMethod, - resolveProviderMatch, -} from "./provider-auth-helpers.js"; -export type PluginProviderAuthChoiceOptions = { - authChoice: string; - pluginId: string; - providerId: string; - methodId?: string; - label: string; -}; +export type PluginProviderAuthChoiceOptions = ExtensionHostPluginProviderAuthChoiceOptions; export async function runProviderPluginAuthMethod(params: { config: ApplyAuthChoiceParams["config"]; runtime: ApplyAuthChoiceParams["runtime"]; prompter: ApplyAuthChoiceParams["prompter"]; - method: ProviderAuthMethod; + method: Parameters[0]["method"]; agentDir?: string; agentId?: string; workspaceDir?: string; emitNotes?: boolean; }): Promise<{ config: ApplyAuthChoiceParams["config"]; defaultModel?: string }> { - const agentId = params.agentId ?? resolveDefaultAgentId(params.config); - const defaultAgentId = resolveDefaultAgentId(params.config); - const agentDir = - params.agentDir ?? - (agentId === defaultAgentId - ? resolveOpenClawAgentDir() - : resolveAgentDir(params.config, agentId)); - const workspaceDir = - params.workspaceDir ?? - resolveAgentWorkspaceDir(params.config, agentId) ?? - resolveDefaultAgentWorkspaceDir(); - - const isRemote = isRemoteEnvironment(); - const result = await params.method.run({ - config: params.config, - agentDir, - workspaceDir, - prompter: params.prompter, - runtime: params.runtime, - isRemote, - openUrl: async (url) => { - await openUrl(url); - }, - oauth: { - createVpsAwareHandlers: (opts) => createVpsAwareOAuthHandlers(opts), - }, - }); - - let nextConfig = params.config; - if (result.configPatch) { - nextConfig = mergeConfigPatch(nextConfig, result.configPatch); - } - - for (const profile of result.profiles) { - upsertAuthProfile({ - profileId: profile.profileId, - credential: profile.credential, - agentDir, - }); - - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: profile.profileId, - provider: profile.credential.provider, - mode: profile.credential.type === "token" ? "token" : profile.credential.type, - ...("email" in profile.credential && profile.credential.email - ? { email: profile.credential.email } - : {}), - }); - } - - if (params.emitNotes !== false && result.notes && result.notes.length > 0) { - await params.prompter.note(result.notes.join("\n"), "Provider notes"); - } - - return { - config: nextConfig, - defaultModel: result.defaultModel, - }; + return runExtensionHostProviderAuthMethod(params); } export async function applyAuthChoiceLoadedPluginProvider( params: ApplyAuthChoiceParams, ): Promise { - const agentId = params.agentId ?? resolveDefaultAgentId(params.config); - const workspaceDir = - resolveAgentWorkspaceDir(params.config, agentId) ?? resolveDefaultAgentWorkspaceDir(); - const providers = resolvePluginProviders({ config: params.config, workspaceDir }); - const resolved = resolveProviderPluginChoice({ - providers, - choice: params.authChoice, - }); - if (!resolved) { - return null; - } - - const applied = await runProviderPluginAuthMethod({ - config: params.config, - runtime: params.runtime, - prompter: params.prompter, - method: resolved.method, - agentDir: params.agentDir, - agentId: params.agentId, - workspaceDir, - }); - - let agentModelOverride: string | undefined; - if (applied.defaultModel) { - if (params.setDefaultModel) { - const nextConfig = applyDefaultModel(applied.config, applied.defaultModel); - await runProviderModelSelectedHook({ - config: nextConfig, - model: applied.defaultModel, - prompter: params.prompter, - agentDir: params.agentDir, - workspaceDir, - }); - await params.prompter.note( - `Default model set to ${applied.defaultModel}`, - "Model configured", - ); - return { config: nextConfig }; - } - agentModelOverride = applied.defaultModel; - } - - return { config: applied.config, agentModelOverride }; + return applyExtensionHostLoadedPluginProvider(params); } export async function applyAuthChoicePluginProvider( params: ApplyAuthChoiceParams, options: PluginProviderAuthChoiceOptions, ): Promise { - if (params.authChoice !== options.authChoice) { - return null; - } - - const enableResult = enablePluginInConfig(params.config, options.pluginId); - let nextConfig = enableResult.config; - if (!enableResult.enabled) { - await params.prompter.note( - `${options.label} plugin is disabled (${enableResult.reason ?? "blocked"}).`, - options.label, - ); - return { config: nextConfig }; - } - - const agentId = params.agentId ?? resolveDefaultAgentId(nextConfig); - const defaultAgentId = resolveDefaultAgentId(nextConfig); - const agentDir = - params.agentDir ?? - (agentId === defaultAgentId ? resolveOpenClawAgentDir() : resolveAgentDir(nextConfig, agentId)); - const workspaceDir = - resolveAgentWorkspaceDir(nextConfig, agentId) ?? resolveDefaultAgentWorkspaceDir(); - - const providers = resolvePluginProviders({ config: nextConfig, workspaceDir }); - const provider = resolveProviderMatch(providers, options.providerId); - if (!provider) { - await params.prompter.note( - `${options.label} auth plugin is not available. Enable it and re-run the wizard.`, - options.label, - ); - return { config: nextConfig }; - } - - const method = pickAuthMethod(provider, options.methodId) ?? provider.auth[0]; - if (!method) { - await params.prompter.note(`${options.label} auth method missing.`, options.label); - return { config: nextConfig }; - } - - const applied = await runProviderPluginAuthMethod({ - config: nextConfig, - runtime: params.runtime, - prompter: params.prompter, - method, - agentDir, - agentId, - workspaceDir, - }); - nextConfig = applied.config; - - let agentModelOverride: string | undefined; - if (applied.defaultModel) { - if (params.setDefaultModel) { - nextConfig = applyDefaultModel(nextConfig, applied.defaultModel); - await runProviderModelSelectedHook({ - config: nextConfig, - model: applied.defaultModel, - prompter: params.prompter, - agentDir, - workspaceDir, - }); - await params.prompter.note( - `Default model set to ${applied.defaultModel}`, - "Model configured", - ); - } else if (params.agentId) { - agentModelOverride = applied.defaultModel; - await params.prompter.note( - `Default model set to ${applied.defaultModel} for agent "${params.agentId}".`, - "Model configured", - ); - } - } - - return { config: nextConfig, agentModelOverride }; + return applyExtensionHostPluginProvider(params, options); } diff --git a/src/commands/provider-auth-helpers.ts b/src/commands/provider-auth-helpers.ts index f36c1c3de73..3810ed57fea 100644 --- a/src/commands/provider-auth-helpers.ts +++ b/src/commands/provider-auth-helpers.ts @@ -1,82 +1,30 @@ -import { normalizeProviderId } from "../agents/model-selection.js"; import type { OpenClawConfig } from "../config/config.js"; +import { + applyExtensionHostDefaultModel, + mergeExtensionHostConfigPatch, + pickExtensionHostAuthMethod, + resolveExtensionHostProviderMatch, +} from "../extension-host/contributions/provider-auth.js"; import type { ProviderAuthMethod, ProviderPlugin } from "../plugins/types.js"; export function resolveProviderMatch( providers: ProviderPlugin[], rawProvider?: string, ): ProviderPlugin | null { - const raw = rawProvider?.trim(); - if (!raw) { - return null; - } - const normalized = normalizeProviderId(raw); - return ( - providers.find((provider) => normalizeProviderId(provider.id) === normalized) ?? - providers.find( - (provider) => - provider.aliases?.some((alias) => normalizeProviderId(alias) === normalized) ?? false, - ) ?? - null - ); + return resolveExtensionHostProviderMatch(providers, rawProvider); } export function pickAuthMethod( provider: ProviderPlugin, rawMethod?: string, ): ProviderAuthMethod | null { - const raw = rawMethod?.trim(); - if (!raw) { - return null; - } - const normalized = raw.toLowerCase(); - return ( - provider.auth.find((method) => method.id.toLowerCase() === normalized) ?? - provider.auth.find((method) => method.label.toLowerCase() === normalized) ?? - null - ); -} - -function isPlainRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); + return pickExtensionHostAuthMethod(provider, rawMethod); } export function mergeConfigPatch(base: T, patch: unknown): T { - if (!isPlainRecord(base) || !isPlainRecord(patch)) { - return patch as T; - } - - const next: Record = { ...base }; - for (const [key, value] of Object.entries(patch)) { - const existing = next[key]; - if (isPlainRecord(existing) && isPlainRecord(value)) { - next[key] = mergeConfigPatch(existing, value); - } else { - next[key] = value; - } - } - return next as T; + return mergeExtensionHostConfigPatch(base, patch); } export function applyDefaultModel(cfg: OpenClawConfig, model: string): OpenClawConfig { - const models = { ...cfg.agents?.defaults?.models }; - models[model] = models[model] ?? {}; - - const existingModel = cfg.agents?.defaults?.model; - return { - ...cfg, - agents: { - ...cfg.agents, - defaults: { - ...cfg.agents?.defaults, - models, - model: { - ...(existingModel && typeof existingModel === "object" && "fallbacks" in existingModel - ? { fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks } - : undefined), - primary: model, - }, - }, - }, - }; + return applyExtensionHostDefaultModel(cfg, model); } diff --git a/src/config/doc-baseline.ts b/src/config/doc-baseline.ts index 4ff03af91e0..e9d82bd7b5d 100644 --- a/src/config/doc-baseline.ts +++ b/src/config/doc-baseline.ts @@ -3,8 +3,11 @@ import os from "node:os"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import type { ChannelPlugin } from "../channels/plugins/index.js"; +import { + loadResolvedExtensionRegistry, + type ResolvedExtensionRegistry, +} from "../extension-host/manifests/resolved-registry.js"; import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; -import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js"; import { FIELD_HELP } from "./schema.help.js"; import { buildConfigSchema, type ConfigSchemaResponse } from "./schema.js"; @@ -355,29 +358,41 @@ async function loadBundledConfigSchemaResponse(): Promise OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(repoRoot, "extensions"), }; - const manifestRegistry = loadPluginManifestRegistry({ + const registry = loadResolvedExtensionRegistry({ cache: false, env, config: {}, }); + return buildBundledConfigSchemaResponseFromRegistry(registry); +} + +async function buildBundledConfigSchemaResponseFromRegistry( + registry: ResolvedExtensionRegistry, +): Promise { const channelPlugins = await Promise.all( - manifestRegistry.plugins - .filter((plugin) => plugin.origin === "bundled" && plugin.channels.length > 0) - .map(async (plugin) => ({ - id: plugin.id, - channel: await importChannelPluginModule(plugin.rootDir), + registry.extensions + .filter( + (record) => + record.extension.origin === "bundled" && + (record.extension.manifest.channels?.length ?? 0) > 0, + ) + .map(async (record) => ({ + id: record.extension.id, + channel: await importChannelPluginModule( + record.extension.rootDir ?? path.dirname(record.manifestPath), + ), })), ); return buildConfigSchema({ - plugins: manifestRegistry.plugins - .filter((plugin) => plugin.origin === "bundled") - .map((plugin) => ({ - id: plugin.id, - name: plugin.name, - description: plugin.description, - configUiHints: plugin.configUiHints, - configSchema: plugin.configSchema, + plugins: registry.extensions + .filter((record) => record.extension.origin === "bundled") + .map((record) => ({ + id: record.extension.id, + name: record.extension.name, + description: record.extension.description, + configUiHints: record.extension.staticMetadata.configUiHints, + configSchema: record.extension.staticMetadata.configSchema, })), channels: channelPlugins.map((entry) => ({ id: entry.channel.id, diff --git a/src/config/plugin-auto-enable.ts b/src/config/plugin-auto-enable.ts index 4e0cae1209f..58cce91c6f0 100644 --- a/src/config/plugin-auto-enable.ts +++ b/src/config/plugin-auto-enable.ts @@ -10,9 +10,11 @@ import { normalizeChatChannelId, } from "../channels/registry.js"; import { - loadPluginManifestRegistry, - type PluginManifestRegistry, -} from "../plugins/manifest-registry.js"; + loadResolvedExtensionRegistry, + resolvedExtensionRegistryFromPluginManifestRegistry, + type ResolvedExtensionRegistry, +} from "../extension-host/manifests/resolved-registry.js"; +import { type PluginManifestRegistry } from "../plugins/manifest-registry.js"; import { isRecord } from "../utils.js"; import type { OpenClawConfig } from "./config.js"; import { ensurePluginAllowlisted } from "./plugins-allowlist.js"; @@ -283,12 +285,12 @@ function isProviderConfigured(cfg: OpenClawConfig, providerId: string): boolean return false; } -function buildChannelToPluginIdMap(registry: PluginManifestRegistry): Map { +function buildChannelToPluginIdMap(registry: ResolvedExtensionRegistry): Map { const map = new Map(); - for (const record of registry.plugins) { - for (const channelId of record.channels) { + for (const record of registry.extensions) { + for (const channelId of record.extension.manifest.channels ?? []) { if (channelId && !map.has(channelId)) { - map.set(channelId, record.id); + map.set(channelId, record.extension.id); } } } @@ -336,7 +338,7 @@ function collectCandidateChannelIds(cfg: OpenClawConfig, env: NodeJS.ProcessEnv) function resolveConfiguredPlugins( cfg: OpenClawConfig, env: NodeJS.ProcessEnv, - registry: PluginManifestRegistry, + registry: ResolvedExtensionRegistry, ): PluginEnableChange[] { const changes: PluginEnableChange[] = []; // Build reverse map: channel ID → plugin ID from installed plugin manifests. @@ -471,17 +473,39 @@ function formatAutoEnableChange(entry: PluginEnableChange): string { return `${reason}, enabled automatically.`; } +function resolveAutoEnableRegistry(params: { + config: OpenClawConfig; + env: NodeJS.ProcessEnv; + resolvedRegistry?: ResolvedExtensionRegistry; + manifestRegistry?: PluginManifestRegistry; +}): ResolvedExtensionRegistry { + if (params.resolvedRegistry) { + return params.resolvedRegistry; + } + if (params.manifestRegistry) { + return resolvedExtensionRegistryFromPluginManifestRegistry(params.manifestRegistry); + } + return loadResolvedExtensionRegistry({ config: params.config, env: params.env }); +} + export function applyPluginAutoEnable(params: { config: OpenClawConfig; env?: NodeJS.ProcessEnv; + /** Pre-loaded resolved-extension registry. Prefer this over manifestRegistry + * for new callers so static consumers stay on the host-owned boundary. */ + resolvedRegistry?: ResolvedExtensionRegistry; /** Pre-loaded manifest registry. When omitted, the registry is loaded from - * the installed plugins on disk. Pass an explicit registry in tests to - * avoid filesystem access and control what plugins are "installed". */ + * the installed plugins on disk. This remains as a compatibility input for + * older callers; prefer resolvedRegistry for new code. */ manifestRegistry?: PluginManifestRegistry; }): PluginAutoEnableResult { const env = params.env ?? process.env; - const registry = - params.manifestRegistry ?? loadPluginManifestRegistry({ config: params.config, env }); + const registry = resolveAutoEnableRegistry({ + config: params.config, + env, + resolvedRegistry: params.resolvedRegistry, + manifestRegistry: params.manifestRegistry, + }); const configured = resolveConfiguredPlugins(params.config, env, registry); if (configured.length === 0) { return { config: params.config, changes: [] }; diff --git a/src/config/resolved-extension-validation.test.ts b/src/config/resolved-extension-validation.test.ts new file mode 100644 index 00000000000..4216eb53779 --- /dev/null +++ b/src/config/resolved-extension-validation.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { buildResolvedExtensionValidationIndex } from "./resolved-extension-validation.js"; + +describe("buildResolvedExtensionValidationIndex", () => { + it("collects known ids, channel ids, and schema-bearing entries from resolved extensions", () => { + const index = buildResolvedExtensionValidationIndex({ + diagnostics: [], + extensions: [ + { + extension: { + id: "helper-plugin", + origin: "config", + manifest: { + id: "helper-plugin", + configSchema: { type: "object" }, + channels: ["apn", "custom-chat"], + }, + staticMetadata: { + configSchema: { + type: "object", + properties: { + enabledFlag: { type: "boolean" }, + }, + }, + package: { entries: ["index.ts"] }, + }, + contributions: [], + }, + manifestPath: "/tmp/helper/openclaw.plugin.json", + schemaCacheKey: "helper-schema", + }, + ], + }); + + expect(index.knownIds).toEqual(new Set(["helper-plugin"])); + expect(index.channelIds).toEqual(new Set(["apn", "custom-chat"])); + expect(index.lowercaseChannelIds).toEqual(new Set(["apn", "custom-chat"])); + expect(index.entries).toEqual([ + expect.objectContaining({ + id: "helper-plugin", + origin: "config", + channels: ["apn", "custom-chat"], + schemaCacheKey: "helper-schema", + }), + ]); + }); +}); diff --git a/src/config/resolved-extension-validation.ts b/src/config/resolved-extension-validation.ts new file mode 100644 index 00000000000..90ffa92b62a --- /dev/null +++ b/src/config/resolved-extension-validation.ts @@ -0,0 +1,56 @@ +import type { ResolvedExtensionRegistry } from "../extension-host/manifests/resolved-registry.js"; + +export type ResolvedExtensionValidationEntry = { + id: string; + origin: "workspace" | "bundled" | "global" | "config"; + format?: "bundle" | "openclaw"; + kind?: string; + channels: string[]; + configSchema?: Record; + manifestPath: string; + schemaCacheKey?: string; +}; + +export type ResolvedExtensionValidationIndex = { + knownIds: Set; + channelIds: Set; + lowercaseChannelIds: Set; + entries: ResolvedExtensionValidationEntry[]; +}; + +export function buildResolvedExtensionValidationIndex( + registry: ResolvedExtensionRegistry, +): ResolvedExtensionValidationIndex { + const knownIds = new Set(); + const channelIds = new Set(); + const lowercaseChannelIds = new Set(); + const entries: ResolvedExtensionValidationEntry[] = registry.extensions.map((record) => { + const extension = record.extension; + const channels = [...(extension.manifest.channels ?? [])]; + knownIds.add(extension.id); + for (const channelId of channels) { + channelIds.add(channelId); + const trimmed = channelId.trim(); + if (trimmed) { + lowercaseChannelIds.add(trimmed.toLowerCase()); + } + } + return { + id: extension.id, + origin: extension.origin ?? "workspace", + format: record.manifestPath.endsWith("package.json") ? "openclaw" : "bundle", + kind: extension.kind, + channels, + configSchema: extension.staticMetadata.configSchema, + manifestPath: record.manifestPath, + schemaCacheKey: record.schemaCacheKey, + }; + }); + + return { + knownIds, + channelIds, + lowercaseChannelIds, + entries, + }; +} diff --git a/src/config/validation.ts b/src/config/validation.ts index e97bd8cbedf..d0697522d69 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -1,12 +1,12 @@ import path from "node:path"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { CHANNEL_IDS, normalizeChatChannelId } from "../channels/registry.js"; +import { loadResolvedExtensionRegistry } from "../extension-host/manifests/resolved-registry.js"; import { normalizePluginsConfig, resolveEffectiveEnableState, resolveMemorySlotDecision, } from "../plugins/config-state.js"; -import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js"; import { validateJsonSchemaValue } from "../plugins/schema-validator.js"; import { hasAvatarUriScheme, @@ -21,6 +21,7 @@ import { findDuplicateAgentDirs, formatDuplicateAgentDirError } from "./agent-di import { appendAllowedValuesHint, summarizeAllowedValues } from "./allowed-values.js"; import { applyAgentDefaults, applyModelDefaults, applySessionDefaults } from "./defaults.js"; import { findLegacyConfigIssues } from "./legacy.js"; +import { buildResolvedExtensionValidationIndex } from "./resolved-extension-validation.js"; import type { OpenClawConfig, ConfigValidationIssue } from "./types.js"; import { OpenClawSchema } from "./zod-schema.js"; @@ -335,7 +336,8 @@ function validateConfigObjectWithPluginsBase( }; type RegistryInfo = { - registry: ReturnType; + registry: ReturnType; + validationIndex?: ReturnType; knownIds?: Set; normalizedPlugins?: ReturnType; }; @@ -348,7 +350,7 @@ function validateConfigObjectWithPluginsBase( } const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config)); - const registry = loadPluginManifestRegistry({ + const registry = loadResolvedExtensionRegistry({ config, workspaceDir: workspaceDir ?? undefined, env: opts.env, @@ -374,12 +376,21 @@ function validateConfigObjectWithPluginsBase( const ensureKnownIds = (): Set => { const info = ensureRegistry(); - if (!info.knownIds) { - info.knownIds = new Set(info.registry.plugins.map((record) => record.id)); + if (!info.validationIndex) { + info.validationIndex = buildResolvedExtensionValidationIndex(info.registry); } + info.knownIds ??= info.validationIndex.knownIds; return info.knownIds; }; + const ensureValidationIndex = (): ReturnType => { + const info = ensureRegistry(); + if (!info.validationIndex) { + info.validationIndex = buildResolvedExtensionValidationIndex(info.registry); + } + return info.validationIndex; + }; + const ensureNormalizedPlugins = (): ReturnType => { const info = ensureRegistry(); if (!info.normalizedPlugins) { @@ -397,11 +408,9 @@ function validateConfigObjectWithPluginsBase( continue; } if (!allowedChannels.has(trimmed)) { - const { registry } = ensureRegistry(); - for (const record of registry.plugins) { - for (const channelId of record.channels) { - allowedChannels.add(channelId); - } + const validationIndex = ensureValidationIndex(); + for (const channelId of validationIndex.channelIds) { + allowedChannels.add(channelId); } } if (!allowedChannels.has(trimmed)) { @@ -435,14 +444,9 @@ function validateConfigObjectWithPluginsBase( return; } if (!heartbeatChannelIds.has(normalized)) { - const { registry } = ensureRegistry(); - for (const record of registry.plugins) { - for (const channelId of record.channels) { - const pluginChannel = channelId.trim(); - if (pluginChannel) { - heartbeatChannelIds.add(pluginChannel.toLowerCase()); - } - } + const validationIndex = ensureValidationIndex(); + for (const channelId of validationIndex.lowercaseChannelIds) { + heartbeatChannelIds.add(channelId); } } if (heartbeatChannelIds.has(normalized)) { @@ -468,7 +472,7 @@ function validateConfigObjectWithPluginsBase( return { ok: true, config, warnings }; } - const { registry } = ensureRegistry(); + const validationIndex = ensureValidationIndex(); const knownIds = ensureKnownIds(); const normalizedPlugins = ensureNormalizedPlugins(); const pushMissingPluginIssue = ( @@ -544,7 +548,7 @@ function validateConfigObjectWithPluginsBase( let selectedMemoryPluginId: string | null = null; const seenPlugins = new Set(); - for (const record of registry.plugins) { + for (const record of validationIndex.entries) { const pluginId = record.id; if (seenPlugins.has(pluginId)) { continue; diff --git a/src/extension-host/activation.test.ts b/src/extension-host/activation.test.ts new file mode 100644 index 00000000000..a71149eec7e --- /dev/null +++ b/src/extension-host/activation.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { getGlobalHookRunner, resetGlobalHookRunner } from "../plugins/hook-runner-global.js"; +import { createEmptyPluginRegistry } from "../plugins/registry.js"; +import { activateExtensionHostRegistry } from "./activation.js"; +import { + getActiveExtensionHostRegistry, + getActiveExtensionHostRegistryKey, +} from "./static/active-registry.js"; + +describe("extension host activation", () => { + beforeEach(() => { + resetGlobalHookRunner(); + }); + + it("activates the registry through the host boundary", () => { + const registry = createEmptyPluginRegistry(); + registry.plugins.push({ + id: "activation-test", + name: "activation-test", + source: "test", + origin: "workspace", + enabled: true, + status: "loaded", + toolNames: [], + hookNames: [], + channelIds: [], + providerIds: [], + gatewayMethods: [], + cliCommands: [], + services: [], + commands: [], + httpRoutes: 0, + hookCount: 0, + configSchema: false, + }); + + activateExtensionHostRegistry(registry, "activation-key"); + + expect(getActiveExtensionHostRegistry()).toBe(registry); + expect(getActiveExtensionHostRegistryKey()).toBe("activation-key"); + expect(getGlobalHookRunner()).toBeDefined(); + }); +}); diff --git a/src/extension-host/activation.ts b/src/extension-host/activation.ts new file mode 100644 index 00000000000..a76d506f03e --- /dev/null +++ b/src/extension-host/activation.ts @@ -0,0 +1,8 @@ +import { initializeGlobalHookRunner } from "../plugins/hook-runner-global.js"; +import type { PluginRegistry } from "../plugins/registry.js"; +import { setActiveExtensionHostRegistry } from "./static/active-registry.js"; + +export function activateExtensionHostRegistry(registry: PluginRegistry, cacheKey: string): void { + setActiveExtensionHostRegistry(registry, cacheKey); + initializeGlobalHookRunner(registry); +} diff --git a/src/extension-host/activation/loader-bootstrap.test.ts b/src/extension-host/activation/loader-bootstrap.test.ts new file mode 100644 index 00000000000..57da529ce2f --- /dev/null +++ b/src/extension-host/activation/loader-bootstrap.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { createEmptyPluginRegistry } from "../../plugins/registry.js"; +import { bootstrapExtensionHostPluginLoad } from "./loader-bootstrap.js"; + +describe("extension host loader bootstrap", () => { + it("pushes manifest diagnostics, logs discovery warnings, and orders candidates", () => { + const warnings: string[] = []; + const registry = createEmptyPluginRegistry(); + + const result = bootstrapExtensionHostPluginLoad({ + config: {}, + env: process.env, + cacheKey: "cache-key", + normalizedConfig: { + enabled: true, + allow: [], + loadPaths: [], + entries: {}, + slots: {}, + }, + warningCache: new Set(), + logger: { + info: () => {}, + warn: (message) => warnings.push(message), + error: () => {}, + }, + registry, + discoverPlugins: () => ({ + candidates: [ + { + idHint: "b", + source: "/plugins/b.ts", + rootDir: "/plugins/b", + origin: "workspace", + }, + { + idHint: "a", + source: "/plugins/a.ts", + rootDir: "/plugins/a", + origin: "workspace", + }, + ], + diagnostics: [], + }), + loadManifestRegistry: () => ({ + diagnostics: [{ level: "warn", message: "manifest warning" }], + plugins: [ + { + id: "a", + rootDir: "/plugins/a", + source: "/plugins/a.ts", + origin: "workspace", + } as never, + { + id: "b", + rootDir: "/plugins/b", + source: "/plugins/b.ts", + origin: "workspace", + } as never, + ], + }), + resolveDiscoveryPolicy: () => ({ + warningMessages: ["open allowlist warning"], + }), + buildProvenanceIndex: () => ({ + loadPathMatcher: { exact: new Set(), dirs: [] }, + installRules: new Map(), + }), + compareDuplicateCandidateOrder: ({ left, right }) => left.idHint.localeCompare(right.idHint), + }); + + expect(registry.diagnostics).toEqual([{ level: "warn", message: "manifest warning" }]); + expect(warnings).toEqual(["open allowlist warning"]); + expect(result.orderedCandidates.map((candidate) => candidate.idHint)).toEqual(["a", "b"]); + expect(result.manifestByRoot.get("/plugins/a")?.id).toBe("a"); + }); +}); diff --git a/src/extension-host/activation/loader-bootstrap.ts b/src/extension-host/activation/loader-bootstrap.ts new file mode 100644 index 00000000000..0745230f6eb --- /dev/null +++ b/src/extension-host/activation/loader-bootstrap.ts @@ -0,0 +1,107 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import type { NormalizedPluginsConfig } from "../../plugins/config-state.js"; +import { discoverOpenClawPlugins, type PluginCandidate } from "../../plugins/discovery.js"; +import { + loadPluginManifestRegistry, + type PluginManifestRecord, + type PluginManifestRegistry, +} from "../../plugins/manifest-registry.js"; +import type { PluginRegistry } from "../../plugins/registry.js"; +import type { PluginLogger } from "../../plugins/types.js"; +import { resolveExtensionHostDiscoveryPolicy } from "../policy/loader-discovery-policy.js"; +import { + buildExtensionHostProvenanceIndex, + compareExtensionHostDuplicateCandidateOrder, + pushExtensionHostDiagnostics, +} from "../policy/loader-policy.js"; +import type { ExtensionHostProvenanceIndex } from "../policy/loader-provenance.js"; + +export function bootstrapExtensionHostPluginLoad(params: { + config: OpenClawConfig; + workspaceDir?: string; + env: NodeJS.ProcessEnv; + cache?: boolean; + cacheKey: string; + normalizedConfig: NormalizedPluginsConfig; + warningCache: Set; + logger: PluginLogger; + registry: PluginRegistry; + discoverPlugins?: typeof discoverOpenClawPlugins; + loadManifestRegistry?: typeof loadPluginManifestRegistry; + pushDiagnostics?: typeof pushExtensionHostDiagnostics; + resolveDiscoveryPolicy?: typeof resolveExtensionHostDiscoveryPolicy; + buildProvenanceIndex?: typeof buildExtensionHostProvenanceIndex; + compareDuplicateCandidateOrder?: typeof compareExtensionHostDuplicateCandidateOrder; +}): { + manifestByRoot: Map; + orderedCandidates: PluginCandidate[]; + provenance: ExtensionHostProvenanceIndex; + manifestRegistry: PluginManifestRegistry; +} { + const discoverPlugins = params.discoverPlugins ?? discoverOpenClawPlugins; + const loadManifestRegistry = params.loadManifestRegistry ?? loadPluginManifestRegistry; + const pushDiagnostics = params.pushDiagnostics ?? pushExtensionHostDiagnostics; + const resolveDiscoveryPolicy = + params.resolveDiscoveryPolicy ?? resolveExtensionHostDiscoveryPolicy; + const buildProvenanceIndex = params.buildProvenanceIndex ?? buildExtensionHostProvenanceIndex; + const compareDuplicateCandidateOrder = + params.compareDuplicateCandidateOrder ?? compareExtensionHostDuplicateCandidateOrder; + + const discovery = discoverPlugins({ + workspaceDir: params.workspaceDir, + extraPaths: params.normalizedConfig.loadPaths, + cache: params.cache, + env: params.env, + }); + const manifestRegistry = loadManifestRegistry({ + config: params.config, + workspaceDir: params.workspaceDir, + cache: params.cache, + env: params.env, + candidates: discovery.candidates, + diagnostics: discovery.diagnostics, + }); + + pushDiagnostics(params.registry.diagnostics, manifestRegistry.diagnostics); + + const discoveryPolicy = resolveDiscoveryPolicy({ + pluginsEnabled: params.normalizedConfig.enabled, + allow: params.normalizedConfig.allow, + warningCacheKey: params.cacheKey, + warningCache: params.warningCache, + discoverablePlugins: manifestRegistry.plugins.map((plugin) => ({ + id: plugin.id, + source: plugin.source, + origin: plugin.origin, + })), + }); + for (const warning of discoveryPolicy.warningMessages) { + params.logger.warn(warning); + } + + const provenance = buildProvenanceIndex({ + config: params.config, + normalizedLoadPaths: params.normalizedConfig.loadPaths, + env: params.env, + }); + + const manifestByRoot = new Map( + manifestRegistry.plugins.map((record) => [record.rootDir, record]), + ); + const orderedCandidates = [...discovery.candidates].toSorted((left, right) => { + return compareDuplicateCandidateOrder({ + left, + right, + manifestByRoot, + provenance, + env: params.env, + }); + }); + + return { + manifestByRoot, + orderedCandidates, + provenance, + manifestRegistry, + }; +} diff --git a/src/extension-host/activation/loader-cache.test.ts b/src/extension-host/activation/loader-cache.test.ts new file mode 100644 index 00000000000..da1f8ced98d --- /dev/null +++ b/src/extension-host/activation/loader-cache.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import type { PluginRegistry } from "../../plugins/registry.js"; +import { + buildExtensionHostRegistryCacheKey, + clearExtensionHostRegistryCache, + getCachedExtensionHostRegistry, + MAX_EXTENSION_HOST_REGISTRY_CACHE_ENTRIES, + setCachedExtensionHostRegistry, +} from "./loader-cache.js"; + +function createRegistry(id: string): PluginRegistry { + return { + plugins: [ + { + id, + name: id, + source: `/plugins/${id}.js`, + origin: "workspace", + enabled: true, + status: "loaded", + lifecycleState: "registered", + toolNames: [], + hookNames: [], + channelIds: [], + providerIds: [], + gatewayMethods: [], + cliCommands: [], + services: [], + commands: [], + httpRoutes: 0, + hookCount: 0, + configSchema: false, + }, + ], + tools: [], + hooks: [], + typedHooks: [], + channels: [], + providers: [], + gatewayHandlers: {}, + httpRoutes: [], + cliRegistrars: [], + services: [], + commands: [], + diagnostics: [], + }; +} + +describe("extension host loader cache", () => { + it("normalizes install paths into the cache key", () => { + const env = { ...process.env, HOME: "/tmp/home" }; + + const first = buildExtensionHostRegistryCacheKey({ + workspaceDir: "/workspace", + plugins: { + enabled: true, + allow: [], + loadPaths: ["~/plugins"], + entries: {}, + slots: {}, + }, + installs: { + demo: { + installPath: "~/demo-install", + sourcePath: "~/demo-source", + }, + }, + env, + }); + const second = buildExtensionHostRegistryCacheKey({ + workspaceDir: "/workspace", + plugins: { + enabled: true, + allow: [], + loadPaths: ["/tmp/home/plugins"], + entries: {}, + slots: {}, + }, + installs: { + demo: { + installPath: "/tmp/home/demo-install", + sourcePath: "/tmp/home/demo-source", + }, + }, + env, + }); + + expect(first).toBe(second); + }); + + it("evicts least recently used registries", () => { + clearExtensionHostRegistryCache(); + + for (let index = 0; index < MAX_EXTENSION_HOST_REGISTRY_CACHE_ENTRIES + 1; index += 1) { + setCachedExtensionHostRegistry(`cache-${index}`, createRegistry(`plugin-${index}`)); + } + + expect(getCachedExtensionHostRegistry("cache-0")).toBeUndefined(); + expect( + getCachedExtensionHostRegistry(`cache-${MAX_EXTENSION_HOST_REGISTRY_CACHE_ENTRIES}`), + ).toBeDefined(); + }); + + it("refreshes cache insertion order on reads", () => { + clearExtensionHostRegistryCache(); + + for (let index = 0; index < MAX_EXTENSION_HOST_REGISTRY_CACHE_ENTRIES; index += 1) { + setCachedExtensionHostRegistry(`cache-${index}`, createRegistry(`plugin-${index}`)); + } + + const refreshed = getCachedExtensionHostRegistry("cache-0"); + expect(refreshed).toBeDefined(); + + setCachedExtensionHostRegistry("cache-new", createRegistry("plugin-new")); + + expect(getCachedExtensionHostRegistry("cache-1")).toBeUndefined(); + expect(getCachedExtensionHostRegistry("cache-0")).toBe(refreshed); + }); +}); diff --git a/src/extension-host/activation/loader-cache.ts b/src/extension-host/activation/loader-cache.ts new file mode 100644 index 00000000000..d046dd4bb1a --- /dev/null +++ b/src/extension-host/activation/loader-cache.ts @@ -0,0 +1,72 @@ +import type { PluginInstallRecord } from "../../config/types.plugins.js"; +import type { NormalizedPluginsConfig } from "../../plugins/config-state.js"; +import type { PluginRegistry } from "../../plugins/registry.js"; +import { resolvePluginCacheInputs } from "../../plugins/roots.js"; +import { resolveUserPath } from "../../utils.js"; + +export const MAX_EXTENSION_HOST_REGISTRY_CACHE_ENTRIES = 32; + +const extensionHostRegistryCache = new Map(); + +export function clearExtensionHostRegistryCache(): void { + extensionHostRegistryCache.clear(); +} + +export function getCachedExtensionHostRegistry(cacheKey: string): PluginRegistry | undefined { + const cached = extensionHostRegistryCache.get(cacheKey); + if (!cached) { + return undefined; + } + // Refresh insertion order so frequently reused registries survive eviction. + extensionHostRegistryCache.delete(cacheKey); + extensionHostRegistryCache.set(cacheKey, cached); + return cached; +} + +export function setCachedExtensionHostRegistry(cacheKey: string, registry: PluginRegistry): void { + if (extensionHostRegistryCache.has(cacheKey)) { + extensionHostRegistryCache.delete(cacheKey); + } + extensionHostRegistryCache.set(cacheKey, registry); + while (extensionHostRegistryCache.size > MAX_EXTENSION_HOST_REGISTRY_CACHE_ENTRIES) { + const oldestKey = extensionHostRegistryCache.keys().next().value; + if (!oldestKey) { + break; + } + extensionHostRegistryCache.delete(oldestKey); + } +} + +export function buildExtensionHostRegistryCacheKey(params: { + workspaceDir?: string; + plugins: NormalizedPluginsConfig; + installs?: Record; + env: NodeJS.ProcessEnv; +}): string { + const { roots, loadPaths } = resolvePluginCacheInputs({ + workspaceDir: params.workspaceDir, + loadPaths: params.plugins.loadPaths, + env: params.env, + }); + const installs = Object.fromEntries( + Object.entries(params.installs ?? {}).map(([pluginId, install]) => [ + pluginId, + { + ...install, + installPath: + typeof install.installPath === "string" + ? resolveUserPath(install.installPath, params.env) + : install.installPath, + sourcePath: + typeof install.sourcePath === "string" + ? resolveUserPath(install.sourcePath, params.env) + : install.sourcePath, + }, + ]), + ); + return `${roots.workspace ?? ""}::${roots.global ?? ""}::${roots.stock ?? ""}::${JSON.stringify({ + ...params.plugins, + installs, + loadPaths, + })}`; +} diff --git a/src/extension-host/activation/loader-execution.test.ts b/src/extension-host/activation/loader-execution.test.ts new file mode 100644 index 00000000000..7ba59f6170f --- /dev/null +++ b/src/extension-host/activation/loader-execution.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from "vitest"; +import { prepareExtensionHostLoaderExecution } from "./loader-execution.js"; + +describe("extension host loader execution", () => { + it("composes runtime, registry, bootstrap, module loader, and session setup", () => { + const runtime = {} as never; + const registry = { plugins: [], diagnostics: [] } as never; + const createApi = vi.fn() as never; + const loadModule = vi.fn() as never; + const session = { registry } as never; + + const result = prepareExtensionHostLoaderExecution({ + config: {}, + env: process.env, + cacheKey: "cache-key", + normalizedConfig: { + enabled: true, + allow: [], + loadPaths: [], + entries: {}, + slots: {}, + }, + logger: { + info: () => {}, + warn: () => {}, + error: () => {}, + }, + warningCache: new Set(), + setCachedRegistry: vi.fn(), + activateRegistry: vi.fn(), + createRuntime: vi.fn(() => runtime) as never, + createRegistry: vi.fn(() => ({ registry, createApi })) as never, + bootstrapLoad: vi.fn(() => ({ + provenance: { loadPathMatcher: { exact: new Set(), dirs: [] }, installRules: new Map() }, + orderedCandidates: [{ rootDir: "/plugins/a" }], + manifestByRoot: new Map([["/plugins/a", { rootDir: "/plugins/a" }]]), + })) as never, + createModuleLoader: vi.fn(() => loadModule) as never, + createSession: vi.fn(() => session) as never, + }); + + expect(result.registry).toBe(registry); + expect(result.createApi).toBe(createApi); + expect(result.loadModule).toBe(loadModule); + expect(result.session).toBe(session); + expect(result.orderedCandidates).toEqual([{ rootDir: "/plugins/a" }]); + expect(result.manifestByRoot.get("/plugins/a")?.rootDir).toBe("/plugins/a"); + }); +}); diff --git a/src/extension-host/activation/loader-execution.ts b/src/extension-host/activation/loader-execution.ts new file mode 100644 index 00000000000..88872fada73 --- /dev/null +++ b/src/extension-host/activation/loader-execution.ts @@ -0,0 +1,92 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import type { NormalizedPluginsConfig } from "../../plugins/config-state.js"; +import { createPluginRegistry, type PluginRegistry } from "../../plugins/registry.js"; +import type { CreatePluginRuntimeOptions } from "../../plugins/runtime/index.js"; +import type { PluginRuntime } from "../../plugins/runtime/types.js"; +import type { PluginLogger } from "../../plugins/types.js"; +import { resolveExtensionHostDiscoveryPolicy } from "../policy/loader-discovery-policy.js"; +import { + buildExtensionHostProvenanceIndex, + compareExtensionHostDuplicateCandidateOrder, + pushExtensionHostDiagnostics, +} from "../policy/loader-policy.js"; +import { bootstrapExtensionHostPluginLoad } from "./loader-bootstrap.js"; +import { createExtensionHostModuleLoader } from "./loader-module-loader.js"; +import { createExtensionHostLazyRuntime } from "./loader-runtime-proxy.js"; +import { + createExtensionHostLoaderSession, + type ExtensionHostLoaderSession, +} from "./loader-session.js"; + +export function prepareExtensionHostLoaderExecution(params: { + config: OpenClawConfig; + workspaceDir?: string; + env: NodeJS.ProcessEnv; + cache?: boolean; + cacheKey: string; + normalizedConfig: NormalizedPluginsConfig; + logger: PluginLogger; + coreGatewayHandlers?: Record; + runtimeOptions?: CreatePluginRuntimeOptions; + warningCache: Set; + setCachedRegistry: (cacheKey: string, registry: PluginRegistry) => void; + activateRegistry: (registry: PluginRegistry, cacheKey: string) => void; + createRuntime: (runtimeOptions?: CreatePluginRuntimeOptions) => PluginRuntime; + createRegistry?: typeof createPluginRegistry; + bootstrapLoad?: typeof bootstrapExtensionHostPluginLoad; + createModuleLoader?: typeof createExtensionHostModuleLoader; + createSession?: typeof createExtensionHostLoaderSession; +}) { + const createRegistry = params.createRegistry ?? createPluginRegistry; + const bootstrapLoad = params.bootstrapLoad ?? bootstrapExtensionHostPluginLoad; + const createModuleLoader = params.createModuleLoader ?? createExtensionHostModuleLoader; + const createSession = params.createSession ?? createExtensionHostLoaderSession; + + const runtime = createExtensionHostLazyRuntime({ + runtimeOptions: params.runtimeOptions, + createRuntime: params.createRuntime, + }); + const { registry, createApi } = createRegistry({ + logger: params.logger, + runtime, + coreGatewayHandlers: params.coreGatewayHandlers as never, + }); + + const bootstrap = bootstrapLoad({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + cacheKey: params.cacheKey, + warningCache: params.warningCache, + cache: params.cache, + normalizedConfig: params.normalizedConfig, + logger: params.logger, + registry, + pushDiagnostics: pushExtensionHostDiagnostics, + resolveDiscoveryPolicy: resolveExtensionHostDiscoveryPolicy, + buildProvenanceIndex: buildExtensionHostProvenanceIndex, + compareDuplicateCandidateOrder: compareExtensionHostDuplicateCandidateOrder, + }); + + const loadModule = createModuleLoader(); + const session: ExtensionHostLoaderSession = createSession({ + registry, + logger: params.logger, + env: params.env, + provenance: bootstrap.provenance, + cacheEnabled: params.cache !== false, + cacheKey: params.cacheKey, + memorySlot: params.normalizedConfig.slots.memory, + setCachedRegistry: params.setCachedRegistry, + activateRegistry: params.activateRegistry, + }); + + return { + registry, + createApi, + loadModule, + session, + orderedCandidates: bootstrap.orderedCandidates, + manifestByRoot: bootstrap.manifestByRoot, + }; +} diff --git a/src/extension-host/activation/loader-finalize.test.ts b/src/extension-host/activation/loader-finalize.test.ts new file mode 100644 index 00000000000..7fb6944992b --- /dev/null +++ b/src/extension-host/activation/loader-finalize.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import type { PluginRegistry } from "../../plugins/registry.js"; +import { createExtensionHostPluginRecord } from "../policy/loader-policy.js"; +import { finalizeExtensionHostRegistryLoad } from "./loader-finalize.js"; +import { setExtensionHostPluginRecordLifecycleState } from "./loader-state.js"; + +function createRegistry(): PluginRegistry { + return { + plugins: [], + tools: [], + hooks: [], + typedHooks: [], + channels: [], + providers: [], + gatewayHandlers: {}, + httpRoutes: [], + cliRegistrars: [], + services: [], + commands: [], + diagnostics: [], + }; +} + +describe("extension host loader finalize", () => { + it("adds missing memory-slot warnings and runs cache plus activation", () => { + const registry = createRegistry(); + const calls: string[] = []; + const record = createExtensionHostPluginRecord({ + id: "demo", + source: "/plugins/demo.js", + origin: "workspace", + enabled: true, + configSchema: true, + }); + setExtensionHostPluginRecordLifecycleState(record, "imported"); + setExtensionHostPluginRecordLifecycleState(record, "validated"); + setExtensionHostPluginRecordLifecycleState(record, "registered"); + registry.plugins.push(record); + + const result = finalizeExtensionHostRegistryLoad({ + registry, + memorySlot: "memory-a", + memorySlotMatched: false, + provenance: { + loadPathMatcher: { + exact: new Set(), + dirs: [], + }, + installRules: new Map(), + }, + logger: { + info: () => {}, + warn: () => {}, + error: () => {}, + }, + env: process.env, + cacheEnabled: true, + cacheKey: "cache-key", + setCachedRegistry: (cacheKey, passedRegistry) => { + calls.push(`cache:${cacheKey}:${passedRegistry === registry}`); + }, + activateRegistry: (passedRegistry, cacheKey) => { + calls.push(`activate:${cacheKey}:${passedRegistry === registry}`); + }, + }); + + expect(result).toBe(registry); + expect(registry.diagnostics).toContainEqual({ + level: "warn", + message: "memory slot plugin not found or not marked as memory: memory-a", + }); + expect(registry.plugins[0]?.lifecycleState).toBe("ready"); + expect(calls).toEqual(["cache:cache-key:true", "activate:cache-key:true"]); + }); + + it("skips cache writes when caching is disabled", () => { + const registry = createRegistry(); + const calls: string[] = []; + + finalizeExtensionHostRegistryLoad({ + registry, + memorySlotMatched: true, + provenance: { + loadPathMatcher: { + exact: new Set(), + dirs: [], + }, + installRules: new Map(), + }, + logger: { + info: () => {}, + warn: () => {}, + error: () => {}, + }, + env: process.env, + cacheEnabled: false, + cacheKey: "cache-key", + setCachedRegistry: () => { + calls.push("cache"); + }, + activateRegistry: () => { + calls.push("activate"); + }, + }); + + expect(calls).toEqual(["activate"]); + }); +}); diff --git a/src/extension-host/activation/loader-finalize.ts b/src/extension-host/activation/loader-finalize.ts new file mode 100644 index 00000000000..6cac899414e --- /dev/null +++ b/src/extension-host/activation/loader-finalize.ts @@ -0,0 +1,37 @@ +import type { PluginRegistry } from "../../plugins/registry.js"; +import type { PluginLogger } from "../../plugins/types.js"; +import { resolveExtensionHostFinalizationPolicy } from "../policy/loader-finalization-policy.js"; +import type { ExtensionHostProvenanceIndex } from "../policy/loader-provenance.js"; +import { markExtensionHostRegistryPluginsReady } from "./loader-state.js"; + +export function finalizeExtensionHostRegistryLoad(params: { + registry: PluginRegistry; + memorySlot?: string | null; + memorySlotMatched: boolean; + provenance: ExtensionHostProvenanceIndex; + logger: PluginLogger; + env: NodeJS.ProcessEnv; + cacheEnabled: boolean; + cacheKey: string; + setCachedRegistry: (cacheKey: string, registry: PluginRegistry) => void; + activateRegistry: (registry: PluginRegistry, cacheKey: string) => void; +}): PluginRegistry { + const finalizationPolicy = resolveExtensionHostFinalizationPolicy({ + registry: params.registry, + memorySlot: params.memorySlot, + memorySlotMatched: params.memorySlotMatched, + provenance: params.provenance, + env: params.env, + }); + params.registry.diagnostics.push(...finalizationPolicy.diagnostics); + for (const warning of finalizationPolicy.warningMessages) { + params.logger.warn(warning); + } + + if (params.cacheEnabled) { + params.setCachedRegistry(params.cacheKey, params.registry); + } + markExtensionHostRegistryPluginsReady(params.registry); + params.activateRegistry(params.registry, params.cacheKey); + return params.registry; +} diff --git a/src/extension-host/activation/loader-flow.test.ts b/src/extension-host/activation/loader-flow.test.ts new file mode 100644 index 00000000000..31c513be785 --- /dev/null +++ b/src/extension-host/activation/loader-flow.test.ts @@ -0,0 +1,256 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { normalizePluginsConfig } from "../../plugins/config-state.js"; +import type { PluginCandidate } from "../../plugins/discovery.js"; +import type { PluginManifestRecord } from "../../plugins/manifest-registry.js"; +import type { PluginRegistry } from "../../plugins/registry.js"; +import { processExtensionHostPluginCandidate } from "./loader-flow.js"; + +const tempDirs: string[] = []; + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } +}); + +function createTempPluginFixture() { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-loader-flow-")); + tempDirs.push(rootDir); + const entryPath = path.join(rootDir, "index.js"); + fs.writeFileSync(entryPath, "export default {}"); + return { rootDir, entryPath }; +} + +function createCandidate( + rootDir: string, + entryPath: string, + overrides: Partial = {}, +): PluginCandidate { + return { + source: entryPath, + rootDir, + packageDir: rootDir, + origin: "workspace", + workspaceDir: "/workspace", + ...overrides, + }; +} + +function createManifestRecord( + rootDir: string, + entryPath: string, + overrides: Partial = {}, +): PluginManifestRecord { + return { + id: "demo", + name: "Demo", + description: "Demo plugin", + version: "1.0.0", + kind: "context-engine", + channels: [], + providers: [], + skills: [], + origin: "workspace", + workspaceDir: "/workspace", + rootDir, + source: entryPath, + manifestPath: path.join(rootDir, "openclaw.plugin.json"), + schemaCacheKey: "demo-schema", + configSchema: { + type: "object", + properties: { + enabled: { type: "boolean" }, + }, + additionalProperties: false, + }, + resolvedExtension: { + id: "demo", + source: "/plugins/demo/index.ts", + origin: "workspace", + rootDir: "/plugins/demo", + workspaceDir: "/workspace", + static: { + package: {}, + config: {}, + setup: {}, + }, + runtime: { + kind: "context-engine", + contributions: [], + }, + policy: {}, + }, + ...overrides, + }; +} + +function createRegistry(): PluginRegistry { + return { + plugins: [], + tools: [], + hooks: [], + typedHooks: [], + channels: [], + providers: [], + gatewayHandlers: {}, + httpRoutes: [], + cliRegistrars: [], + services: [], + commands: [], + diagnostics: [], + }; +} + +describe("extension host loader flow", () => { + it("handles validate-only candidates through the host orchestrator", () => { + const { rootDir, entryPath } = createTempPluginFixture(); + const registry = createRegistry(); + + const result = processExtensionHostPluginCandidate({ + candidate: createCandidate(rootDir, entryPath), + manifestRecord: createManifestRecord(rootDir, entryPath), + normalizedConfig: normalizePluginsConfig({ + entries: { + demo: { + enabled: true, + config: { enabled: true }, + }, + }, + }), + rootConfig: { + plugins: { + entries: { + demo: { + enabled: true, + config: { enabled: true }, + }, + }, + }, + }, + validateOnly: true, + logger: { + info: () => {}, + warn: () => {}, + error: () => {}, + }, + registry, + seenIds: new Map(), + selectedMemoryPluginId: null, + createApi: () => ({}) as never, + loadModule: () => + ({ + default: { + id: "demo", + register: () => {}, + }, + }) as never, + }); + + expect(result).toEqual({ + selectedMemoryPluginId: null, + memorySlotMatched: false, + }); + expect(registry.plugins).toHaveLength(1); + expect(registry.plugins[0]?.id).toBe("demo"); + expect(registry.plugins[0]?.status).toBe("loaded"); + expect(registry.plugins[0]?.lifecycleState).toBe("validated"); + }); + + it("records import failures through the existing plugin error path", () => { + const { rootDir, entryPath } = createTempPluginFixture(); + const registry = createRegistry(); + + processExtensionHostPluginCandidate({ + candidate: createCandidate(rootDir, entryPath), + manifestRecord: createManifestRecord(rootDir, entryPath), + normalizedConfig: normalizePluginsConfig({ + entries: { + demo: { + enabled: true, + }, + }, + }), + rootConfig: { + plugins: { + entries: { + demo: { + enabled: true, + }, + }, + }, + }, + validateOnly: false, + logger: { + info: () => {}, + warn: () => {}, + error: () => {}, + }, + registry, + seenIds: new Map(), + selectedMemoryPluginId: null, + createApi: () => ({}) as never, + loadModule: () => { + throw new Error("boom"); + }, + }); + + expect(registry.plugins).toHaveLength(1); + expect(registry.plugins[0]?.status).toBe("error"); + expect(registry.plugins[0]?.lifecycleState).toBe("error"); + expect(registry.diagnostics[0]?.message).toContain("failed to load plugin"); + }); + + it("records fully registered plugins before final readiness promotion", () => { + const { rootDir, entryPath } = createTempPluginFixture(); + const registry = createRegistry(); + + processExtensionHostPluginCandidate({ + candidate: createCandidate(rootDir, entryPath), + manifestRecord: createManifestRecord(rootDir, entryPath), + normalizedConfig: normalizePluginsConfig({ + entries: { + demo: { + enabled: true, + config: { enabled: true }, + }, + }, + }), + rootConfig: { + plugins: { + entries: { + demo: { + enabled: true, + config: { enabled: true }, + }, + }, + }, + }, + validateOnly: false, + logger: { + info: () => {}, + warn: () => {}, + error: () => {}, + }, + registry, + seenIds: new Map(), + selectedMemoryPluginId: null, + createApi: () => ({}) as never, + loadModule: () => + ({ + default: { + id: "demo", + register: () => {}, + }, + }) as never, + }); + + expect(registry.plugins[0]?.lifecycleState).toBe("registered"); + expect(registry.plugins[0]?.status).toBe("loaded"); + }); +}); diff --git a/src/extension-host/activation/loader-flow.ts b/src/extension-host/activation/loader-flow.ts new file mode 100644 index 00000000000..e867ce38c99 --- /dev/null +++ b/src/extension-host/activation/loader-flow.ts @@ -0,0 +1,242 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import type { NormalizedPluginsConfig } from "../../plugins/config-state.js"; +import type { PluginCandidate } from "../../plugins/discovery.js"; +import type { PluginManifestRecord } from "../../plugins/manifest-registry.js"; +import type { PluginRecord, PluginRegistry } from "../../plugins/registry.js"; +import type { OpenClawPluginApi, OpenClawPluginModule, PluginLogger } from "../../plugins/types.js"; +import { resolveExtensionHostActivationPolicy } from "../policy/loader-activation-policy.js"; +import { recordExtensionHostPluginError } from "../policy/loader-policy.js"; +import { importExtensionHostPluginModule } from "./loader-import.js"; +import { + planExtensionHostLoadedPlugin, + runExtensionHostPluginRegister, +} from "./loader-register.js"; +import { resolveExtensionHostModuleExport } from "./loader-runtime.js"; +import { + appendExtensionHostPluginRecord, + setExtensionHostPluginRecordDisabled, + setExtensionHostPluginRecordLifecycleState, + setExtensionHostPluginRecordError, +} from "./loader-state.js"; + +export function processExtensionHostPluginCandidate(params: { + candidate: PluginCandidate; + manifestRecord: PluginManifestRecord; + normalizedConfig: NormalizedPluginsConfig; + rootConfig: OpenClawConfig; + validateOnly: boolean; + logger: PluginLogger; + registry: PluginRegistry; + seenIds: Map; + selectedMemoryPluginId: string | null; + createApi: ( + record: PluginRecord, + options: { + config: OpenClawConfig; + pluginConfig?: Record; + hookPolicy?: { allowPromptInjection?: boolean }; + }, + ) => OpenClawPluginApi; + loadModule: (safeSource: string) => OpenClawPluginModule; +}): { selectedMemoryPluginId: string | null; memorySlotMatched: boolean } { + const { candidate, manifestRecord } = params; + const activationPolicy = resolveExtensionHostActivationPolicy({ + candidate, + manifestRecord, + normalizedConfig: params.normalizedConfig, + rootConfig: params.rootConfig, + seenIds: params.seenIds, + selectedMemoryPluginId: params.selectedMemoryPluginId, + }); + if (activationPolicy.kind === "duplicate") { + appendExtensionHostPluginRecord({ + registry: params.registry, + record: activationPolicy.record, + }); + return { + selectedMemoryPluginId: params.selectedMemoryPluginId, + memorySlotMatched: false, + }; + } + + const { pluginId, record, entry } = activationPolicy; + const pushPluginLoadError = (message: string) => { + setExtensionHostPluginRecordError(record, message); + appendExtensionHostPluginRecord({ + registry: params.registry, + record, + seenIds: params.seenIds, + pluginId, + origin: candidate.origin, + }); + params.registry.diagnostics.push({ + level: "error", + pluginId: record.id, + source: record.source, + message: record.error ?? message, + }); + }; + + if (activationPolicy.kind === "disabled") { + appendExtensionHostPluginRecord({ + registry: params.registry, + record, + seenIds: params.seenIds, + pluginId, + origin: candidate.origin, + }); + return { + selectedMemoryPluginId: params.selectedMemoryPluginId, + memorySlotMatched: false, + }; + } + + if (!manifestRecord.configSchema) { + pushPluginLoadError("missing config schema"); + return { + selectedMemoryPluginId: params.selectedMemoryPluginId, + memorySlotMatched: false, + }; + } + + const moduleImport = importExtensionHostPluginModule({ + rootDir: candidate.rootDir, + source: candidate.source, + origin: candidate.origin, + loadModule: params.loadModule, + }); + if (!moduleImport.ok) { + if (moduleImport.message !== "failed to load plugin") { + pushPluginLoadError(moduleImport.message); + return { + selectedMemoryPluginId: params.selectedMemoryPluginId, + memorySlotMatched: false, + }; + } + recordExtensionHostPluginError({ + logger: params.logger, + registry: params.registry, + record, + seenIds: params.seenIds, + pluginId, + origin: candidate.origin, + error: moduleImport.error, + logPrefix: `[plugins] ${record.id} failed to load from ${record.source}: `, + diagnosticMessagePrefix: "failed to load plugin: ", + }); + return { + selectedMemoryPluginId: params.selectedMemoryPluginId, + memorySlotMatched: false, + }; + } + + setExtensionHostPluginRecordLifecycleState(record, "imported"); + const resolved = resolveExtensionHostModuleExport(moduleImport.module); + const loadedPlan = planExtensionHostLoadedPlugin({ + record, + manifestRecord, + definition: resolved.definition, + register: resolved.register, + diagnostics: params.registry.diagnostics, + memorySlot: params.normalizedConfig.slots.memory, + selectedMemoryPluginId: params.selectedMemoryPluginId, + entryConfig: entry?.config, + validateOnly: params.validateOnly, + }); + + if (loadedPlan.kind === "error") { + pushPluginLoadError(loadedPlan.message); + return { + selectedMemoryPluginId: loadedPlan.selectedMemoryPluginId, + memorySlotMatched: loadedPlan.memorySlotMatched, + }; + } + + if (loadedPlan.kind === "disabled") { + setExtensionHostPluginRecordDisabled(record, loadedPlan.reason); + appendExtensionHostPluginRecord({ + registry: params.registry, + record, + seenIds: params.seenIds, + pluginId, + origin: candidate.origin, + }); + return { + selectedMemoryPluginId: loadedPlan.selectedMemoryPluginId, + memorySlotMatched: loadedPlan.memorySlotMatched, + }; + } + + if (loadedPlan.kind === "invalid-config") { + params.logger.error(`[plugins] ${record.id} ${loadedPlan.message}`); + pushPluginLoadError(loadedPlan.message); + return { + selectedMemoryPluginId: loadedPlan.selectedMemoryPluginId, + memorySlotMatched: loadedPlan.memorySlotMatched, + }; + } + + setExtensionHostPluginRecordLifecycleState(record, "validated"); + if (loadedPlan.kind === "validate-only") { + appendExtensionHostPluginRecord({ + registry: params.registry, + record, + seenIds: params.seenIds, + pluginId, + origin: candidate.origin, + }); + return { + selectedMemoryPluginId: loadedPlan.selectedMemoryPluginId, + memorySlotMatched: loadedPlan.memorySlotMatched, + }; + } + + if (loadedPlan.kind === "missing-register") { + params.logger.error(`[plugins] ${record.id} missing register/activate export`); + pushPluginLoadError(loadedPlan.message); + return { + selectedMemoryPluginId: loadedPlan.selectedMemoryPluginId, + memorySlotMatched: loadedPlan.memorySlotMatched, + }; + } + + const registerResult = runExtensionHostPluginRegister({ + register: loadedPlan.register, + createApi: params.createApi, + record, + config: params.rootConfig, + pluginConfig: loadedPlan.pluginConfig, + hookPolicy: entry?.hooks, + diagnostics: params.registry.diagnostics, + }); + if (!registerResult.ok) { + recordExtensionHostPluginError({ + logger: params.logger, + registry: params.registry, + record, + seenIds: params.seenIds, + pluginId, + origin: candidate.origin, + error: registerResult.error, + logPrefix: `[plugins] ${record.id} failed during register from ${record.source}: `, + diagnosticMessagePrefix: "plugin failed during register: ", + }); + return { + selectedMemoryPluginId: loadedPlan.selectedMemoryPluginId, + memorySlotMatched: loadedPlan.memorySlotMatched, + }; + } + + setExtensionHostPluginRecordLifecycleState(record, "registered"); + appendExtensionHostPluginRecord({ + registry: params.registry, + record, + seenIds: params.seenIds, + pluginId, + origin: candidate.origin, + }); + return { + selectedMemoryPluginId: loadedPlan.selectedMemoryPluginId, + memorySlotMatched: loadedPlan.memorySlotMatched, + }; +} diff --git a/src/extension-host/activation/loader-host-state.test.ts b/src/extension-host/activation/loader-host-state.test.ts new file mode 100644 index 00000000000..ed85a65af40 --- /dev/null +++ b/src/extension-host/activation/loader-host-state.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { + clearExtensionHostLoaderHostState, + getExtensionHostDiscoveryWarningCache, +} from "./loader-host-state.js"; + +describe("extension host loader host state", () => { + it("clears the shared discovery warning cache", () => { + const warningCache = getExtensionHostDiscoveryWarningCache(); + warningCache.add("warn-key"); + + clearExtensionHostLoaderHostState(); + + expect(getExtensionHostDiscoveryWarningCache().size).toBe(0); + }); +}); diff --git a/src/extension-host/activation/loader-host-state.ts b/src/extension-host/activation/loader-host-state.ts new file mode 100644 index 00000000000..f5a2d543f8c --- /dev/null +++ b/src/extension-host/activation/loader-host-state.ts @@ -0,0 +1,12 @@ +import { clearExtensionHostRegistryCache } from "./loader-cache.js"; + +const extensionHostDiscoveryWarningCache = new Set(); + +export function getExtensionHostDiscoveryWarningCache(): Set { + return extensionHostDiscoveryWarningCache; +} + +export function clearExtensionHostLoaderHostState(): void { + clearExtensionHostRegistryCache(); + extensionHostDiscoveryWarningCache.clear(); +} diff --git a/src/extension-host/activation/loader-import.test.ts b/src/extension-host/activation/loader-import.test.ts new file mode 100644 index 00000000000..1035bcf3958 --- /dev/null +++ b/src/extension-host/activation/loader-import.test.ts @@ -0,0 +1,88 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { importExtensionHostPluginModule } from "./loader-import.js"; + +const tempDirs: string[] = []; + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } +}); + +function createTempPluginFixture() { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-loader-import-")); + tempDirs.push(rootDir); + const entryPath = path.join(rootDir, "index.js"); + fs.writeFileSync(entryPath, "export default {}"); + return { rootDir, entryPath }; +} + +describe("extension host loader import", () => { + it("loads modules through a boundary-checked safe source path", () => { + const { rootDir, entryPath } = createTempPluginFixture(); + const resolvedEntryPath = fs.realpathSync(entryPath); + + const result = importExtensionHostPluginModule({ + rootDir, + source: entryPath, + origin: "workspace", + loadModule: (safeSource) => ({ safeSource }), + }); + + expect(result).toMatchObject({ + ok: true, + module: { + safeSource: resolvedEntryPath, + }, + safeSource: resolvedEntryPath, + }); + }); + + it("rejects entry paths outside the plugin root", () => { + const { rootDir } = createTempPluginFixture(); + const outsidePath = path.join(os.tmpdir(), `outside-${Date.now()}.js`); + fs.writeFileSync(outsidePath, "export default {}"); + + const result = importExtensionHostPluginModule({ + rootDir, + source: outsidePath, + origin: "workspace", + loadModule: () => { + throw new Error("should not run"); + }, + }); + + fs.rmSync(outsidePath, { force: true }); + + expect(result).toEqual({ + ok: false, + message: "plugin entry path escapes plugin root or fails alias checks", + }); + }); + + it("returns load failures without throwing", () => { + const { rootDir, entryPath } = createTempPluginFixture(); + const error = new Error("boom"); + + const result = importExtensionHostPluginModule({ + rootDir, + source: entryPath, + origin: "workspace", + loadModule: () => { + throw error; + }, + }); + + expect(result).toEqual({ + ok: false, + message: "failed to load plugin", + error, + }); + }); +}); diff --git a/src/extension-host/activation/loader-import.ts b/src/extension-host/activation/loader-import.ts new file mode 100644 index 00000000000..629f8387f24 --- /dev/null +++ b/src/extension-host/activation/loader-import.ts @@ -0,0 +1,60 @@ +import fs from "node:fs"; +import path from "node:path"; +import { openBoundaryFileSync } from "../../infra/boundary-file-read.js"; +import type { PluginRecord } from "../../plugins/registry.js"; + +export function importExtensionHostPluginModule(params: { + rootDir: string; + source: string; + origin: PluginRecord["origin"]; + loadModule: (safeSource: string) => unknown; +}): + | { + ok: true; + module: unknown; + safeSource: string; + } + | { + ok: false; + message: string; + error?: unknown; + } { + const pluginRoot = safeRealpathOrResolve(params.rootDir); + const opened = openBoundaryFileSync({ + absolutePath: params.source, + rootPath: pluginRoot, + boundaryLabel: "plugin root", + rejectHardlinks: params.origin !== "bundled", + skipLexicalRootCheck: true, + }); + if (!opened.ok) { + return { + ok: false, + message: "plugin entry path escapes plugin root or fails alias checks", + }; + } + + const safeSource = opened.path; + fs.closeSync(opened.fd); + try { + return { + ok: true, + module: params.loadModule(safeSource), + safeSource, + }; + } catch (error) { + return { + ok: false, + message: "failed to load plugin", + error, + }; + } +} + +function safeRealpathOrResolve(value: string): string { + try { + return fs.realpathSync(value); + } catch { + return path.resolve(value); + } +} diff --git a/src/extension-host/activation/loader-module-loader.test.ts b/src/extension-host/activation/loader-module-loader.test.ts new file mode 100644 index 00000000000..8ef358cf4c2 --- /dev/null +++ b/src/extension-host/activation/loader-module-loader.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { createExtensionHostModuleLoader } from "./loader-module-loader.js"; + +describe("extension host module loader", () => { + it("creates the jiti loader lazily and reuses it", () => { + let createCount = 0; + const loadedSources: string[] = []; + + const loadModule = createExtensionHostModuleLoader({ + importMetaUrl: "file:///test-loader.ts", + createJitiLoader: (_url, options) => { + createCount += 1; + expect(options.alias).toEqual({ + "openclaw/plugin-sdk": "/sdk/index.ts", + "openclaw/plugin-sdk/telegram": "/sdk/telegram.ts", + }); + return ((safeSource: string) => { + loadedSources.push(safeSource); + return { safeSource }; + }) as never; + }, + resolvePluginSdkAliasFn: () => "/sdk/index.ts", + resolvePluginSdkScopedAliasMapFn: () => ({ + "openclaw/plugin-sdk/telegram": "/sdk/telegram.ts", + }), + }); + + expect(createCount).toBe(0); + expect(loadModule("/plugins/one.ts")).toEqual({ safeSource: "/plugins/one.ts" }); + expect(loadModule("/plugins/two.ts")).toEqual({ safeSource: "/plugins/two.ts" }); + expect(createCount).toBe(1); + expect(loadedSources).toEqual(["/plugins/one.ts", "/plugins/two.ts"]); + }); + + it("omits alias config when no aliases resolve", () => { + const loadModule = createExtensionHostModuleLoader({ + importMetaUrl: "file:///test-loader.ts", + createJitiLoader: (_url, options) => { + expect(options.alias).toBeUndefined(); + return ((safeSource: string) => ({ safeSource })) as never; + }, + resolvePluginSdkAliasFn: () => null, + resolvePluginSdkScopedAliasMapFn: () => ({}), + }); + + expect(loadModule("/plugins/demo.ts")).toEqual({ safeSource: "/plugins/demo.ts" }); + }); +}); diff --git a/src/extension-host/activation/loader-module-loader.ts b/src/extension-host/activation/loader-module-loader.ts new file mode 100644 index 00000000000..9aa834a1378 --- /dev/null +++ b/src/extension-host/activation/loader-module-loader.ts @@ -0,0 +1,44 @@ +import { createJiti } from "jiti"; +import type { OpenClawPluginModule } from "../../plugins/types.js"; +import { resolvePluginSdkAlias, resolvePluginSdkScopedAliasMap } from "../compat/loader-compat.js"; + +type JitiLoaderFactory = typeof createJiti; +type JitiLoader = ReturnType; + +export function createExtensionHostModuleLoader( + params: { + createJitiLoader?: JitiLoaderFactory; + importMetaUrl?: string; + resolvePluginSdkAliasFn?: typeof resolvePluginSdkAlias; + resolvePluginSdkScopedAliasMapFn?: typeof resolvePluginSdkScopedAliasMap; + } = {}, +): (safeSource: string) => OpenClawPluginModule { + const createJitiLoader = params.createJitiLoader ?? createJiti; + const importMetaUrl = params.importMetaUrl ?? import.meta.url; + const resolvePluginSdkAliasFn = params.resolvePluginSdkAliasFn ?? resolvePluginSdkAlias; + const resolvePluginSdkScopedAliasMapFn = + params.resolvePluginSdkScopedAliasMapFn ?? resolvePluginSdkScopedAliasMap; + + let jitiLoader: JitiLoader | null = null; + + const getJiti = (): JitiLoader => { + if (jitiLoader) { + return jitiLoader; + } + const pluginSdkAlias = resolvePluginSdkAliasFn(); + const aliasMap = { + ...(pluginSdkAlias ? { "openclaw/plugin-sdk": pluginSdkAlias } : {}), + ...resolvePluginSdkScopedAliasMapFn(), + }; + jitiLoader = createJitiLoader(importMetaUrl, { + interopDefault: true, + extensions: [".ts", ".tsx", ".mts", ".cts", ".mtsx", ".ctsx", ".js", ".mjs", ".cjs", ".json"], + ...(Object.keys(aliasMap).length > 0 ? { alias: aliasMap } : {}), + }); + return jitiLoader; + }; + + return (safeSource: string): OpenClawPluginModule => { + return getJiti()(safeSource) as OpenClawPluginModule; + }; +} diff --git a/src/extension-host/activation/loader-orchestrator.ts b/src/extension-host/activation/loader-orchestrator.ts new file mode 100644 index 00000000000..a45f5988ee6 --- /dev/null +++ b/src/extension-host/activation/loader-orchestrator.ts @@ -0,0 +1,58 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import { createSubsystemLogger } from "../../logging/subsystem.js"; +import type { PluginRegistry } from "../../plugins/registry.js"; +import { + createPluginRuntime, + type CreatePluginRuntimeOptions, +} from "../../plugins/runtime/index.js"; +import type { PluginLogger } from "../../plugins/types.js"; +import { clearExtensionHostPluginCommands } from "../contributions/command-runtime.js"; +import { + clearExtensionHostLoaderHostState, + getExtensionHostDiscoveryWarningCache, +} from "./loader-host-state.js"; +import { executeExtensionHostLoaderPipeline } from "./loader-pipeline.js"; +import { prepareExtensionHostLoaderPreflight } from "./loader-preflight.js"; + +export type ExtensionHostPluginLoadOptions = { + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + logger?: PluginLogger; + coreGatewayHandlers?: Record< + string, + import("../../gateway/server-methods/types.js").GatewayRequestHandler + >; + runtimeOptions?: CreatePluginRuntimeOptions; + cache?: boolean; + mode?: "full" | "validate"; +}; + +const defaultLogger = () => createSubsystemLogger("plugins"); + +export function clearExtensionHostLoaderState(): void { + clearExtensionHostLoaderHostState(); +} + +export function loadExtensionHostPluginRegistry( + options: ExtensionHostPluginLoadOptions = {}, +): PluginRegistry { + const preflight = prepareExtensionHostLoaderPreflight({ + options, + createDefaultLogger: defaultLogger, + clearPluginCommands: clearExtensionHostPluginCommands, + }); + if (preflight.cacheHit) { + return preflight.registry; + } + + return executeExtensionHostLoaderPipeline({ + preflight, + workspaceDir: options.workspaceDir, + cache: options.cache, + coreGatewayHandlers: options.coreGatewayHandlers, + runtimeOptions: options.runtimeOptions, + warningCache: getExtensionHostDiscoveryWarningCache(), + createRuntime: createPluginRuntime, + }); +} diff --git a/src/extension-host/activation/loader-pipeline.test.ts b/src/extension-host/activation/loader-pipeline.test.ts new file mode 100644 index 00000000000..3c80eddf0b4 --- /dev/null +++ b/src/extension-host/activation/loader-pipeline.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from "vitest"; +import { executeExtensionHostLoaderPipeline } from "./loader-pipeline.js"; + +describe("extension host loader pipeline", () => { + it("threads preflight data through execution setup and session run", () => { + const session = {} as never; + const createApi = vi.fn() as never; + const loadModule = vi.fn() as never; + const registry = { plugins: [] } as never; + const resultRegistry = { plugins: [{ id: "demo" }] } as never; + + const result = executeExtensionHostLoaderPipeline({ + preflight: { + cacheHit: false, + env: { TEST: "1" }, + config: { plugins: { enabled: true } }, + logger: { info() {}, warn() {}, error() {} }, + validateOnly: true, + normalizedConfig: { + enabled: true, + allow: [], + loadPaths: [], + entries: {}, + slots: {}, + }, + cacheKey: "cache-key", + }, + workspaceDir: "/workspace", + cache: false, + coreGatewayHandlers: { ping: vi.fn() as never }, + warningCache: new Set(), + createRuntime: vi.fn(() => ({}) as never) as never, + prepareExecution: vi.fn(() => ({ + registry, + createApi, + loadModule, + session, + orderedCandidates: [{ rootDir: "/plugins/a" }], + manifestByRoot: new Map([["/plugins/a", { rootDir: "/plugins/a" }]]), + })) as never, + runSession: vi.fn(() => resultRegistry) as never, + }); + + expect(result).toBe(resultRegistry); + }); +}); diff --git a/src/extension-host/activation/loader-pipeline.ts b/src/extension-host/activation/loader-pipeline.ts new file mode 100644 index 00000000000..57a963b9029 --- /dev/null +++ b/src/extension-host/activation/loader-pipeline.ts @@ -0,0 +1,51 @@ +import type { GatewayRequestHandler } from "../../gateway/server-methods/types.js"; +import type { PluginRegistry } from "../../plugins/registry.js"; +import type { CreatePluginRuntimeOptions } from "../../plugins/runtime/index.js"; +import type { PluginRuntime } from "../../plugins/runtime/types.js"; +import { activateExtensionHostRegistry } from "../activation.js"; +import { setCachedExtensionHostRegistry } from "./loader-cache.js"; +import { prepareExtensionHostLoaderExecution } from "./loader-execution.js"; +import type { ExtensionHostLoaderPreflightReady } from "./loader-preflight.js"; +import { runExtensionHostLoaderSession } from "./loader-run.js"; + +export function executeExtensionHostLoaderPipeline(params: { + preflight: ExtensionHostLoaderPreflightReady; + workspaceDir?: string; + cache?: boolean; + coreGatewayHandlers?: Record; + runtimeOptions?: CreatePluginRuntimeOptions; + warningCache: Set; + createRuntime: (runtimeOptions?: CreatePluginRuntimeOptions) => PluginRuntime; + prepareExecution?: typeof prepareExtensionHostLoaderExecution; + runSession?: typeof runExtensionHostLoaderSession; +}): PluginRegistry { + const prepareExecution = params.prepareExecution ?? prepareExtensionHostLoaderExecution; + const runSession = params.runSession ?? runExtensionHostLoaderSession; + + const execution = prepareExecution({ + config: params.preflight.config, + workspaceDir: params.workspaceDir, + env: params.preflight.env, + cache: params.cache, + cacheKey: params.preflight.cacheKey, + normalizedConfig: params.preflight.normalizedConfig, + logger: params.preflight.logger, + coreGatewayHandlers: params.coreGatewayHandlers as Record, + runtimeOptions: params.runtimeOptions, + warningCache: params.warningCache, + setCachedRegistry: setCachedExtensionHostRegistry, + activateRegistry: activateExtensionHostRegistry, + createRuntime: params.createRuntime, + }); + + return runSession({ + session: execution.session, + orderedCandidates: execution.orderedCandidates, + manifestByRoot: execution.manifestByRoot, + normalizedConfig: params.preflight.normalizedConfig, + rootConfig: params.preflight.config, + validateOnly: params.preflight.validateOnly, + createApi: execution.createApi, + loadModule: execution.loadModule, + }); +} diff --git a/src/extension-host/activation/loader-preflight.test.ts b/src/extension-host/activation/loader-preflight.test.ts new file mode 100644 index 00000000000..99f2d56905d --- /dev/null +++ b/src/extension-host/activation/loader-preflight.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from "vitest"; +import { prepareExtensionHostLoaderPreflight } from "./loader-preflight.js"; + +describe("extension host loader preflight", () => { + it("returns a cache hit without clearing commands", () => { + const registry = { plugins: [] } as never; + const clearPluginCommands = vi.fn(); + const activateRegistry = vi.fn(); + + const result = prepareExtensionHostLoaderPreflight({ + options: { + env: { TEST: "1" }, + }, + createDefaultLogger: vi.fn(() => ({ info() {}, warn() {}, error() {} })) as never, + clearPluginCommands, + applyTestDefaults: vi.fn((config) => config) as never, + normalizeConfig: vi.fn(() => ({ installs: [], entries: {}, slots: {} })) as never, + buildCacheKey: vi.fn(() => "cache-key") as never, + getCachedRegistry: vi.fn(() => registry) as never, + activateRegistry: activateRegistry as never, + }); + + expect(result).toEqual({ + cacheHit: true, + registry, + }); + expect(activateRegistry).toHaveBeenCalledWith(registry, "cache-key"); + expect(clearPluginCommands).not.toHaveBeenCalled(); + }); + + it("normalizes inputs and clears commands on a cache miss", () => { + const clearPluginCommands = vi.fn(); + const logger = { info() {}, warn() {}, error() {} }; + + const result = prepareExtensionHostLoaderPreflight({ + options: { + config: { plugins: { enabled: true } }, + workspaceDir: "/workspace", + env: { TEST: "1" }, + mode: "validate", + }, + createDefaultLogger: vi.fn(() => logger) as never, + clearPluginCommands, + applyTestDefaults: vi.fn((config) => ({ + ...config, + plugins: { ...config.plugins, allow: ["demo"] }, + })) as never, + normalizeConfig: vi.fn(() => ({ + enabled: true, + allow: ["demo"], + loadPaths: [], + entries: {}, + slots: {}, + })) as never, + buildCacheKey: vi.fn(() => "cache-key") as never, + getCachedRegistry: vi.fn(() => null) as never, + activateRegistry: vi.fn() as never, + }); + + expect(result).toMatchObject({ + cacheHit: false, + env: { TEST: "1" }, + logger, + validateOnly: true, + cacheKey: "cache-key", + normalizedConfig: { + allow: ["demo"], + }, + }); + expect(clearPluginCommands).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/extension-host/activation/loader-preflight.ts b/src/extension-host/activation/loader-preflight.ts new file mode 100644 index 00000000000..6940100c571 --- /dev/null +++ b/src/extension-host/activation/loader-preflight.ts @@ -0,0 +1,96 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import { applyTestPluginDefaults, normalizePluginsConfig } from "../../plugins/config-state.js"; +import type { PluginLogger } from "../../plugins/types.js"; +import { activateExtensionHostRegistry } from "../activation.js"; +import { + buildExtensionHostRegistryCacheKey, + getCachedExtensionHostRegistry, +} from "./loader-cache.js"; + +export type ExtensionHostPluginLoadMode = "full" | "validate"; + +export type ExtensionHostLoaderPreflightOptions = { + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + logger?: PluginLogger; + cache?: boolean; + mode?: ExtensionHostPluginLoadMode; +}; + +export type ExtensionHostLoaderPreflightCacheHit = { + cacheHit: true; + registry: ReturnType extends infer T + ? Exclude + : never; +}; + +export type ExtensionHostLoaderPreflightReady = { + cacheHit: false; + env: NodeJS.ProcessEnv; + config: OpenClawConfig; + logger: PluginLogger; + validateOnly: boolean; + normalizedConfig: ReturnType; + cacheKey: string; +}; + +export type ExtensionHostLoaderPreflightResult = + | ExtensionHostLoaderPreflightCacheHit + | ExtensionHostLoaderPreflightReady; + +export function prepareExtensionHostLoaderPreflight(params: { + options: ExtensionHostLoaderPreflightOptions; + createDefaultLogger: () => PluginLogger; + clearPluginCommands: () => void; + applyTestDefaults?: typeof applyTestPluginDefaults; + normalizeConfig?: typeof normalizePluginsConfig; + buildCacheKey?: typeof buildExtensionHostRegistryCacheKey; + getCachedRegistry?: typeof getCachedExtensionHostRegistry; + activateRegistry?: typeof activateExtensionHostRegistry; +}): ExtensionHostLoaderPreflightResult { + const applyTestDefaults = params.applyTestDefaults ?? applyTestPluginDefaults; + const normalizeConfig = params.normalizeConfig ?? normalizePluginsConfig; + const buildCacheKey = params.buildCacheKey ?? buildExtensionHostRegistryCacheKey; + const getCachedRegistry = params.getCachedRegistry ?? getCachedExtensionHostRegistry; + const activateRegistry = params.activateRegistry ?? activateExtensionHostRegistry; + + const env = params.options.env ?? process.env; + // Test env: default-disable plugins unless explicitly configured. + // This keeps unit/gateway suites fast and avoids loading heavyweight plugin deps by accident. + const config = applyTestDefaults(params.options.config ?? {}, env); + const logger = params.options.logger ?? params.createDefaultLogger(); + const validateOnly = params.options.mode === "validate"; + const normalizedConfig = normalizeConfig(config.plugins); + const cacheKey = buildCacheKey({ + workspaceDir: params.options.workspaceDir, + plugins: normalizedConfig, + installs: config.plugins?.installs, + env, + }); + const cacheEnabled = params.options.cache !== false; + + if (cacheEnabled) { + const cachedRegistry = getCachedRegistry(cacheKey); + if (cachedRegistry) { + activateRegistry(cachedRegistry, cacheKey); + return { + cacheHit: true as const, + registry: cachedRegistry, + }; + } + } + + // Clear previously registered plugin commands before reloading. + params.clearPluginCommands(); + + return { + cacheHit: false as const, + env, + config, + logger, + validateOnly, + normalizedConfig, + cacheKey, + }; +} diff --git a/src/extension-host/activation/loader-records.test.ts b/src/extension-host/activation/loader-records.test.ts new file mode 100644 index 00000000000..3b2e4d8649e --- /dev/null +++ b/src/extension-host/activation/loader-records.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import { normalizePluginsConfig } from "../../plugins/config-state.js"; +import type { PluginCandidate } from "../../plugins/discovery.js"; +import type { PluginManifestRecord } from "../../plugins/manifest-registry.js"; +import { prepareExtensionHostPluginCandidate } from "./loader-records.js"; + +function createCandidate(overrides: Partial = {}): PluginCandidate { + return { + source: "/plugins/demo/index.ts", + rootDir: "/plugins/demo", + packageDir: "/plugins/demo", + origin: "workspace", + workspaceDir: "/workspace", + ...overrides, + }; +} + +function createManifestRecord(overrides: Partial = {}): PluginManifestRecord { + return { + id: "demo", + name: "Demo", + description: "Demo plugin", + version: "1.0.0", + kind: "tool", + channels: [], + providers: [], + skills: [], + origin: "workspace", + workspaceDir: "/workspace", + rootDir: "/plugins/demo", + source: "/plugins/demo/index.ts", + manifestPath: "/plugins/demo/openclaw.plugin.json", + configSchema: { + type: "object", + properties: { + enabled: { type: "boolean" }, + }, + }, + configUiHints: { + enabled: { sensitive: false }, + }, + resolvedExtension: { + id: "demo", + source: "/plugins/demo/index.ts", + origin: "workspace", + rootDir: "/plugins/demo", + workspaceDir: "/workspace", + static: { + package: {}, + config: {}, + setup: {}, + }, + runtime: { + kind: "tool", + contributions: [], + }, + policy: {}, + }, + ...overrides, + }; +} + +describe("extension host loader records", () => { + it("prepares duplicate candidates as disabled compatibility records", () => { + const seenIds = new Map([ + ["demo", "bundled"], + ]); + + const prepared = prepareExtensionHostPluginCandidate({ + candidate: createCandidate(), + manifestRecord: createManifestRecord(), + normalizedConfig: normalizePluginsConfig({}), + rootConfig: {}, + seenIds, + }); + + expect(prepared).toMatchObject({ + kind: "duplicate", + pluginId: "demo", + record: { + enabled: false, + status: "disabled", + error: "overridden by bundled plugin", + }, + }); + }); + + it("prepares candidate records with manifest metadata and config entry", () => { + const rootConfig: OpenClawConfig = { + plugins: { + entries: { + demo: { + enabled: true, + config: { enabled: true }, + }, + }, + }, + }; + + const prepared = prepareExtensionHostPluginCandidate({ + candidate: createCandidate({ origin: "bundled" }), + manifestRecord: createManifestRecord({ origin: "bundled" }), + normalizedConfig: normalizePluginsConfig(rootConfig.plugins), + rootConfig, + seenIds: new Map(), + }); + + expect(prepared).toMatchObject({ + kind: "candidate", + pluginId: "demo", + entry: { + enabled: true, + config: { enabled: true }, + }, + enableState: { + enabled: true, + }, + record: { + id: "demo", + name: "Demo", + kind: "tool", + configJsonSchema: { + type: "object", + }, + }, + }); + }); + + it("preserves disabled-by-config decisions in the prepared record", () => { + const rootConfig: OpenClawConfig = { + plugins: { + entries: { + demo: { + enabled: false, + }, + }, + }, + }; + + const prepared = prepareExtensionHostPluginCandidate({ + candidate: createCandidate({ origin: "bundled" }), + manifestRecord: createManifestRecord({ origin: "bundled" }), + normalizedConfig: normalizePluginsConfig(rootConfig.plugins), + rootConfig, + seenIds: new Map(), + }); + + expect(prepared).toMatchObject({ + kind: "candidate", + enableState: { + enabled: false, + reason: "disabled in config", + }, + record: { + enabled: false, + status: "disabled", + }, + }); + }); +}); diff --git a/src/extension-host/activation/loader-records.ts b/src/extension-host/activation/loader-records.ts new file mode 100644 index 00000000000..063a8285c3d --- /dev/null +++ b/src/extension-host/activation/loader-records.ts @@ -0,0 +1,93 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import { + resolveEffectiveEnableState, + type NormalizedPluginsConfig, +} from "../../plugins/config-state.js"; +import type { PluginCandidate } from "../../plugins/discovery.js"; +import type { PluginManifestRecord } from "../../plugins/manifest-registry.js"; +import type { PluginRecord } from "../../plugins/registry.js"; +import { createExtensionHostPluginRecord } from "../policy/loader-policy.js"; +import { setExtensionHostPluginRecordDisabled } from "./loader-state.js"; + +type CandidateEntry = NormalizedPluginsConfig["entries"][string]; + +export type ExtensionHostPreparedPluginCandidate = + | { + kind: "duplicate"; + pluginId: string; + record: PluginRecord; + } + | { + kind: "candidate"; + pluginId: string; + record: PluginRecord; + entry: CandidateEntry | undefined; + enableState: { enabled: boolean; reason?: string }; + }; + +export function prepareExtensionHostPluginCandidate(params: { + candidate: PluginCandidate; + manifestRecord: PluginManifestRecord; + normalizedConfig: NormalizedPluginsConfig; + rootConfig: OpenClawConfig; + seenIds: Map; +}): ExtensionHostPreparedPluginCandidate { + const pluginId = params.manifestRecord.id; + const existingOrigin = params.seenIds.get(pluginId); + if (existingOrigin) { + const record = createBasePluginRecord({ + candidate: params.candidate, + manifestRecord: params.manifestRecord, + enabled: false, + }); + setExtensionHostPluginRecordDisabled(record, `overridden by ${existingOrigin} plugin`); + return { + kind: "duplicate", + pluginId, + record, + }; + } + + const enableState = resolveEffectiveEnableState({ + id: pluginId, + origin: params.candidate.origin, + config: params.normalizedConfig, + rootConfig: params.rootConfig, + }); + const entry = params.normalizedConfig.entries[pluginId]; + const record = createBasePluginRecord({ + candidate: params.candidate, + manifestRecord: params.manifestRecord, + enabled: enableState.enabled, + }); + return { + kind: "candidate", + pluginId, + record, + entry, + enableState, + }; +} + +function createBasePluginRecord(params: { + candidate: PluginCandidate; + manifestRecord: PluginManifestRecord; + enabled: boolean; +}): PluginRecord { + const pluginId = params.manifestRecord.id; + const record = createExtensionHostPluginRecord({ + id: pluginId, + name: params.manifestRecord.name ?? pluginId, + description: params.manifestRecord.description, + version: params.manifestRecord.version, + source: params.candidate.source, + origin: params.candidate.origin, + workspaceDir: params.candidate.workspaceDir, + enabled: params.enabled, + configSchema: Boolean(params.manifestRecord.configSchema), + }); + record.kind = params.manifestRecord.kind; + record.configUiHints = params.manifestRecord.configUiHints; + record.configJsonSchema = params.manifestRecord.configSchema; + return record; +} diff --git a/src/extension-host/activation/loader-register.test.ts b/src/extension-host/activation/loader-register.test.ts new file mode 100644 index 00000000000..a02798b2afb --- /dev/null +++ b/src/extension-host/activation/loader-register.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "vitest"; +import type { PluginDiagnostic } from "../../plugins/types.js"; +import { createExtensionHostPluginRecord } from "../policy/loader-policy.js"; +import { + planExtensionHostLoadedPlugin, + runExtensionHostPluginRegister, +} from "./loader-register.js"; + +describe("extension host loader register", () => { + it("returns a register plan for valid loaded plugins", () => { + const record = createExtensionHostPluginRecord({ + id: "demo", + source: "/plugins/demo.js", + origin: "workspace", + enabled: true, + configSchema: true, + }); + const diagnostics: PluginDiagnostic[] = []; + + const plan = planExtensionHostLoadedPlugin({ + record, + manifestRecord: { + configSchema: { + type: "object", + properties: { + enabled: { type: "boolean" }, + }, + additionalProperties: false, + }, + }, + definition: { + id: "demo", + }, + register: () => {}, + diagnostics, + selectedMemoryPluginId: null, + entryConfig: { enabled: true }, + validateOnly: false, + }); + + expect(plan).toMatchObject({ + kind: "register", + pluginConfig: { enabled: true }, + selectedMemoryPluginId: null, + }); + }); + + it("returns invalid-config plans with the normalized message", () => { + const record = createExtensionHostPluginRecord({ + id: "demo", + source: "/plugins/demo.js", + origin: "workspace", + enabled: true, + configSchema: true, + }); + + const plan = planExtensionHostLoadedPlugin({ + record, + manifestRecord: { + configSchema: { + type: "object", + properties: { + enabled: { type: "boolean" }, + }, + additionalProperties: false, + }, + }, + diagnostics: [], + selectedMemoryPluginId: null, + entryConfig: { nope: true }, + validateOnly: false, + }); + + expect(plan.kind).toBe("invalid-config"); + expect(plan.message).toContain("invalid config:"); + }); + + it("returns missing-register plans when validation passes but no register function exists", () => { + const record = createExtensionHostPluginRecord({ + id: "demo", + source: "/plugins/demo.js", + origin: "workspace", + enabled: true, + configSchema: true, + }); + + expect( + planExtensionHostLoadedPlugin({ + record, + manifestRecord: { + configSchema: { + type: "object", + }, + }, + diagnostics: [], + selectedMemoryPluginId: null, + validateOnly: false, + }), + ).toMatchObject({ + kind: "missing-register", + message: "plugin export missing register/activate", + }); + }); + + it("runs register through the provided api factory and records async warnings", () => { + const record = createExtensionHostPluginRecord({ + id: "demo", + source: "/plugins/demo.js", + origin: "workspace", + enabled: true, + configSchema: true, + }); + const diagnostics: PluginDiagnostic[] = []; + let apiSeen = false; + + const result = runExtensionHostPluginRegister({ + register: async (api) => { + apiSeen = api.id === "demo"; + }, + createApi: (pluginRecord, options) => + ({ + id: pluginRecord.id, + name: pluginRecord.name, + source: pluginRecord.source, + config: options.config, + pluginConfig: options.pluginConfig, + }) as never, + record, + config: {}, + pluginConfig: { enabled: true }, + diagnostics, + }); + + expect(result).toEqual({ ok: true }); + expect(apiSeen).toBe(true); + expect(diagnostics).toContainEqual({ + level: "warn", + pluginId: "demo", + source: "/plugins/demo.js", + message: "plugin register returned a promise; async registration is ignored", + }); + }); +}); diff --git a/src/extension-host/activation/loader-register.ts b/src/extension-host/activation/loader-register.ts new file mode 100644 index 00000000000..049ab2ef960 --- /dev/null +++ b/src/extension-host/activation/loader-register.ts @@ -0,0 +1,186 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import type { PluginManifestRecord } from "../../plugins/manifest-registry.js"; +import type { PluginRecord } from "../../plugins/registry.js"; +import type { + OpenClawPluginApi, + OpenClawPluginDefinition, + PluginDiagnostic, +} from "../../plugins/types.js"; +import { + applyExtensionHostDefinitionToRecord, + resolveExtensionHostMemoryDecision, + validateExtensionHostConfig, +} from "./loader-runtime.js"; + +export type ExtensionHostLoadedPluginPlan = + | { + kind: "disabled"; + reason?: string; + memorySlotMatched: boolean; + selectedMemoryPluginId: string | null; + } + | { + kind: "invalid-config"; + message: string; + errors: string[]; + memorySlotMatched: boolean; + selectedMemoryPluginId: string | null; + } + | { + kind: "validate-only"; + memorySlotMatched: boolean; + selectedMemoryPluginId: string | null; + } + | { + kind: "missing-register"; + message: string; + memorySlotMatched: boolean; + selectedMemoryPluginId: string | null; + } + | { + kind: "register"; + register: NonNullable; + pluginConfig?: Record; + memorySlotMatched: boolean; + selectedMemoryPluginId: string | null; + } + | { + kind: "error"; + message: string; + memorySlotMatched: boolean; + selectedMemoryPluginId: string | null; + }; + +export function planExtensionHostLoadedPlugin(params: { + record: PluginRecord; + manifestRecord: Pick; + definition?: OpenClawPluginDefinition; + register?: OpenClawPluginDefinition["register"]; + diagnostics: PluginDiagnostic[]; + memorySlot?: string | null; + selectedMemoryPluginId: string | null; + entryConfig?: unknown; + validateOnly: boolean; +}): ExtensionHostLoadedPluginPlan { + const definitionResult = applyExtensionHostDefinitionToRecord({ + record: params.record, + definition: params.definition, + diagnostics: params.diagnostics, + }); + const memorySlotMatched = + params.record.kind === "memory" && params.memorySlot === params.record.id; + if (!definitionResult.ok) { + return { + kind: "error", + message: definitionResult.message, + memorySlotMatched, + selectedMemoryPluginId: params.selectedMemoryPluginId, + }; + } + + const memoryDecision = resolveExtensionHostMemoryDecision({ + recordId: params.record.id, + recordKind: params.record.kind, + memorySlot: params.memorySlot, + selectedMemoryPluginId: params.selectedMemoryPluginId, + }); + const nextSelectedMemoryPluginId = + memoryDecision.selected && params.record.kind === "memory" + ? params.record.id + : params.selectedMemoryPluginId; + + if (!memoryDecision.enabled) { + return { + kind: "disabled", + reason: memoryDecision.reason, + memorySlotMatched, + selectedMemoryPluginId: nextSelectedMemoryPluginId, + }; + } + + const validatedConfig = validateExtensionHostConfig({ + schema: params.manifestRecord.configSchema, + cacheKey: params.manifestRecord.schemaCacheKey, + value: params.entryConfig, + }); + if (!validatedConfig.ok) { + const errors = validatedConfig.errors ?? ["invalid config"]; + return { + kind: "invalid-config", + message: `invalid config: ${errors.join(", ")}`, + errors, + memorySlotMatched, + selectedMemoryPluginId: nextSelectedMemoryPluginId, + }; + } + + if (params.validateOnly) { + return { + kind: "validate-only", + memorySlotMatched, + selectedMemoryPluginId: nextSelectedMemoryPluginId, + }; + } + + if (typeof params.register !== "function") { + return { + kind: "missing-register", + message: "plugin export missing register/activate", + memorySlotMatched, + selectedMemoryPluginId: nextSelectedMemoryPluginId, + }; + } + + return { + kind: "register", + register: params.register, + pluginConfig: validatedConfig.value, + memorySlotMatched, + selectedMemoryPluginId: nextSelectedMemoryPluginId, + }; +} + +export function runExtensionHostPluginRegister(params: { + register: NonNullable; + createApi: ( + record: PluginRecord, + options: { + config: OpenClawConfig; + pluginConfig?: Record; + hookPolicy?: { allowPromptInjection?: boolean }; + }, + ) => OpenClawPluginApi; + record: PluginRecord; + config: OpenClawConfig; + pluginConfig?: Record; + hookPolicy?: { allowPromptInjection?: boolean }; + diagnostics: PluginDiagnostic[]; +}): + | { + ok: true; + } + | { + ok: false; + error: unknown; + } { + try { + const result = params.register( + params.createApi(params.record, { + config: params.config, + pluginConfig: params.pluginConfig, + hookPolicy: params.hookPolicy, + }), + ); + if (result && typeof result === "object" && "then" in result) { + params.diagnostics.push({ + level: "warn", + pluginId: params.record.id, + source: params.record.source, + message: "plugin register returned a promise; async registration is ignored", + }); + } + return { ok: true }; + } catch (error) { + return { ok: false, error }; + } +} diff --git a/src/extension-host/activation/loader-run.test.ts b/src/extension-host/activation/loader-run.test.ts new file mode 100644 index 00000000000..656ff501584 --- /dev/null +++ b/src/extension-host/activation/loader-run.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from "vitest"; +import type { PluginRegistry } from "../../plugins/registry.js"; +import { runExtensionHostLoaderSession } from "./loader-run.js"; + +vi.mock("./loader-session.js", () => ({ + processExtensionHostLoaderSessionCandidate: vi.fn(), + finalizeExtensionHostLoaderSession: vi.fn((session) => session.registry), +})); + +describe("extension host loader run", () => { + it("processes only candidates with manifest records and then finalizes", async () => { + const sessionModule = await import("./loader-session.js"); + const processCandidate = vi.mocked(sessionModule.processExtensionHostLoaderSessionCandidate); + const finalizeSession = vi.mocked(sessionModule.finalizeExtensionHostLoaderSession); + + const registry = { plugins: [], diagnostics: [] } as unknown as PluginRegistry; + const session = { + registry, + } as never; + + const result = runExtensionHostLoaderSession({ + session, + orderedCandidates: [{ rootDir: "/plugins/a" }, { rootDir: "/plugins/missing" }], + manifestByRoot: new Map([["/plugins/a", { rootDir: "/plugins/a" }]]), + normalizedConfig: { entries: {}, slots: {} }, + rootConfig: {}, + validateOnly: false, + createApi: vi.fn() as never, + loadModule: vi.fn() as never, + }); + + expect(processCandidate).toHaveBeenCalledTimes(1); + expect(finalizeSession).toHaveBeenCalledWith(session); + expect(result).toBe(registry); + }); +}); diff --git a/src/extension-host/activation/loader-run.ts b/src/extension-host/activation/loader-run.ts new file mode 100644 index 00000000000..14a51539eb5 --- /dev/null +++ b/src/extension-host/activation/loader-run.ts @@ -0,0 +1,48 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import type { NormalizedPluginsConfig } from "../../plugins/config-state.js"; +import type { PluginRecord } from "../../plugins/registry.js"; +import type { OpenClawPluginApi, OpenClawPluginModule } from "../../plugins/types.js"; +import type { ExtensionHostLoaderSession } from "./loader-session.js"; +import { + finalizeExtensionHostLoaderSession, + processExtensionHostLoaderSessionCandidate, +} from "./loader-session.js"; + +export function runExtensionHostLoaderSession(params: { + session: ExtensionHostLoaderSession; + orderedCandidates: Array<{ + rootDir: string; + }>; + manifestByRoot: Map; + normalizedConfig: NormalizedPluginsConfig; + rootConfig: OpenClawConfig; + validateOnly: boolean; + createApi: ( + record: PluginRecord, + options: { + config: OpenClawConfig; + pluginConfig?: Record; + hookPolicy?: { allowPromptInjection?: boolean }; + }, + ) => OpenClawPluginApi; + loadModule: (safeSource: string) => OpenClawPluginModule; +}) { + for (const candidate of params.orderedCandidates) { + const manifestRecord = params.manifestByRoot.get(candidate.rootDir); + if (!manifestRecord) { + continue; + } + processExtensionHostLoaderSessionCandidate({ + session: params.session, + candidate: candidate as never, + manifestRecord: manifestRecord as never, + normalizedConfig: params.normalizedConfig, + rootConfig: params.rootConfig, + validateOnly: params.validateOnly, + createApi: params.createApi, + loadModule: params.loadModule, + }); + } + + return finalizeExtensionHostLoaderSession(params.session); +} diff --git a/src/extension-host/activation/loader-runtime-proxy.test.ts b/src/extension-host/activation/loader-runtime-proxy.test.ts new file mode 100644 index 00000000000..6724205b16a --- /dev/null +++ b/src/extension-host/activation/loader-runtime-proxy.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { createExtensionHostLazyRuntime } from "./loader-runtime-proxy.js"; + +describe("extension host loader runtime proxy", () => { + it("creates the runtime lazily on first access", () => { + let createCount = 0; + const runtime = createExtensionHostLazyRuntime({ + createRuntime: () => { + createCount += 1; + return { value: 1 } as never; + }, + }); + + expect(createCount).toBe(0); + expect((runtime as never as { value: number }).value).toBe(1); + expect(createCount).toBe(1); + }); + + it("reuses the same runtime instance across proxy operations", () => { + let createCount = 0; + const runtime = createExtensionHostLazyRuntime({ + createRuntime: () => { + createCount += 1; + return { value: 1 } as never; + }, + }); + + expect("value" in (runtime as object)).toBe(true); + expect(Object.keys(runtime as object)).toEqual(["value"]); + expect((runtime as never as { value: number }).value).toBe(1); + expect(createCount).toBe(1); + }); +}); diff --git a/src/extension-host/activation/loader-runtime-proxy.ts b/src/extension-host/activation/loader-runtime-proxy.ts new file mode 100644 index 00000000000..7fd6a498dbb --- /dev/null +++ b/src/extension-host/activation/loader-runtime-proxy.ts @@ -0,0 +1,39 @@ +import type { PluginRuntime } from "../../plugins/runtime/types.js"; + +export function createExtensionHostLazyRuntime(params: { + runtimeOptions?: TOptions; + createRuntime: (runtimeOptions?: TOptions) => PluginRuntime; +}): PluginRuntime { + let resolvedRuntime: PluginRuntime | null = null; + const resolveRuntime = (): PluginRuntime => { + resolvedRuntime ??= params.createRuntime(params.runtimeOptions); + return resolvedRuntime; + }; + + return new Proxy({} as PluginRuntime, { + get(_target, prop, receiver) { + return Reflect.get(resolveRuntime(), prop, receiver); + }, + set(_target, prop, value, receiver) { + return Reflect.set(resolveRuntime(), prop, value, receiver); + }, + has(_target, prop) { + return Reflect.has(resolveRuntime(), prop); + }, + ownKeys() { + return Reflect.ownKeys(resolveRuntime() as object); + }, + getOwnPropertyDescriptor(_target, prop) { + return Reflect.getOwnPropertyDescriptor(resolveRuntime() as object, prop); + }, + defineProperty(_target, prop, attributes) { + return Reflect.defineProperty(resolveRuntime() as object, prop, attributes); + }, + deleteProperty(_target, prop) { + return Reflect.deleteProperty(resolveRuntime() as object, prop); + }, + getPrototypeOf() { + return Reflect.getPrototypeOf(resolveRuntime() as object); + }, + }); +} diff --git a/src/extension-host/activation/loader-runtime.test.ts b/src/extension-host/activation/loader-runtime.test.ts new file mode 100644 index 00000000000..6a07ee53e87 --- /dev/null +++ b/src/extension-host/activation/loader-runtime.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import { createExtensionHostPluginRecord } from "../policy/loader-policy.js"; +import { + applyExtensionHostDefinitionToRecord, + resolveExtensionHostEarlyMemoryDecision, + resolveExtensionHostMemoryDecision, + resolveExtensionHostModuleExport, + validateExtensionHostConfig, +} from "./loader-runtime.js"; + +describe("extension host loader runtime", () => { + it("resolves function exports as register handlers", () => { + const register = () => {}; + expect(resolveExtensionHostModuleExport(register)).toEqual({ + register, + }); + }); + + it("resolves object exports with default values", () => { + const register = () => {}; + const definition = { + id: "demo", + register, + }; + expect(resolveExtensionHostModuleExport({ default: definition })).toEqual({ + definition, + register, + }); + }); + + it("applies export metadata to plugin records", () => { + const record = createExtensionHostPluginRecord({ + id: "demo", + source: "/plugins/demo.js", + origin: "workspace", + enabled: true, + configSchema: true, + }); + record.kind = "memory"; + const diagnostics: Array<{ level: "warn" | "error"; message: string }> = []; + + const result = applyExtensionHostDefinitionToRecord({ + record, + definition: { + id: "demo", + name: "Demo Plugin", + description: "demo desc", + version: "1.2.3", + kind: "memory", + }, + diagnostics, + }); + + expect(result).toEqual({ ok: true }); + expect(record.name).toBe("Demo Plugin"); + expect(record.description).toBe("demo desc"); + expect(record.version).toBe("1.2.3"); + expect(diagnostics).toEqual([]); + }); + + it("rejects export id mismatches", () => { + const record = createExtensionHostPluginRecord({ + id: "demo", + source: "/plugins/demo.js", + origin: "workspace", + enabled: true, + configSchema: true, + }); + + expect( + applyExtensionHostDefinitionToRecord({ + record, + definition: { + id: "other", + }, + diagnostics: [], + }), + ).toEqual({ + ok: false, + message: 'plugin id mismatch (config uses "demo", export uses "other")', + }); + }); + + it("validates config through the host helper", () => { + expect( + validateExtensionHostConfig({ + schema: { + type: "object", + properties: { + enabled: { type: "boolean" }, + }, + additionalProperties: false, + }, + value: { enabled: true }, + }), + ).toMatchObject({ + ok: true, + value: { enabled: true }, + }); + }); + + it("returns the validated default object when config input is undefined", () => { + expect( + validateExtensionHostConfig({ + schema: { + type: "object", + properties: { + enabled: { type: "boolean" }, + }, + additionalProperties: false, + }, + }), + ).toEqual({ + ok: true, + value: {}, + }); + }); + + it("can disable bundled memory plugins early based on slot policy", () => { + const result = resolveExtensionHostEarlyMemoryDecision({ + origin: "bundled", + manifestKind: "memory", + recordId: "memory-b", + memorySlot: "memory-a", + selectedMemoryPluginId: null, + }); + + expect(result.enabled).toBe(false); + expect(result.reason).toContain('memory slot set to "memory-a"'); + }); + + it("returns the post-definition memory slot decision", () => { + const result = resolveExtensionHostMemoryDecision({ + recordId: "memory-a", + recordKind: "memory", + memorySlot: "memory-a", + selectedMemoryPluginId: null, + }); + + expect(result).toEqual({ + enabled: true, + selected: true, + }); + }); +}); diff --git a/src/extension-host/activation/loader-runtime.ts b/src/extension-host/activation/loader-runtime.ts new file mode 100644 index 00000000000..b9092452199 --- /dev/null +++ b/src/extension-host/activation/loader-runtime.ts @@ -0,0 +1,126 @@ +import { resolveMemorySlotDecision } from "../../plugins/config-state.js"; +import type { PluginRecord } from "../../plugins/registry.js"; +import { validateJsonSchemaValue } from "../../plugins/schema-validator.js"; +import type { OpenClawPluginDefinition, PluginDiagnostic } from "../../plugins/types.js"; + +export function validateExtensionHostConfig(params: { + schema?: Record; + cacheKey?: string; + value?: unknown; +}): { ok: boolean; value?: Record; errors?: string[] } { + const schema = params.schema; + if (!schema) { + return { ok: true, value: params.value as Record | undefined }; + } + const validatedValue = (params.value ?? {}) as Record; + const cacheKey = params.cacheKey ?? JSON.stringify(schema); + const result = validateJsonSchemaValue({ + schema, + cacheKey, + value: validatedValue, + }); + if (result.ok) { + return { ok: true, value: validatedValue }; + } + return { ok: false, errors: result.errors.map((error) => error.text) }; +} + +export function resolveExtensionHostModuleExport(moduleExport: unknown): { + definition?: OpenClawPluginDefinition; + register?: OpenClawPluginDefinition["register"]; +} { + const resolved = + moduleExport && + typeof moduleExport === "object" && + "default" in (moduleExport as Record) + ? (moduleExport as { default: unknown }).default + : moduleExport; + if (typeof resolved === "function") { + return { + register: resolved as OpenClawPluginDefinition["register"], + }; + } + if (resolved && typeof resolved === "object") { + const def = resolved as OpenClawPluginDefinition; + const register = def.register ?? def.activate; + return { definition: def, register }; + } + return {}; +} + +export function applyExtensionHostDefinitionToRecord(params: { + record: PluginRecord; + definition?: OpenClawPluginDefinition; + diagnostics: PluginDiagnostic[]; +}): + | { + ok: true; + } + | { + ok: false; + message: string; + } { + if (params.definition?.id && params.definition.id !== params.record.id) { + return { + ok: false, + message: `plugin id mismatch (config uses "${params.record.id}", export uses "${params.definition.id}")`, + }; + } + + params.record.name = params.definition?.name ?? params.record.name; + params.record.description = params.definition?.description ?? params.record.description; + params.record.version = params.definition?.version ?? params.record.version; + const manifestKind = params.record.kind as string | undefined; + const exportKind = params.definition?.kind as string | undefined; + if (manifestKind && exportKind && exportKind !== manifestKind) { + params.diagnostics.push({ + level: "warn", + pluginId: params.record.id, + source: params.record.source, + message: `plugin kind mismatch (manifest uses "${manifestKind}", export uses "${exportKind}")`, + }); + } + params.record.kind = params.definition?.kind ?? params.record.kind; + return { ok: true }; +} + +export function resolveExtensionHostEarlyMemoryDecision(params: { + origin: PluginRecord["origin"]; + manifestKind?: PluginRecord["kind"]; + recordId: string; + memorySlot?: string | null; + selectedMemoryPluginId: string | null; +}): { enabled: boolean; reason?: string } { + if (params.origin !== "bundled" || params.manifestKind !== "memory") { + return { enabled: true }; + } + const decision = resolveMemorySlotDecision({ + id: params.recordId, + kind: "memory", + slot: params.memorySlot, + selectedId: params.selectedMemoryPluginId, + }); + return { + enabled: decision.enabled, + ...(decision.enabled ? {} : { reason: decision.reason }), + }; +} + +export function resolveExtensionHostMemoryDecision(params: { + recordId: string; + recordKind?: PluginRecord["kind"]; + memorySlot?: string | null; + selectedMemoryPluginId: string | null; +}): { enabled: boolean; selected: boolean; reason?: string } { + const decision = resolveMemorySlotDecision({ + id: params.recordId, + kind: params.recordKind, + slot: params.memorySlot, + selectedId: params.selectedMemoryPluginId, + }); + return { + enabled: decision.enabled, + selected: decision.selected === true, + ...(decision.enabled ? {} : { reason: decision.reason }), + }; +} diff --git a/src/extension-host/activation/loader-session.test.ts b/src/extension-host/activation/loader-session.test.ts new file mode 100644 index 00000000000..f44d174a9ef --- /dev/null +++ b/src/extension-host/activation/loader-session.test.ts @@ -0,0 +1,164 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { normalizePluginsConfig } from "../../plugins/config-state.js"; +import type { PluginManifestRecord } from "../../plugins/manifest-registry.js"; +import type { PluginRegistry } from "../../plugins/registry.js"; +import { + createExtensionHostLoaderSession, + finalizeExtensionHostLoaderSession, + processExtensionHostLoaderSessionCandidate, +} from "./loader-session.js"; + +const tempDirs: string[] = []; + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } +}); + +function createTempPluginFixture() { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-loader-session-")); + tempDirs.push(rootDir); + const entryPath = path.join(rootDir, "index.js"); + fs.writeFileSync(entryPath, "export default { id: 'demo', register() {} }"); + return { rootDir, entryPath }; +} + +function createManifestRecord(rootDir: string, entryPath: string): PluginManifestRecord { + return { + id: "demo", + name: "Demo", + description: "Demo plugin", + version: "1.0.0", + kind: "memory", + channels: [], + providers: [], + skills: [], + origin: "bundled", + rootDir, + source: entryPath, + manifestPath: path.join(rootDir, "openclaw.plugin.json"), + schemaCacheKey: "demo-schema", + configSchema: { + type: "object", + properties: {}, + }, + resolvedExtension: { + id: "demo", + source: entryPath, + origin: "bundled", + rootDir, + static: { + package: {}, + config: {}, + setup: {}, + }, + runtime: { + kind: "memory", + contributions: [], + }, + policy: {}, + }, + }; +} + +function createRegistry(): PluginRegistry { + return { + plugins: [], + tools: [], + hooks: [], + typedHooks: [], + channels: [], + providers: [], + gatewayHandlers: {}, + httpRoutes: [], + cliRegistrars: [], + services: [], + commands: [], + diagnostics: [], + }; +} + +describe("extension host loader session", () => { + it("owns mutable activation state for memory-slot selection", () => { + const { rootDir, entryPath } = createTempPluginFixture(); + const session = createExtensionHostLoaderSession({ + registry: createRegistry(), + logger: { + info: () => {}, + warn: () => {}, + error: () => {}, + }, + env: process.env, + provenance: { + loadPathMatcher: { exact: new Set(), dirs: [] }, + installRules: new Map(), + }, + cacheEnabled: false, + cacheKey: "cache-key", + memorySlot: "demo", + setCachedRegistry: () => {}, + activateRegistry: () => {}, + }); + + processExtensionHostLoaderSessionCandidate({ + session, + candidate: { + source: entryPath, + rootDir, + packageDir: rootDir, + origin: "bundled", + }, + manifestRecord: createManifestRecord(rootDir, entryPath), + normalizedConfig: normalizePluginsConfig({ + slots: { + memory: "demo", + }, + }), + rootConfig: {}, + validateOnly: true, + createApi: () => ({}) as never, + loadModule: () => + ({ + default: { + id: "demo", + register: () => {}, + }, + }) as never, + }); + + expect(session.selectedMemoryPluginId).toBe("demo"); + expect(session.memorySlotMatched).toBe(true); + expect(session.registry.plugins[0]?.lifecycleState).toBe("validated"); + }); + + it("finalizes the session through the shared finalizer", () => { + const session = createExtensionHostLoaderSession({ + registry: createRegistry(), + logger: { + info: () => {}, + warn: () => {}, + error: () => {}, + }, + env: process.env, + provenance: { + loadPathMatcher: { exact: new Set(), dirs: [] }, + installRules: new Map(), + }, + cacheEnabled: false, + cacheKey: "cache-key", + setCachedRegistry: () => {}, + activateRegistry: () => {}, + }); + + const result = finalizeExtensionHostLoaderSession(session); + + expect(result).toBe(session.registry); + }); +}); diff --git a/src/extension-host/activation/loader-session.ts b/src/extension-host/activation/loader-session.ts new file mode 100644 index 00000000000..298085987b1 --- /dev/null +++ b/src/extension-host/activation/loader-session.ts @@ -0,0 +1,102 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import type { NormalizedPluginsConfig } from "../../plugins/config-state.js"; +import type { PluginCandidate } from "../../plugins/discovery.js"; +import type { PluginManifestRecord } from "../../plugins/manifest-registry.js"; +import type { PluginRecord, PluginRegistry } from "../../plugins/registry.js"; +import type { OpenClawPluginApi, OpenClawPluginModule, PluginLogger } from "../../plugins/types.js"; +import type { ExtensionHostProvenanceIndex } from "../policy/loader-provenance.js"; +import { finalizeExtensionHostRegistryLoad } from "./loader-finalize.js"; +import { processExtensionHostPluginCandidate } from "./loader-flow.js"; + +export type ExtensionHostLoaderSession = { + registry: PluginRegistry; + logger: PluginLogger; + env: NodeJS.ProcessEnv; + provenance: ExtensionHostProvenanceIndex; + cacheEnabled: boolean; + cacheKey: string; + memorySlot?: string | null; + seenIds: Map; + selectedMemoryPluginId: string | null; + memorySlotMatched: boolean; + setCachedRegistry: (cacheKey: string, registry: PluginRegistry) => void; + activateRegistry: (registry: PluginRegistry, cacheKey: string) => void; +}; + +export function createExtensionHostLoaderSession(params: { + registry: PluginRegistry; + logger: PluginLogger; + env: NodeJS.ProcessEnv; + provenance: ExtensionHostProvenanceIndex; + cacheEnabled: boolean; + cacheKey: string; + memorySlot?: string | null; + setCachedRegistry: (cacheKey: string, registry: PluginRegistry) => void; + activateRegistry: (registry: PluginRegistry, cacheKey: string) => void; +}): ExtensionHostLoaderSession { + return { + registry: params.registry, + logger: params.logger, + env: params.env, + provenance: params.provenance, + cacheEnabled: params.cacheEnabled, + cacheKey: params.cacheKey, + memorySlot: params.memorySlot, + seenIds: new Map(), + selectedMemoryPluginId: null, + memorySlotMatched: false, + setCachedRegistry: params.setCachedRegistry, + activateRegistry: params.activateRegistry, + }; +} + +export function processExtensionHostLoaderSessionCandidate(params: { + session: ExtensionHostLoaderSession; + candidate: PluginCandidate; + manifestRecord: PluginManifestRecord; + normalizedConfig: NormalizedPluginsConfig; + rootConfig: OpenClawConfig; + validateOnly: boolean; + createApi: ( + record: PluginRecord, + options: { + config: OpenClawConfig; + pluginConfig?: Record; + hookPolicy?: { allowPromptInjection?: boolean }; + }, + ) => OpenClawPluginApi; + loadModule: (safeSource: string) => OpenClawPluginModule; +}): void { + const processed = processExtensionHostPluginCandidate({ + candidate: params.candidate, + manifestRecord: params.manifestRecord, + normalizedConfig: params.normalizedConfig, + rootConfig: params.rootConfig, + validateOnly: params.validateOnly, + logger: params.session.logger, + registry: params.session.registry, + seenIds: params.session.seenIds, + selectedMemoryPluginId: params.session.selectedMemoryPluginId, + createApi: params.createApi, + loadModule: params.loadModule, + }); + params.session.selectedMemoryPluginId = processed.selectedMemoryPluginId; + params.session.memorySlotMatched ||= processed.memorySlotMatched; +} + +export function finalizeExtensionHostLoaderSession( + session: ExtensionHostLoaderSession, +): PluginRegistry { + return finalizeExtensionHostRegistryLoad({ + registry: session.registry, + memorySlot: session.memorySlot, + memorySlotMatched: session.memorySlotMatched, + provenance: session.provenance, + logger: session.logger, + env: session.env, + cacheEnabled: session.cacheEnabled, + cacheKey: session.cacheKey, + setCachedRegistry: session.setCachedRegistry, + activateRegistry: session.activateRegistry, + }); +} diff --git a/src/extension-host/activation/loader-state.test.ts b/src/extension-host/activation/loader-state.test.ts new file mode 100644 index 00000000000..eac602d1905 --- /dev/null +++ b/src/extension-host/activation/loader-state.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import type { PluginRegistry } from "../../plugins/registry.js"; +import { createExtensionHostPluginRecord } from "../policy/loader-policy.js"; +import { + appendExtensionHostPluginRecord, + markExtensionHostRegistryPluginsReady, + setExtensionHostPluginRecordLifecycleState, + setExtensionHostPluginRecordDisabled, + setExtensionHostPluginRecordError, +} from "./loader-state.js"; + +function createRegistry(): PluginRegistry { + return { + plugins: [], + tools: [], + hooks: [], + typedHooks: [], + channels: [], + providers: [], + gatewayHandlers: {}, + httpRoutes: [], + cliRegistrars: [], + services: [], + commands: [], + diagnostics: [], + }; +} + +describe("extension host loader state", () => { + it("maps explicit lifecycle states onto compatibility status values", () => { + const record = createExtensionHostPluginRecord({ + id: "demo", + source: "/plugins/demo.js", + origin: "workspace", + enabled: true, + configSchema: true, + }); + + expect(setExtensionHostPluginRecordLifecycleState(record, "imported")).toMatchObject({ + lifecycleState: "imported", + status: "loaded", + }); + expect(setExtensionHostPluginRecordLifecycleState(record, "validated")).toMatchObject({ + lifecycleState: "validated", + status: "loaded", + }); + expect(setExtensionHostPluginRecordLifecycleState(record, "registered")).toMatchObject({ + lifecycleState: "registered", + status: "loaded", + }); + }); + + it("rejects invalid lifecycle jumps", () => { + const record = createExtensionHostPluginRecord({ + id: "demo", + source: "/plugins/demo.js", + origin: "workspace", + enabled: true, + configSchema: true, + }); + + expect(() => setExtensionHostPluginRecordLifecycleState(record, "registered")).toThrow( + "invalid extension host lifecycle transition: prepared -> registered", + ); + }); + + it("marks plugin records disabled", () => { + const record = createExtensionHostPluginRecord({ + id: "demo", + source: "/plugins/demo.js", + origin: "workspace", + enabled: true, + configSchema: true, + }); + + expect(setExtensionHostPluginRecordDisabled(record, "disabled by policy")).toMatchObject({ + enabled: false, + status: "disabled", + lifecycleState: "disabled", + error: "disabled by policy", + }); + }); + + it("marks plugin records as errors", () => { + const record = createExtensionHostPluginRecord({ + id: "demo", + source: "/plugins/demo.js", + origin: "workspace", + enabled: true, + configSchema: true, + }); + + expect(setExtensionHostPluginRecordError(record, "failed to load")).toMatchObject({ + status: "error", + lifecycleState: "error", + error: "failed to load", + }); + }); + + it("appends records and optionally updates seen ids", () => { + const registry = createRegistry(); + const seenIds = new Map(); + const record = createExtensionHostPluginRecord({ + id: "demo", + source: "/plugins/demo.js", + origin: "workspace", + enabled: true, + configSchema: true, + }); + + appendExtensionHostPluginRecord({ + registry, + record, + seenIds, + pluginId: "demo", + origin: "workspace", + }); + + expect(registry.plugins).toEqual([record]); + expect(seenIds.get("demo")).toBe("workspace"); + }); + + it("promotes registered plugins to ready during finalization", () => { + const registry = createRegistry(); + const record = createExtensionHostPluginRecord({ + id: "demo", + source: "/plugins/demo.js", + origin: "workspace", + enabled: true, + configSchema: true, + }); + + setExtensionHostPluginRecordLifecycleState(record, "imported"); + setExtensionHostPluginRecordLifecycleState(record, "validated"); + setExtensionHostPluginRecordLifecycleState(record, "registered"); + registry.plugins.push(record); + + markExtensionHostRegistryPluginsReady(registry); + + expect(record).toMatchObject({ + lifecycleState: "ready", + status: "loaded", + }); + }); +}); diff --git a/src/extension-host/activation/loader-state.ts b/src/extension-host/activation/loader-state.ts new file mode 100644 index 00000000000..348cae44508 --- /dev/null +++ b/src/extension-host/activation/loader-state.ts @@ -0,0 +1,109 @@ +import type { + PluginRecord, + PluginRecordLifecycleState, + PluginRegistry, +} from "../../plugins/registry.js"; + +const EXTENSION_HOST_LIFECYCLE_STATUS_MAP: Record< + PluginRecordLifecycleState, + PluginRecord["status"] +> = { + prepared: "loaded", + imported: "loaded", + disabled: "disabled", + validated: "loaded", + registered: "loaded", + ready: "loaded", + error: "error", +}; + +const EXTENSION_HOST_PLUGIN_LIFECYCLE_TRANSITIONS: Record< + PluginRecordLifecycleState, + Set +> = { + prepared: new Set(["imported", "disabled", "error"]), + imported: new Set(["validated", "disabled", "error"]), + disabled: new Set(), + validated: new Set(["registered", "disabled", "error"]), + registered: new Set(["ready", "error"]), + ready: new Set(["error"]), + error: new Set(), +}; + +function assertExtensionHostPluginLifecycleTransition( + currentState: PluginRecordLifecycleState | undefined, + nextState: PluginRecordLifecycleState, +): void { + if (currentState === undefined) { + if (nextState === "prepared" || nextState === "disabled" || nextState === "error") { + return; + } + throw new Error(`invalid initial extension host lifecycle transition: -> ${nextState}`); + } + if (currentState === nextState) { + return; + } + if (EXTENSION_HOST_PLUGIN_LIFECYCLE_TRANSITIONS[currentState].has(nextState)) { + return; + } + throw new Error(`invalid extension host lifecycle transition: ${currentState} -> ${nextState}`); +} + +export function setExtensionHostPluginRecordLifecycleState( + record: PluginRecord, + nextState: PluginRecordLifecycleState, + opts?: { error?: string }, +): PluginRecord { + assertExtensionHostPluginLifecycleTransition(record.lifecycleState, nextState); + record.lifecycleState = nextState; + record.status = EXTENSION_HOST_LIFECYCLE_STATUS_MAP[nextState]; + + if (nextState === "disabled") { + record.enabled = false; + record.error = opts?.error; + return record; + } + if (nextState === "error") { + record.error = opts?.error; + return record; + } + if (opts?.error === undefined) { + delete record.error; + } + return record; +} + +export function setExtensionHostPluginRecordDisabled( + record: PluginRecord, + reason?: string, +): PluginRecord { + return setExtensionHostPluginRecordLifecycleState(record, "disabled", { error: reason }); +} + +export function setExtensionHostPluginRecordError( + record: PluginRecord, + message: string, +): PluginRecord { + return setExtensionHostPluginRecordLifecycleState(record, "error", { error: message }); +} + +export function markExtensionHostRegistryPluginsReady(registry: PluginRegistry): void { + for (const record of registry.plugins) { + if (record.lifecycleState === "registered") { + setExtensionHostPluginRecordLifecycleState(record, "ready"); + } + } +} + +export function appendExtensionHostPluginRecord(params: { + registry: PluginRegistry; + record: PluginRecord; + seenIds?: Map; + pluginId?: string; + origin?: PluginRecord["origin"]; +}): void { + params.registry.plugins.push(params.record); + if (params.seenIds && params.pluginId && params.origin) { + params.seenIds.set(params.pluginId, params.origin); + } +} diff --git a/src/extension-host/compat/hook-compat.test.ts b/src/extension-host/compat/hook-compat.test.ts new file mode 100644 index 00000000000..f98e108875a --- /dev/null +++ b/src/extension-host/compat/hook-compat.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from "vitest"; +import { + applyExtensionHostTypedHookPolicy, + bridgeExtensionHostLegacyHooks, + constrainExtensionHostPromptInjectionHook, +} from "./hook-compat.js"; + +describe("extension host hook compatibility", () => { + it("bridges legacy hooks only when internal hook registration is enabled", () => { + const registerHook = vi.fn(); + + bridgeExtensionHostLegacyHooks({ + events: ["before_send", "after_send"], + handler: (() => {}) as never, + hookSystemEnabled: true, + registerHook: registerHook as never, + }); + + expect(registerHook).toHaveBeenCalledTimes(2); + expect(registerHook).toHaveBeenNthCalledWith(1, "before_send", expect.any(Function)); + expect(registerHook).toHaveBeenNthCalledWith(2, "after_send", expect.any(Function)); + }); + + it("constrains prompt-mutation fields for before_agent_start hooks", async () => { + const handler = vi.fn(async () => ({ + messages: [{ role: "system", content: "keep" }], + systemPrompt: "drop", + prependContext: "drop", + appendSystemContext: "drop", + })); + + const constrained = constrainExtensionHostPromptInjectionHook(handler as never); + const result = await constrained({} as never, {} as never); + + expect(result).toEqual({ + messages: [{ role: "system", content: "keep" }], + }); + }); + + it("blocks before_prompt_build and constrains before_agent_start when prompt injection is disabled", () => { + const blocked = applyExtensionHostTypedHookPolicy({ + hookName: "before_prompt_build", + handler: (() => ({})) as never, + policy: { allowPromptInjection: false }, + blockedMessage: "blocked", + constrainedMessage: "constrained", + }); + const constrained = applyExtensionHostTypedHookPolicy({ + hookName: "before_agent_start", + handler: (() => ({})) as never, + policy: { allowPromptInjection: false }, + blockedMessage: "blocked", + constrainedMessage: "constrained", + }); + + expect(blocked).toEqual({ + ok: false, + message: "blocked", + }); + expect(constrained.ok).toBe(true); + if (constrained.ok) { + expect(constrained.warningMessage).toBe("constrained"); + expect(constrained.entryHandler).toBeTypeOf("function"); + } + }); +}); diff --git a/src/extension-host/compat/hook-compat.ts b/src/extension-host/compat/hook-compat.ts new file mode 100644 index 00000000000..1dd66dc14e2 --- /dev/null +++ b/src/extension-host/compat/hook-compat.ts @@ -0,0 +1,91 @@ +import { registerInternalHook, type InternalHookHandler } from "../../hooks/internal-hooks.js"; +import type { + PluginHookHandlerMap, + PluginHookName, + PluginHookRegistration as TypedPluginHookRegistration, +} from "../../plugins/types.js"; +import { + isPromptInjectionHookName, + stripPromptMutationFieldsFromLegacyHookResult, +} from "../../plugins/types.js"; + +export function constrainExtensionHostPromptInjectionHook( + handler: PluginHookHandlerMap["before_agent_start"], +): PluginHookHandlerMap["before_agent_start"] { + return (event, ctx) => { + const result = handler(event, ctx); + if (result && typeof result === "object" && "then" in result) { + return Promise.resolve(result).then((resolved) => + stripPromptMutationFieldsFromLegacyHookResult(resolved), + ); + } + return stripPromptMutationFieldsFromLegacyHookResult(result); + }; +} + +export function bridgeExtensionHostLegacyHooks(params: { + events: string[]; + handler: InternalHookHandler; + hookSystemEnabled: boolean; + register?: boolean; + registerHook?: typeof registerInternalHook; +}): void { + if (!params.hookSystemEnabled || params.register === false) { + return; + } + + const registerHook = params.registerHook ?? registerInternalHook; + for (const event of params.events) { + registerHook(event, params.handler); + } +} + +export function applyExtensionHostTypedHookPolicy(params: { + hookName: K; + handler: PluginHookHandlerMap[K]; + policy?: { + allowPromptInjection?: boolean; + }; + blockedMessage: string; + constrainedMessage: string; +}): + | { + ok: false; + message: string; + } + | { + ok: true; + entryHandler: TypedPluginHookRegistration["handler"]; + warningMessage?: string; + } { + if ( + !(params.policy?.allowPromptInjection === false && isPromptInjectionHookName(params.hookName)) + ) { + return { + ok: true, + entryHandler: params.handler, + }; + } + + if (params.hookName === "before_prompt_build") { + return { + ok: false, + message: params.blockedMessage, + }; + } + + if (params.hookName === "before_agent_start") { + return { + ok: true, + entryHandler: constrainExtensionHostPromptInjectionHook( + params.handler as PluginHookHandlerMap["before_agent_start"], + ), + warningMessage: params.constrainedMessage, + }; + } + + return { + ok: true, + entryHandler: params.handler, + }; +} diff --git a/src/extension-host/compat/loader-compat.ts b/src/extension-host/compat/loader-compat.ts new file mode 100644 index 00000000000..a03980b3e26 --- /dev/null +++ b/src/extension-host/compat/loader-compat.ts @@ -0,0 +1,114 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { resolveOpenClawPackageRootSync } from "../../infra/openclaw-root.js"; + +type PluginSdkAliasCandidateKind = "dist" | "src"; + +const cachedPluginSdkExportedSubpaths = new Map(); + +export function resolvePluginSdkAliasCandidateOrder(params: { + modulePath: string; + isProduction: boolean; +}): PluginSdkAliasCandidateKind[] { + const normalizedModulePath = params.modulePath.replace(/\\/g, "/"); + const isDistRuntime = normalizedModulePath.includes("/dist/"); + return isDistRuntime || params.isProduction ? ["dist", "src"] : ["src", "dist"]; +} + +export function listPluginSdkAliasCandidates(params: { + srcFile: string; + distFile: string; + modulePath: string; +}): string[] { + const orderedKinds = resolvePluginSdkAliasCandidateOrder({ + modulePath: params.modulePath, + isProduction: process.env.NODE_ENV === "production", + }); + let cursor = path.dirname(params.modulePath); + const candidates: string[] = []; + for (let i = 0; i < 6; i += 1) { + const candidateMap = { + src: path.join(cursor, "src", "plugin-sdk", params.srcFile), + dist: path.join(cursor, "dist", "plugin-sdk", params.distFile), + } as const; + for (const kind of orderedKinds) { + candidates.push(candidateMap[kind]); + } + const parent = path.dirname(cursor); + if (parent === cursor) { + break; + } + cursor = parent; + } + return candidates; +} + +export function resolvePluginSdkAliasFile(params: { + srcFile: string; + distFile: string; + modulePath?: string; +}): string | null { + try { + const modulePath = params.modulePath ?? fileURLToPath(import.meta.url); + for (const candidate of listPluginSdkAliasCandidates({ + srcFile: params.srcFile, + distFile: params.distFile, + modulePath, + })) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + } catch { + // ignore + } + return null; +} + +export function resolvePluginSdkAlias(): string | null { + return resolvePluginSdkAliasFile({ srcFile: "root-alias.cjs", distFile: "root-alias.cjs" }); +} + +export function listPluginSdkExportedSubpaths(params: { modulePath?: string } = {}): string[] { + const modulePath = params.modulePath ?? fileURLToPath(import.meta.url); + const packageRoot = resolveOpenClawPackageRootSync({ + cwd: path.dirname(modulePath), + }); + if (!packageRoot) { + return []; + } + const cached = cachedPluginSdkExportedSubpaths.get(packageRoot); + if (cached) { + return cached; + } + try { + const pkgRaw = fs.readFileSync(path.join(packageRoot, "package.json"), "utf-8"); + const pkg = JSON.parse(pkgRaw) as { + exports?: Record; + }; + const subpaths = Object.keys(pkg.exports ?? {}) + .filter((key) => key.startsWith("./plugin-sdk/")) + .map((key) => key.slice("./plugin-sdk/".length)) + .filter((subpath) => Boolean(subpath) && !subpath.includes("/")) + .toSorted(); + cachedPluginSdkExportedSubpaths.set(packageRoot, subpaths); + return subpaths; + } catch { + return []; + } +} + +export function resolvePluginSdkScopedAliasMap(): Record { + const aliasMap: Record = {}; + for (const subpath of listPluginSdkExportedSubpaths()) { + const resolved = resolvePluginSdkAliasFile({ + srcFile: `${subpath}.ts`, + distFile: `${subpath}.js`, + }); + if (resolved) { + aliasMap[`openclaw/plugin-sdk/${subpath}`] = resolved; + } + } + return aliasMap; +} diff --git a/src/extension-host/compat/plugin-api.test.ts b/src/extension-host/compat/plugin-api.test.ts new file mode 100644 index 00000000000..c089f50a39a --- /dev/null +++ b/src/extension-host/compat/plugin-api.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it, vi } from "vitest"; +import type { PluginRecord } from "../../plugins/registry.js"; +import { createExtensionHostPluginApi, normalizeExtensionHostPluginLogger } from "./plugin-api.js"; + +function createRecord(): PluginRecord { + return { + id: "demo", + name: "Demo", + source: "/plugins/demo.ts", + origin: "workspace", + enabled: true, + status: "loaded", + toolNames: [], + hookNames: [], + channelIds: [], + providerIds: [], + gatewayMethods: [], + cliCommands: [], + services: [], + commands: [], + httpRoutes: 0, + hookCount: 0, + configSchema: false, + }; +} + +describe("extension host plugin api", () => { + it("normalizes plugin logger methods", () => { + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }; + + const normalized = normalizeExtensionHostPluginLogger(logger); + normalized.info("x"); + + expect(logger.info).toHaveBeenCalledWith("x"); + expect(normalized.debug).toBe(logger.debug); + }); + + it("creates a compatibility plugin api that delegates all registration calls", () => { + const callbacks = { + registerTool: vi.fn(), + registerHook: vi.fn(), + registerHttpRoute: vi.fn(), + registerChannel: vi.fn(), + registerProvider: vi.fn(), + registerGatewayMethod: vi.fn(), + registerCli: vi.fn(), + registerService: vi.fn(), + registerCommand: vi.fn(), + registerContextEngine: vi.fn(), + on: vi.fn(), + }; + + const api = createExtensionHostPluginApi({ + record: createRecord(), + runtime: {} as never, + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + config: {}, + registerTool: callbacks.registerTool as never, + registerHook: callbacks.registerHook as never, + registerHttpRoute: callbacks.registerHttpRoute as never, + registerChannel: callbacks.registerChannel as never, + registerProvider: callbacks.registerProvider as never, + registerGatewayMethod: callbacks.registerGatewayMethod as never, + registerCli: callbacks.registerCli as never, + registerService: callbacks.registerService as never, + registerCommand: callbacks.registerCommand as never, + registerContextEngine: callbacks.registerContextEngine as never, + on: callbacks.on as never, + }); + + api.registerTool({ name: "tool" } as never); + api.registerHook("before_send", (() => {}) as never); + api.registerHttpRoute({ path: "/x", handler: (() => {}) as never, auth: "gateway" }); + api.registerChannel({ id: "ch" } as never); + api.registerProvider({} as never); + api.registerGatewayMethod("ping", (() => {}) as never); + api.registerCli((() => {}) as never); + api.registerService({ id: "svc", start: async () => {}, stop: async () => {} } as never); + api.registerCommand({ name: "cmd", description: "demo", handler: async () => ({}) } as never); + api.registerContextEngine("engine", (() => ({}) as never) as never); + api.on("before_send" as never, (() => {}) as never); + + expect(callbacks.registerTool).toHaveBeenCalledTimes(1); + expect(callbacks.registerHook).toHaveBeenCalledTimes(1); + expect(callbacks.registerHttpRoute).toHaveBeenCalledTimes(1); + expect(callbacks.registerChannel).toHaveBeenCalledTimes(1); + expect(callbacks.registerProvider).toHaveBeenCalledTimes(1); + expect(callbacks.registerGatewayMethod).toHaveBeenCalledTimes(1); + expect(callbacks.registerCli).toHaveBeenCalledTimes(1); + expect(callbacks.registerService).toHaveBeenCalledTimes(1); + expect(callbacks.registerCommand).toHaveBeenCalledTimes(1); + expect(callbacks.registerContextEngine).toHaveBeenCalledTimes(1); + expect(callbacks.on).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/extension-host/compat/plugin-api.ts b/src/extension-host/compat/plugin-api.ts new file mode 100644 index 00000000000..7ea8488a035 --- /dev/null +++ b/src/extension-host/compat/plugin-api.ts @@ -0,0 +1,91 @@ +import type { AnyAgentTool } from "../../agents/tools/common.js"; +import type { PluginRecord } from "../../plugins/registry.js"; +import type { PluginRuntime } from "../../plugins/runtime/types.js"; +import type { + OpenClawPluginApi, + OpenClawPluginChannelRegistration, + OpenClawPluginCliRegistrar, + OpenClawPluginCommandDefinition, + OpenClawPluginHttpRouteParams, + PluginInteractiveHandlerRegistration, + OpenClawPluginService, + OpenClawPluginToolFactory, + PluginLogger, + PluginHookName, + PluginHookHandlerMap, + ProviderPlugin, +} from "../../plugins/types.js"; +import { resolveUserPath } from "../../utils.js"; + +export function normalizeExtensionHostPluginLogger(logger: PluginLogger): PluginLogger { + return { + info: logger.info, + warn: logger.warn, + error: logger.error, + debug: logger.debug, + }; +} + +export function createExtensionHostPluginApi(params: { + record: PluginRecord; + runtime: PluginRuntime; + logger: PluginLogger; + config: OpenClawPluginApi["config"]; + pluginConfig?: Record; + registerTool: ( + tool: OpenClawPluginToolFactory | AnyAgentTool, + opts?: { name?: string; names?: string[]; optional?: boolean }, + ) => void; + registerHook: ( + events: string | string[], + handler: Parameters[1], + opts?: Parameters[2], + ) => void; + registerHttpRoute: (params: OpenClawPluginHttpRouteParams) => void; + registerChannel: (registration: OpenClawPluginChannelRegistration | object) => void; + registerProvider: (provider: ProviderPlugin) => void; + registerGatewayMethod: ( + method: string, + handler: OpenClawPluginApi["registerGatewayMethod"] extends (m: string, h: infer H) => void + ? H + : never, + ) => void; + registerInteractiveHandler: (registration: PluginInteractiveHandlerRegistration) => void; + registerCli: (registrar: OpenClawPluginCliRegistrar, opts?: { commands?: string[] }) => void; + registerService: (service: OpenClawPluginService) => void; + registerCommand: (command: OpenClawPluginCommandDefinition) => void; + registerContextEngine: ( + id: string, + factory: Parameters[1], + ) => void; + on: ( + hookName: K, + handler: PluginHookHandlerMap[K], + opts?: { priority?: number }, + ) => void; +}): OpenClawPluginApi { + return { + id: params.record.id, + name: params.record.name, + version: params.record.version, + description: params.record.description, + source: params.record.source, + config: params.config, + pluginConfig: params.pluginConfig, + runtime: params.runtime, + logger: normalizeExtensionHostPluginLogger(params.logger), + registerTool: (tool, opts) => params.registerTool(tool as never, opts), + registerHook: (events, handler, opts) => params.registerHook(events, handler, opts), + registerHttpRoute: (routeParams) => params.registerHttpRoute(routeParams), + registerChannel: (registration) => params.registerChannel(registration), + registerProvider: (provider) => params.registerProvider(provider), + registerGatewayMethod: (method, handler) => params.registerGatewayMethod(method, handler), + registerInteractiveHandler: (registration) => params.registerInteractiveHandler(registration), + registerCli: (registrar, opts) => params.registerCli(registrar, opts), + registerService: (service) => params.registerService(service), + registerCommand: (command) => params.registerCommand(command), + registerContextEngine: (id, factory) => params.registerContextEngine(id, factory), + resolvePath: (input) => resolveUserPath(input), + on: (hookName, handler, opts) => params.on(hookName as never, handler as never, opts), + }; +} diff --git a/src/extension-host/compat/plugin-registry-compat.test.ts b/src/extension-host/compat/plugin-registry-compat.test.ts new file mode 100644 index 00000000000..d6d0a6a53a5 --- /dev/null +++ b/src/extension-host/compat/plugin-registry-compat.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from "vitest"; +import { clearPluginCommands } from "../../plugins/commands.js"; +import { createEmptyPluginRegistry, type PluginRecord } from "../../plugins/registry.js"; +import { + resolveExtensionHostCommandCompatibility, + resolveExtensionHostProviderCompatibility, +} from "./plugin-registry-compat.js"; + +function createRecord(): PluginRecord { + return { + id: "demo", + name: "Demo", + source: "/plugins/demo.ts", + origin: "workspace", + enabled: true, + status: "loaded", + toolNames: [], + hookNames: [], + channelIds: [], + providerIds: [], + gatewayMethods: [], + cliCommands: [], + services: [], + commands: [], + httpRoutes: 0, + hookCount: 0, + configSchema: false, + }; +} + +describe("extension host plugin registry compatibility", () => { + it("normalizes provider registration through the host-owned compatibility helper", () => { + const result = resolveExtensionHostProviderCompatibility({ + registry: createEmptyPluginRegistry(), + record: createRecord(), + provider: { + id: " demo-provider ", + label: " Demo Provider ", + auth: [{ id: " api-key ", label: " API Key " }], + } as never, + }); + + expect(result).toMatchObject({ + ok: true, + providerId: "demo-provider", + entry: { + provider: { + id: "demo-provider", + label: "Demo Provider", + auth: [{ id: "api-key", label: "API Key" }], + }, + }, + }); + }); + + it("reports duplicate command registration through the host-owned compatibility helper", () => { + clearPluginCommands(); + const registry = createEmptyPluginRegistry(); + const record = createRecord(); + + const first = resolveExtensionHostCommandCompatibility({ + registry, + record, + command: { + name: "demo", + description: "first", + handler: vi.fn(async () => ({ handled: true })), + }, + }); + const second = resolveExtensionHostCommandCompatibility({ + registry, + record, + command: { + name: "demo", + description: "second", + handler: vi.fn(async () => ({ handled: true })), + }, + }); + + expect(first.ok).toBe(true); + expect(second.ok).toBe(false); + expect(registry.diagnostics).toContainEqual( + expect.objectContaining({ + level: "error", + pluginId: "demo", + message: 'command registration failed: Command "demo" already registered by plugin "demo"', + }), + ); + + clearPluginCommands(); + }); +}); diff --git a/src/extension-host/compat/plugin-registry-compat.ts b/src/extension-host/compat/plugin-registry-compat.ts new file mode 100644 index 00000000000..2eb0eaa6d2e --- /dev/null +++ b/src/extension-host/compat/plugin-registry-compat.ts @@ -0,0 +1,117 @@ +import { normalizeRegisteredProvider } from "../../plugins/provider-validation.js"; +import type { PluginRecord, PluginRegistry } from "../../plugins/registry.js"; +import type { + OpenClawPluginCommandDefinition, + PluginDiagnostic, + ProviderPlugin, +} from "../../plugins/types.js"; +import { registerExtensionHostPluginCommand } from "../contributions/command-runtime.js"; +import { + type ExtensionHostCommandRegistration, + type ExtensionHostProviderRegistration, + resolveExtensionCommandRegistration, + resolveExtensionProviderRegistration, +} from "../contributions/runtime-registrations.js"; +import { listExtensionHostProviderRegistrations } from "../contributions/runtime-registry.js"; + +export function pushExtensionHostRegistryDiagnostic(params: { + registry: PluginRegistry; + level: PluginDiagnostic["level"]; + pluginId: string; + source: string; + message: string; +}) { + params.registry.diagnostics.push({ + level: params.level, + pluginId: params.pluginId, + source: params.source, + message: params.message, + }); +} + +export function resolveExtensionHostProviderCompatibility(params: { + registry: PluginRegistry; + record: PluginRecord; + provider: ProviderPlugin; +}): + | { + ok: true; + providerId: string; + entry: ExtensionHostProviderRegistration; + } + | { ok: false } { + const pushDiagnostic = (diag: PluginDiagnostic) => { + params.registry.diagnostics.push(diag); + }; + + const normalizedProvider = normalizeRegisteredProvider({ + pluginId: params.record.id, + source: params.record.source, + provider: params.provider, + pushDiagnostic, + }); + if (!normalizedProvider) { + return { ok: false }; + } + + const result = resolveExtensionProviderRegistration({ + existing: [...listExtensionHostProviderRegistrations(params.registry)], + ownerPluginId: params.record.id, + ownerSource: params.record.source, + provider: normalizedProvider, + }); + if (!result.ok) { + pushExtensionHostRegistryDiagnostic({ + registry: params.registry, + level: "error", + pluginId: params.record.id, + source: params.record.source, + message: result.message, + }); + return { ok: false }; + } + + return result; +} + +export function resolveExtensionHostCommandCompatibility(params: { + registry: PluginRegistry; + record: PluginRecord; + command: OpenClawPluginCommandDefinition; +}): + | { + ok: true; + commandName: string; + entry: ExtensionHostCommandRegistration; + } + | { ok: false } { + const normalized = resolveExtensionCommandRegistration({ + ownerPluginId: params.record.id, + ownerSource: params.record.source, + command: params.command, + }); + if (!normalized.ok) { + pushExtensionHostRegistryDiagnostic({ + registry: params.registry, + level: "error", + pluginId: params.record.id, + source: params.record.source, + message: normalized.message, + }); + return { ok: false }; + } + + const result = registerExtensionHostPluginCommand(params.record.id, normalized.entry.command); + if (!result.ok) { + pushExtensionHostRegistryDiagnostic({ + registry: params.registry, + level: "error", + pluginId: params.record.id, + source: params.record.source, + message: `command registration failed: ${result.error}`, + }); + return { ok: false }; + } + + return normalized; +} diff --git a/src/extension-host/compat/plugin-registry-registrations.test.ts b/src/extension-host/compat/plugin-registry-registrations.test.ts new file mode 100644 index 00000000000..692134b9ccc --- /dev/null +++ b/src/extension-host/compat/plugin-registry-registrations.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it, vi } from "vitest"; +import { createEmptyPluginRegistry, type PluginRecord } from "../../plugins/registry.js"; +import { createExtensionHostPluginRegistrationActions } from "./plugin-registry-registrations.js"; + +function createRecord(): PluginRecord { + return { + id: "demo", + name: "Demo", + source: "/plugins/demo.ts", + origin: "workspace", + enabled: true, + status: "loaded", + toolNames: [], + hookNames: [], + channelIds: [], + providerIds: [], + gatewayMethods: [], + cliCommands: [], + services: [], + commands: [], + httpRoutes: 0, + hookCount: 0, + configSchema: false, + }; +} + +describe("extension host plugin registry registrations", () => { + it("reports gateway-method collisions against core methods", () => { + const registry = createEmptyPluginRegistry(); + const actions = createExtensionHostPluginRegistrationActions({ + registry, + coreGatewayMethods: new Set(["ping"]), + }); + + actions.registerGatewayMethod(createRecord(), "ping", (() => {}) as never); + + expect(registry.gatewayHandlers.ping).toBeUndefined(); + expect(registry.diagnostics).toContainEqual( + expect.objectContaining({ + level: "error", + pluginId: "demo", + }), + ); + }); + + it("reports invalid context-engine registrations through the host-owned action helper", () => { + const registry = createEmptyPluginRegistry(); + const actions = createExtensionHostPluginRegistrationActions({ + registry, + coreGatewayMethods: new Set(), + }); + + actions.registerContextEngine(createRecord(), " ", (() => ({})) as never); + + expect(registry.diagnostics).toContainEqual( + expect.objectContaining({ + level: "error", + pluginId: "demo", + message: "context engine registration missing id", + }), + ); + }); + + it("rejects legacy hook-name collisions", () => { + const registry = createEmptyPluginRegistry(); + registry.hooks.push({ + pluginId: "existing", + entry: { + hook: { + name: "shared-hook", + description: "existing hook", + source: "openclaw-plugin", + pluginId: "existing", + filePath: "/plugins/existing.ts", + baseDir: "/plugins", + handlerPath: "/plugins/existing.ts", + }, + frontmatter: {}, + metadata: { events: ["message:received"] }, + invocation: { enabled: true }, + }, + events: ["message:received"], + source: "/plugins/existing.ts", + }); + const actions = createExtensionHostPluginRegistrationActions({ + registry, + coreGatewayMethods: new Set(), + }); + + actions.registerHook( + createRecord(), + "message:received", + vi.fn() as never, + { name: "shared-hook" }, + {}, + ); + + expect(registry.hooks).toHaveLength(1); + expect(registry.diagnostics).toContainEqual( + expect.objectContaining({ + level: "error", + message: "hook already registered: shared-hook (existing)", + }), + ); + }); + + it("rejects cli registrations without explicit command metadata", () => { + const registry = createEmptyPluginRegistry(); + const actions = createExtensionHostPluginRegistrationActions({ + registry, + coreGatewayMethods: new Set(), + }); + + actions.registerCli(createRecord(), vi.fn() as never, undefined); + + expect(registry.cliRegistrars).toHaveLength(0); + expect(registry.diagnostics).toContainEqual( + expect.objectContaining({ + level: "error", + message: "cli registration missing explicit commands metadata", + }), + ); + }); + + it("rejects cli command collisions", () => { + const registry = createEmptyPluginRegistry(); + registry.cliRegistrars.push({ + pluginId: "existing", + register: vi.fn(), + commands: ["status"], + source: "/plugins/existing.ts", + }); + const actions = createExtensionHostPluginRegistrationActions({ + registry, + coreGatewayMethods: new Set(), + }); + + actions.registerCli(createRecord(), vi.fn() as never, { + commands: ["status", "other"], + }); + + expect(registry.cliRegistrars).toHaveLength(1); + expect(registry.diagnostics).toContainEqual( + expect.objectContaining({ + level: "error", + message: "cli command already registered: status (existing)", + }), + ); + }); + + it("rejects duplicate service ids", () => { + const registry = createEmptyPluginRegistry(); + registry.services.push({ + pluginId: "existing", + service: { + id: "shared-service", + start: vi.fn(), + }, + source: "/plugins/existing.ts", + }); + const actions = createExtensionHostPluginRegistrationActions({ + registry, + coreGatewayMethods: new Set(), + }); + + actions.registerService(createRecord(), { + id: "shared-service", + start: vi.fn(), + }); + + expect(registry.services).toHaveLength(1); + expect(registry.diagnostics).toContainEqual( + expect.objectContaining({ + level: "error", + message: "service already registered: shared-service (existing)", + }), + ); + }); +}); diff --git a/src/extension-host/compat/plugin-registry-registrations.ts b/src/extension-host/compat/plugin-registry-registrations.ts new file mode 100644 index 00000000000..68d7b2db31c --- /dev/null +++ b/src/extension-host/compat/plugin-registry-registrations.ts @@ -0,0 +1,395 @@ +import type { AnyAgentTool } from "../../agents/tools/common.js"; +import type { ChannelPlugin } from "../../channels/plugins/types.js"; +import { registerContextEngine as registerLegacyContextEngine } from "../../context-engine/registry.js"; +import type { GatewayRequestHandler } from "../../gateway/server-methods/types.js"; +import { registerInternalHook } from "../../hooks/internal-hooks.js"; +import type { PluginRecord, PluginRegistry } from "../../plugins/registry.js"; +import type { + PluginHookHandlerMap, + PluginHookName, + OpenClawPluginApi, + OpenClawPluginChannelRegistration, + OpenClawPluginCliRegistrar, + OpenClawPluginHookOptions, + OpenClawPluginHttpRouteParams, + OpenClawPluginService, + OpenClawPluginToolFactory, + PluginHookRegistration as TypedPluginHookRegistration, +} from "../../plugins/types.js"; +import { + addExtensionChannelRegistration, + addExtensionCliRegistration, + addExtensionContextEngineRegistration, + addExtensionGatewayMethodRegistration, + addExtensionLegacyHookRegistration, + addExtensionHttpRouteRegistration, + addExtensionServiceRegistration, + addExtensionToolRegistration, + addExtensionTypedHookRegistration, +} from "../contributions/registry-writes.js"; +import { + resolveExtensionChannelRegistration, + resolveExtensionCliRegistration, + resolveExtensionContextEngineRegistration, + resolveExtensionGatewayMethodRegistration, + resolveExtensionLegacyHookRegistration, + resolveExtensionHttpRouteRegistration, + resolveExtensionServiceRegistration, + resolveExtensionToolRegistration, + resolveExtensionTypedHookRegistration, +} from "../contributions/runtime-registrations.js"; +import { + listExtensionHostChannelRegistrations, + listExtensionHostCliRegistrations, + getExtensionHostGatewayHandlers, + listExtensionHostHttpRoutes, + listExtensionHostServiceRegistrations, +} from "../contributions/runtime-registry.js"; +import { + applyExtensionHostTypedHookPolicy, + bridgeExtensionHostLegacyHooks, +} from "./hook-compat.js"; +import { pushExtensionHostRegistryDiagnostic } from "./plugin-registry-compat.js"; + +export type PluginTypedHookPolicy = { + allowPromptInjection?: boolean; +}; + +export function createExtensionHostPluginRegistrationActions(params: { + registry: PluginRegistry; + coreGatewayMethods: Set; +}) { + const { registry, coreGatewayMethods } = params; + + const registerTool = ( + record: PluginRecord, + tool: AnyAgentTool | OpenClawPluginToolFactory, + opts?: { name?: string; names?: string[]; optional?: boolean }, + ) => { + const result = resolveExtensionToolRegistration({ + ownerPluginId: record.id, + ownerSource: record.source, + tool, + opts, + }); + addExtensionToolRegistration({ registry, record, names: result.names, entry: result.entry }); + }; + + const registerHook = ( + record: PluginRecord, + events: string | string[], + handler: Parameters[1], + opts: OpenClawPluginHookOptions | undefined, + config: OpenClawPluginApi["config"], + ) => { + const normalized = resolveExtensionLegacyHookRegistration({ + ownerPluginId: record.id, + ownerSource: record.source, + events, + handler, + opts, + }); + if (!normalized.ok) { + pushExtensionHostRegistryDiagnostic({ + registry, + level: "warn", + pluginId: record.id, + source: record.source, + message: normalized.message, + }); + return; + } + const existingHook = registry.hooks.find( + (entry) => entry.entry.hook.name === normalized.hookName, + ); + if (existingHook) { + pushExtensionHostRegistryDiagnostic({ + registry, + level: "error", + pluginId: record.id, + source: record.source, + message: `hook already registered: ${normalized.hookName} (${existingHook.pluginId})`, + }); + return; + } + addExtensionLegacyHookRegistration({ + registry, + record, + hookName: normalized.hookName, + entry: normalized.entry, + events: normalized.events, + }); + + bridgeExtensionHostLegacyHooks({ + events: normalized.events, + handler, + hookSystemEnabled: config?.hooks?.internal?.enabled === true, + register: opts?.register, + registerHook: registerInternalHook, + }); + }; + + const registerGatewayMethod = ( + record: PluginRecord, + method: string, + handler: GatewayRequestHandler, + ) => { + const result = resolveExtensionGatewayMethodRegistration({ + existing: { ...getExtensionHostGatewayHandlers(registry) }, + coreGatewayMethods, + method, + handler, + }); + if (!result.ok) { + pushExtensionHostRegistryDiagnostic({ + registry, + level: "error", + pluginId: record.id, + source: record.source, + message: result.message, + }); + return; + } + addExtensionGatewayMethodRegistration({ + registry, + record, + method: result.method, + handler: result.handler, + }); + }; + + const registerHttpRoute = (record: PluginRecord, route: OpenClawPluginHttpRouteParams) => { + const result = resolveExtensionHttpRouteRegistration({ + existing: [...listExtensionHostHttpRoutes(registry)], + ownerPluginId: record.id, + ownerSource: record.source, + route, + }); + if (!result.ok) { + pushExtensionHostRegistryDiagnostic({ + registry, + level: result.message === "http route registration missing path" ? "warn" : "error", + pluginId: record.id, + source: record.source, + message: result.message, + }); + return; + } + if (result.action === "replace") { + addExtensionHttpRouteRegistration({ + registry, + record, + action: "replace", + existingIndex: result.existingIndex, + entry: result.entry, + }); + return; + } + addExtensionHttpRouteRegistration({ + registry, + record, + action: "append", + entry: result.entry, + }); + }; + + const registerChannel = ( + record: PluginRecord, + registration: OpenClawPluginChannelRegistration | ChannelPlugin, + ) => { + const result = resolveExtensionChannelRegistration({ + existing: [...listExtensionHostChannelRegistrations(registry)], + ownerPluginId: record.id, + ownerSource: record.source, + registration, + }); + if (!result.ok) { + pushExtensionHostRegistryDiagnostic({ + registry, + level: "error", + pluginId: record.id, + source: record.source, + message: result.message, + }); + return; + } + addExtensionChannelRegistration({ + registry, + record, + channelId: result.channelId, + entry: result.entry, + }); + }; + + const registerCli = ( + record: PluginRecord, + registrar: OpenClawPluginCliRegistrar, + opts?: { commands?: string[] }, + ) => { + const result = resolveExtensionCliRegistration({ + ownerPluginId: record.id, + ownerSource: record.source, + registrar, + opts, + }); + if (result.commands.length === 0) { + pushExtensionHostRegistryDiagnostic({ + registry, + level: "error", + pluginId: record.id, + source: record.source, + message: "cli registration missing explicit commands metadata", + }); + return; + } + const existing = listExtensionHostCliRegistrations(registry).find((entry) => + entry.commands.some((command) => result.commands.includes(command)), + ); + if (existing) { + const overlap = result.commands.find((command) => existing.commands.includes(command)); + pushExtensionHostRegistryDiagnostic({ + registry, + level: "error", + pluginId: record.id, + source: record.source, + message: `cli command already registered: ${overlap ?? result.commands[0]} (${existing.pluginId})`, + }); + return; + } + addExtensionCliRegistration({ + registry, + record, + commands: result.commands, + entry: result.entry, + }); + }; + + const registerService = (record: PluginRecord, service: OpenClawPluginService) => { + const result = resolveExtensionServiceRegistration({ + ownerPluginId: record.id, + ownerSource: record.source, + service, + }); + if (!result.ok) { + return; + } + const existing = listExtensionHostServiceRegistrations(registry).find( + (entry) => entry.service.id === result.serviceId, + ); + if (existing) { + pushExtensionHostRegistryDiagnostic({ + registry, + level: "error", + pluginId: record.id, + source: record.source, + message: `service already registered: ${result.serviceId} (${existing.pluginId})`, + }); + return; + } + addExtensionServiceRegistration({ + registry, + record, + serviceId: result.serviceId, + entry: result.entry, + }); + }; + + const registerTypedHook = ( + record: PluginRecord, + hookName: K, + handler: PluginHookHandlerMap[K], + opts?: { priority?: number }, + policy?: PluginTypedHookPolicy, + ) => { + const normalized = resolveExtensionTypedHookRegistration({ + ownerPluginId: record.id, + ownerSource: record.source, + hookName, + handler, + priority: opts?.priority, + }); + if (!normalized.ok) { + pushExtensionHostRegistryDiagnostic({ + registry, + level: "warn", + pluginId: record.id, + source: record.source, + message: normalized.message, + }); + return; + } + const policyResult = applyExtensionHostTypedHookPolicy({ + hookName: normalized.hookName, + handler, + policy, + blockedMessage: `typed hook "${normalized.hookName}" blocked by plugins.entries.${record.id}.hooks.allowPromptInjection=false`, + constrainedMessage: `typed hook "${normalized.hookName}" prompt fields constrained by plugins.entries.${record.id}.hooks.allowPromptInjection=false`, + }); + if (!policyResult.ok) { + pushExtensionHostRegistryDiagnostic({ + registry, + level: "warn", + pluginId: record.id, + source: record.source, + message: policyResult.message, + }); + return; + } + if (policyResult.warningMessage) { + pushExtensionHostRegistryDiagnostic({ + registry, + level: "warn", + pluginId: record.id, + source: record.source, + message: policyResult.warningMessage, + }); + } + addExtensionTypedHookRegistration({ + registry, + record, + entry: { + ...normalized.entry, + pluginId: record.id, + hookName: normalized.hookName, + handler: policyResult.entryHandler, + } as TypedPluginHookRegistration, + }); + }; + + const registerContextEngine = ( + record: PluginRecord, + engineId: string, + factory: Parameters[1], + ) => { + const result = resolveExtensionContextEngineRegistration({ + engineId, + factory, + }); + if (!result.ok) { + pushExtensionHostRegistryDiagnostic({ + registry, + level: "error", + pluginId: record.id, + source: record.source, + message: result.message, + }); + return; + } + addExtensionContextEngineRegistration({ + entry: result.entry, + registerEngine: registerLegacyContextEngine, + }); + }; + + return { + registerTool, + registerHook, + registerGatewayMethod, + registerHttpRoute, + registerChannel, + registerCli, + registerService, + registerTypedHook, + registerContextEngine, + }; +} diff --git a/src/extension-host/compat/plugin-registry.test.ts b/src/extension-host/compat/plugin-registry.test.ts new file mode 100644 index 00000000000..b36abad1fe7 --- /dev/null +++ b/src/extension-host/compat/plugin-registry.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } from "vitest"; +import { clearPluginCommands } from "../../plugins/commands.js"; +import { createEmptyPluginRegistry, type PluginRecord } from "../../plugins/registry.js"; +import { createExtensionHostPluginRegistry } from "./plugin-registry.js"; + +function createRecord(): PluginRecord { + return { + id: "demo", + name: "Demo", + source: "/plugins/demo.ts", + origin: "workspace", + enabled: true, + status: "loaded", + toolNames: [], + hookNames: [], + channelIds: [], + providerIds: [], + gatewayMethods: [], + cliCommands: [], + services: [], + commands: [], + httpRoutes: 0, + hookCount: 0, + configSchema: false, + }; +} + +describe("extension host plugin registry", () => { + it("registers providers through the host-owned facade", () => { + const registry = createEmptyPluginRegistry(); + const facade = createExtensionHostPluginRegistry({ + registry, + registryParams: { + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + runtime: {} as never, + }, + }); + + facade.registerProvider(createRecord(), { + id: " demo-provider ", + label: " Demo Provider ", + auth: [{ id: " api-key ", label: " API Key " }], + } as never); + + expect(registry.providers).toHaveLength(1); + expect(registry.providers[0]?.provider.id).toBe("demo-provider"); + expect(registry.providers[0]?.provider.label).toBe("Demo Provider"); + expect(registry.providers[0]?.provider.auth[0]?.id).toBe("api-key"); + }); + + it("records command registration failures as diagnostics through the host-owned facade", () => { + clearPluginCommands(); + const registry = createEmptyPluginRegistry(); + const facade = createExtensionHostPluginRegistry({ + registry, + registryParams: { + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + runtime: {} as never, + }, + }); + const record = createRecord(); + + facade.registerCommand(record, { + name: "demo", + description: "first", + handler: async () => ({ handled: true }), + }); + facade.registerCommand(record, { + name: "demo", + description: "second", + handler: async () => ({ handled: true }), + }); + + expect(registry.commands).toHaveLength(1); + expect(registry.diagnostics).toContainEqual( + expect.objectContaining({ + level: "error", + pluginId: "demo", + message: 'command registration failed: Command "demo" already registered by plugin "demo"', + }), + ); + + clearPluginCommands(); + }); +}); diff --git a/src/extension-host/compat/plugin-registry.ts b/src/extension-host/compat/plugin-registry.ts new file mode 100644 index 00000000000..be2a8814574 --- /dev/null +++ b/src/extension-host/compat/plugin-registry.ts @@ -0,0 +1,127 @@ +import { registerPluginInteractiveHandler } from "../../plugins/interactive.js"; +import type { PluginRecord, PluginRegistry, PluginRegistryParams } from "../../plugins/registry.js"; +import type { + PluginDiagnostic, + OpenClawPluginApi, + OpenClawPluginCommandDefinition, + PluginInteractiveHandlerRegistration, + ProviderPlugin, +} from "../../plugins/types.js"; +import { + addExtensionCommandRegistration, + addExtensionProviderRegistration, +} from "../contributions/registry-writes.js"; +import { createExtensionHostPluginApi } from "./plugin-api.js"; +import { + resolveExtensionHostCommandCompatibility, + resolveExtensionHostProviderCompatibility, +} from "./plugin-registry-compat.js"; +import { + createExtensionHostPluginRegistrationActions, + type PluginTypedHookPolicy, +} from "./plugin-registry-registrations.js"; + +export function createExtensionHostPluginRegistry(params: { + registry: PluginRegistry; + registryParams: PluginRegistryParams; +}) { + const { registry, registryParams } = params; + const coreGatewayMethods = new Set(Object.keys(registryParams.coreGatewayHandlers ?? {})); + const pushDiagnostic = (diag: PluginDiagnostic) => { + registry.diagnostics.push(diag); + }; + const actions = createExtensionHostPluginRegistrationActions({ + registry, + coreGatewayMethods, + }); + + const registerProvider = (record: PluginRecord, provider: ProviderPlugin) => { + const result = resolveExtensionHostProviderCompatibility({ + registry, + record, + provider, + }); + if (!result.ok) { + return; + } + addExtensionProviderRegistration({ + registry, + record, + providerId: result.providerId, + entry: result.entry, + }); + }; + + const registerCommand = (record: PluginRecord, command: OpenClawPluginCommandDefinition) => { + const normalized = resolveExtensionHostCommandCompatibility({ registry, record, command }); + if (!normalized.ok) { + return; + } + addExtensionCommandRegistration({ + registry, + record, + commandName: normalized.commandName, + entry: normalized.entry, + }); + }; + + const createApi = ( + record: PluginRecord, + params: { + config: OpenClawPluginApi["config"]; + pluginConfig?: Record; + hookPolicy?: PluginTypedHookPolicy; + }, + ): OpenClawPluginApi => { + return createExtensionHostPluginApi({ + record, + runtime: registryParams.runtime, + logger: registryParams.logger, + config: params.config, + pluginConfig: params.pluginConfig, + registerTool: (tool, opts) => actions.registerTool(record, tool, opts), + registerHook: (events, handler, opts) => + actions.registerHook(record, events, handler, opts, params.config), + registerHttpRoute: (routeParams) => actions.registerHttpRoute(record, routeParams), + registerChannel: (registration) => actions.registerChannel(record, registration as never), + registerProvider: (provider) => registerProvider(record, provider), + registerGatewayMethod: (method, handler) => + actions.registerGatewayMethod(record, method, handler), + registerInteractiveHandler: (registration: PluginInteractiveHandlerRegistration) => { + const result = registerPluginInteractiveHandler(record.id, registration, { + pluginName: record.name, + pluginRoot: record.rootDir, + }); + if (!result.ok) { + pushDiagnostic({ + level: "warn", + pluginId: record.id, + source: record.source, + message: result.error ?? "interactive handler registration failed", + }); + } + }, + registerCli: (registrar, opts) => actions.registerCli(record, registrar, opts), + registerService: (service) => actions.registerService(record, service), + registerCommand: (command) => registerCommand(record, command), + registerContextEngine: (id, factory) => actions.registerContextEngine(record, id, factory), + on: (hookName, handler, opts) => + actions.registerTypedHook(record, hookName, handler, opts, params.hookPolicy), + }); + }; + + return { + registry, + createApi, + pushDiagnostic, + registerTool: actions.registerTool, + registerChannel: actions.registerChannel, + registerProvider, + registerGatewayMethod: actions.registerGatewayMethod, + registerCli: actions.registerCli, + registerService: actions.registerService, + registerCommand, + registerHook: actions.registerHook, + registerTypedHook: actions.registerTypedHook, + }; +} diff --git a/src/extension-host/contributions/cli-lifecycle.test.ts b/src/extension-host/contributions/cli-lifecycle.test.ts new file mode 100644 index 00000000000..504a9bce6bc --- /dev/null +++ b/src/extension-host/contributions/cli-lifecycle.test.ts @@ -0,0 +1,97 @@ +import { Command } from "commander"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createEmptyPluginRegistry } from "../../plugins/registry.js"; +import type { PluginLogger } from "../../plugins/types.js"; +import { registerExtensionHostCliCommands } from "./cli-lifecycle.js"; + +function createLogger(): PluginLogger { + return { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }; +} + +describe("registerExtensionHostCliCommands", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("skips overlapping command registrations", () => { + const program = new Command(); + program.command("memory"); + const registry = createEmptyPluginRegistry(); + const memoryRegister = vi.fn(); + const otherRegister = vi.fn(); + registry.cliRegistrars.push( + { + pluginId: "memory-core", + register: memoryRegister, + commands: ["memory"], + source: "bundled", + }, + { + pluginId: "other", + register: otherRegister, + commands: ["other"], + source: "bundled", + }, + ); + const logger = createLogger(); + + registerExtensionHostCliCommands({ + program, + registry, + config: {} as never, + workspaceDir: "/tmp/workspace", + logger, + }); + + expect(memoryRegister).not.toHaveBeenCalled(); + expect(otherRegister).toHaveBeenCalledOnce(); + expect(logger.debug).toHaveBeenCalledWith( + "plugin CLI register skipped (memory-core): command already registered (memory)", + ); + }); + + it("warns on sync and async registration failures", async () => { + const program = new Command(); + const registry = createEmptyPluginRegistry(); + registry.cliRegistrars.push( + { + pluginId: "sync-fail", + register: () => { + throw new Error("sync fail"); + }, + commands: ["sync"], + source: "bundled", + }, + { + pluginId: "async-fail", + register: async () => { + throw new Error("async fail"); + }, + commands: ["async"], + source: "bundled", + }, + ); + const logger = createLogger(); + + registerExtensionHostCliCommands({ + program, + registry, + config: {} as never, + workspaceDir: "/tmp/workspace", + logger, + }); + await Promise.resolve(); + + expect(logger.warn).toHaveBeenCalledWith( + "plugin CLI register failed (sync-fail): Error: sync fail", + ); + expect(logger.warn).toHaveBeenCalledWith( + "plugin CLI register failed (async-fail): Error: async fail", + ); + }); +}); diff --git a/src/extension-host/contributions/cli-lifecycle.ts b/src/extension-host/contributions/cli-lifecycle.ts new file mode 100644 index 00000000000..665db1b2f2e --- /dev/null +++ b/src/extension-host/contributions/cli-lifecycle.ts @@ -0,0 +1,47 @@ +import type { Command } from "commander"; +import type { OpenClawConfig } from "../../config/config.js"; +import type { PluginRegistry } from "../../plugins/registry.js"; +import type { PluginLogger } from "../../plugins/types.js"; +import { listExtensionHostCliRegistrations } from "./runtime-registry.js"; + +export function registerExtensionHostCliCommands(params: { + program: Command; + registry: PluginRegistry; + config: OpenClawConfig; + workspaceDir: string; + logger: PluginLogger; +}): void { + const existingCommands = new Set(params.program.commands.map((cmd) => cmd.name())); + + for (const entry of listExtensionHostCliRegistrations(params.registry)) { + if (entry.commands.length > 0) { + const overlaps = entry.commands.filter((command) => existingCommands.has(command)); + if (overlaps.length > 0) { + params.logger.debug( + `plugin CLI register skipped (${entry.pluginId}): command already registered (${overlaps.join( + ", ", + )})`, + ); + continue; + } + } + try { + const result = entry.register({ + program: params.program, + config: params.config, + workspaceDir: params.workspaceDir, + logger: params.logger, + }); + if (result && typeof result.then === "function") { + void result.catch((err) => { + params.logger.warn(`plugin CLI register failed (${entry.pluginId}): ${String(err)}`); + }); + } + for (const command of entry.commands) { + existingCommands.add(command); + } + } catch (err) { + params.logger.warn(`plugin CLI register failed (${entry.pluginId}): ${String(err)}`); + } + } +} diff --git a/src/extension-host/contributions/command-runtime.test.ts b/src/extension-host/contributions/command-runtime.test.ts new file mode 100644 index 00000000000..fe1cbd6eff2 --- /dev/null +++ b/src/extension-host/contributions/command-runtime.test.ts @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + clearExtensionHostPluginCommands, + getExtensionHostPluginCommandSpecs, + listExtensionHostPluginCommands, + registerExtensionHostPluginCommand, +} from "./command-runtime.js"; + +afterEach(() => { + clearExtensionHostPluginCommands(); +}); + +describe("extension host command runtime", () => { + it("rejects malformed runtime command shapes", () => { + const invalidName = registerExtensionHostPluginCommand("demo-plugin", { + name: undefined as unknown as string, + description: "Demo", + handler: async () => ({ text: "ok" }), + }); + expect(invalidName).toEqual({ + ok: false, + error: "Command name must be a string", + }); + + const invalidDescription = registerExtensionHostPluginCommand("demo-plugin", { + name: "demo", + description: undefined as unknown as string, + handler: async () => ({ text: "ok" }), + }); + expect(invalidDescription).toEqual({ + ok: false, + error: "Command description must be a string", + }); + }); + + it("normalizes command metadata for downstream consumers", () => { + const result = registerExtensionHostPluginCommand("demo-plugin", { + name: " demo_cmd ", + description: " Demo command ", + handler: async () => ({ text: "ok" }), + }); + expect(result).toEqual({ ok: true }); + expect(listExtensionHostPluginCommands()).toEqual([ + { + name: "demo_cmd", + description: "Demo command", + pluginId: "demo-plugin", + }, + ]); + expect(getExtensionHostPluginCommandSpecs()).toEqual([ + { + name: "demo_cmd", + description: "Demo command", + acceptsArgs: false, + }, + ]); + }); + + it("supports provider-specific native command aliases", () => { + const result = registerExtensionHostPluginCommand("demo-plugin", { + name: "voice", + nativeNames: { + default: "talkvoice", + discord: "discordvoice", + }, + description: "Demo command", + handler: async () => ({ text: "ok" }), + }); + + expect(result).toEqual({ ok: true }); + expect(getExtensionHostPluginCommandSpecs()).toEqual([ + { + name: "talkvoice", + description: "Demo command", + acceptsArgs: false, + }, + ]); + expect(getExtensionHostPluginCommandSpecs("discord")).toEqual([ + { + name: "discordvoice", + description: "Demo command", + acceptsArgs: false, + }, + ]); + expect(getExtensionHostPluginCommandSpecs("telegram")).toEqual([ + { + name: "talkvoice", + description: "Demo command", + acceptsArgs: false, + }, + ]); + }); +}); diff --git a/src/extension-host/contributions/command-runtime.ts b/src/extension-host/contributions/command-runtime.ts new file mode 100644 index 00000000000..afa666fc241 --- /dev/null +++ b/src/extension-host/contributions/command-runtime.ts @@ -0,0 +1,275 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import { logVerbose } from "../../globals.js"; +import type { + OpenClawPluginCommandDefinition, + PluginCommandContext, + PluginCommandResult, +} from "../../plugins/types.js"; + +export type RegisteredExtensionHostPluginCommand = OpenClawPluginCommandDefinition & { + pluginId: string; +}; + +const extensionHostPluginCommands = new Map(); + +let extensionHostCommandRegistryLocked = false; + +const MAX_ARGS_LENGTH = 4096; + +const RESERVED_COMMANDS = new Set([ + "help", + "commands", + "status", + "whoami", + "context", + "btw", + "stop", + "restart", + "reset", + "new", + "compact", + "config", + "debug", + "allowlist", + "activation", + "skill", + "subagents", + "kill", + "steer", + "tell", + "model", + "models", + "queue", + "send", + "bash", + "exec", + "think", + "verbose", + "reasoning", + "elevated", + "usage", +]); + +export type CommandRegistrationResult = { + ok: boolean; + error?: string; +}; + +export function validateExtensionHostCommandName(name: string): string | null { + const trimmed = name.trim().toLowerCase(); + + if (!trimmed) { + return "Command name cannot be empty"; + } + + if (!/^[a-z][a-z0-9_-]*$/.test(trimmed)) { + return "Command name must start with a letter and contain only letters, numbers, hyphens, and underscores"; + } + + if (RESERVED_COMMANDS.has(trimmed)) { + return `Command name "${trimmed}" is reserved by a built-in command`; + } + + return null; +} + +export function registerExtensionHostPluginCommand( + pluginId: string, + command: OpenClawPluginCommandDefinition, +): CommandRegistrationResult { + if (extensionHostCommandRegistryLocked) { + return { ok: false, error: "Cannot register commands while processing is in progress" }; + } + + if (typeof command.handler !== "function") { + return { ok: false, error: "Command handler must be a function" }; + } + + if (typeof command.name !== "string") { + return { ok: false, error: "Command name must be a string" }; + } + + if (typeof command.description !== "string") { + return { ok: false, error: "Command description must be a string" }; + } + + const name = command.name.trim(); + const description = command.description.trim(); + if (!description) { + return { ok: false, error: "Command description cannot be empty" }; + } + + const validationError = validateExtensionHostCommandName(name); + if (validationError) { + return { ok: false, error: validationError }; + } + + const key = `/${name.toLowerCase()}`; + const existing = extensionHostPluginCommands.get(key); + if (existing) { + return { + ok: false, + error: `Command "${name}" already registered by plugin "${existing.pluginId}"`, + }; + } + + extensionHostPluginCommands.set(key, { ...command, name, description, pluginId }); + logVerbose(`Registered plugin command: ${key} (plugin: ${pluginId})`); + return { ok: true }; +} + +export function clearExtensionHostPluginCommands(): void { + extensionHostPluginCommands.clear(); +} + +export function clearExtensionHostPluginCommandsForPlugin(pluginId: string): void { + for (const [key, cmd] of extensionHostPluginCommands.entries()) { + if (cmd.pluginId === pluginId) { + extensionHostPluginCommands.delete(key); + } + } +} + +export function matchExtensionHostPluginCommand( + commandBody: string, +): { command: RegisteredExtensionHostPluginCommand; args?: string } | null { + const trimmed = commandBody.trim(); + if (!trimmed.startsWith("/")) { + return null; + } + + const spaceIndex = trimmed.indexOf(" "); + const commandName = spaceIndex === -1 ? trimmed : trimmed.slice(0, spaceIndex); + const args = spaceIndex === -1 ? undefined : trimmed.slice(spaceIndex + 1).trim(); + + const command = extensionHostPluginCommands.get(commandName.toLowerCase()); + if (!command) { + return null; + } + + if (args && !command.acceptsArgs) { + return null; + } + + return { command, args: args || undefined }; +} + +function sanitizeArgs(args: string | undefined): string | undefined { + if (!args) { + return undefined; + } + + if (args.length > MAX_ARGS_LENGTH) { + return args.slice(0, MAX_ARGS_LENGTH); + } + + let sanitized = ""; + for (const char of args) { + const code = char.charCodeAt(0); + const isControl = (code <= 0x1f && code !== 0x09 && code !== 0x0a) || code === 0x7f; + if (!isControl) { + sanitized += char; + } + } + return sanitized; +} + +export async function executeExtensionHostPluginCommand(params: { + command: RegisteredExtensionHostPluginCommand; + args?: string; + senderId?: string; + channel: string; + channelId?: PluginCommandContext["channelId"]; + isAuthorizedSender: boolean; + commandBody: string; + config: OpenClawConfig; + from?: PluginCommandContext["from"]; + to?: PluginCommandContext["to"]; + accountId?: PluginCommandContext["accountId"]; + messageThreadId?: PluginCommandContext["messageThreadId"]; +}): Promise { + const { command, args, senderId, channel, isAuthorizedSender, commandBody, config } = params; + + const requireAuth = command.requireAuth !== false; + if (requireAuth && !isAuthorizedSender) { + logVerbose( + `Plugin command /${command.name} blocked: unauthorized sender ${senderId || ""}`, + ); + return { text: "⚠️ This command requires authorization." }; + } + + const ctx: PluginCommandContext = { + senderId, + channel, + channelId: params.channelId, + isAuthorizedSender, + args: sanitizeArgs(args), + commandBody, + config, + from: params.from, + to: params.to, + accountId: params.accountId, + messageThreadId: params.messageThreadId, + requestConversationBinding: async () => ({ + status: "error" as const, + message: "Conversation binding is unavailable for this command surface.", + }), + detachConversationBinding: async () => ({ removed: false }), + getCurrentConversationBinding: async () => null, + }; + + extensionHostCommandRegistryLocked = true; + try { + const result = await command.handler(ctx); + logVerbose( + `Plugin command /${command.name} executed successfully for ${senderId || "unknown"}`, + ); + return result; + } catch (err) { + const error = err as Error; + logVerbose(`Plugin command /${command.name} error: ${error.message}`); + return { text: "⚠️ Command failed. Please try again later." }; + } finally { + extensionHostCommandRegistryLocked = false; + } +} + +function resolveExtensionHostPluginNativeName( + command: OpenClawPluginCommandDefinition, + provider?: string, +): string { + const providerName = provider?.trim().toLowerCase(); + const providerOverride = providerName ? command.nativeNames?.[providerName] : undefined; + if (typeof providerOverride === "string" && providerOverride.trim()) { + return providerOverride.trim(); + } + const defaultOverride = command.nativeNames?.default; + if (typeof defaultOverride === "string" && defaultOverride.trim()) { + return defaultOverride.trim(); + } + return command.name; +} + +export function listExtensionHostPluginCommands(): Array<{ + name: string; + description: string; + pluginId: string; +}> { + return Array.from(extensionHostPluginCommands.values()).map((cmd) => ({ + name: cmd.name, + description: cmd.description, + pluginId: cmd.pluginId, + })); +} + +export function getExtensionHostPluginCommandSpecs(provider?: string): Array<{ + name: string; + description: string; + acceptsArgs: boolean; +}> { + return Array.from(extensionHostPluginCommands.values()).map((cmd) => ({ + name: resolveExtensionHostPluginNativeName(cmd, provider), + description: cmd.description, + acceptsArgs: cmd.acceptsArgs ?? false, + })); +} diff --git a/src/extension-host/contributions/gateway-methods.test.ts b/src/extension-host/contributions/gateway-methods.test.ts new file mode 100644 index 00000000000..e1704749b17 --- /dev/null +++ b/src/extension-host/contributions/gateway-methods.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from "vitest"; +import { createEmptyPluginRegistry } from "../../plugins/registry.js"; +import { + createExtensionHostGatewayExtraHandlers, + logExtensionHostPluginDiagnostics, + resolveExtensionHostGatewayMethods, +} from "./gateway-methods.js"; +import { setExtensionHostGatewayHandler } from "./runtime-registry.js"; + +describe("resolveExtensionHostGatewayMethods", () => { + it("adds plugin methods without duplicating base methods", () => { + const registry = createEmptyPluginRegistry(); + setExtensionHostGatewayHandler({ registry, method: "health", handler: vi.fn() }); + setExtensionHostGatewayHandler({ registry, method: "plugin.echo", handler: vi.fn() }); + + expect( + resolveExtensionHostGatewayMethods({ + registry, + baseMethods: ["health", "config.get"], + }), + ).toEqual(["health", "config.get", "plugin.echo"]); + }); +}); + +describe("createExtensionHostGatewayExtraHandlers", () => { + it("lets caller-provided handlers override plugin handlers", () => { + const pluginHandler = vi.fn(); + const callerHandler = vi.fn(); + const registry = createEmptyPluginRegistry(); + setExtensionHostGatewayHandler({ registry, method: "demo", handler: pluginHandler }); + + const handlers = createExtensionHostGatewayExtraHandlers({ + registry, + extraHandlers: { demo: callerHandler, health: vi.fn() }, + }); + + expect(handlers.demo).toBe(callerHandler); + expect(handlers.health).toBeTypeOf("function"); + }); +}); + +describe("logExtensionHostPluginDiagnostics", () => { + it("routes error diagnostics to error and others to info", () => { + const log = { + info: vi.fn(), + error: vi.fn(), + }; + + logExtensionHostPluginDiagnostics({ + diagnostics: [ + { + level: "warn", + pluginId: "demo", + source: "bundled", + message: "warned", + }, + { + level: "error", + pluginId: "demo", + source: "bundled", + message: "failed", + }, + ], + log, + }); + + expect(log.info).toHaveBeenCalledWith("[plugins] warned (plugin=demo, source=bundled)"); + expect(log.error).toHaveBeenCalledWith("[plugins] failed (plugin=demo, source=bundled)"); + }); +}); diff --git a/src/extension-host/contributions/gateway-methods.ts b/src/extension-host/contributions/gateway-methods.ts new file mode 100644 index 00000000000..5ab034e32c4 --- /dev/null +++ b/src/extension-host/contributions/gateway-methods.ts @@ -0,0 +1,48 @@ +import type { GatewayRequestHandlers } from "../../gateway/server-methods/types.js"; +import type { PluginRegistry } from "../../plugins/registry.js"; +import type { PluginDiagnostic } from "../../plugins/types.js"; +import { getExtensionHostGatewayHandlers } from "./runtime-registry.js"; + +export function resolveExtensionHostGatewayMethods(params: { + registry: PluginRegistry; + baseMethods: string[]; +}): string[] { + const pluginMethods = Object.keys(getExtensionHostGatewayHandlers(params.registry)); + return Array.from(new Set([...params.baseMethods, ...pluginMethods])); +} + +export function createExtensionHostGatewayExtraHandlers(params: { + registry: PluginRegistry; + extraHandlers?: GatewayRequestHandlers; +}): GatewayRequestHandlers { + const pluginHandlers = getExtensionHostGatewayHandlers(params.registry); + return { + ...pluginHandlers, + ...params.extraHandlers, + }; +} + +export function logExtensionHostPluginDiagnostics(params: { + diagnostics: PluginDiagnostic[]; + log: { + info: (msg: string) => void; + error: (msg: string) => void; + }; +}): void { + for (const diag of params.diagnostics) { + const details = [ + diag.pluginId ? `plugin=${diag.pluginId}` : null, + diag.source ? `source=${diag.source}` : null, + ] + .filter((entry): entry is string => Boolean(entry)) + .join(", "); + const message = details + ? `[plugins] ${diag.message} (${details})` + : `[plugins] ${diag.message}`; + if (diag.level === "error") { + params.log.error(message); + continue; + } + params.log.info(message); + } +} diff --git a/src/extension-host/contributions/provider-auth-flow.ts b/src/extension-host/contributions/provider-auth-flow.ts new file mode 100644 index 00000000000..a8262c41ab9 --- /dev/null +++ b/src/extension-host/contributions/provider-auth-flow.ts @@ -0,0 +1,233 @@ +import { resolveOpenClawAgentDir } from "../../agents/agent-paths.js"; +import { + resolveDefaultAgentId, + resolveAgentDir, + resolveAgentWorkspaceDir, +} from "../../agents/agent-scope.js"; +import { upsertAuthProfile } from "../../agents/auth-profiles.js"; +import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js"; +import type { + ApplyAuthChoiceParams, + ApplyAuthChoiceResult, +} from "../../commands/auth-choice.apply.js"; +import { isRemoteEnvironment } from "../../commands/oauth-env.js"; +import { createVpsAwareOAuthHandlers } from "../../commands/oauth-flow.js"; +import { applyAuthProfileConfig } from "../../commands/onboard-auth.js"; +import { openUrl } from "../../commands/onboard-helpers.js"; +import { enablePluginInConfig } from "../../plugins/enable.js"; +import { resolveProviderPluginChoice } from "../../plugins/provider-wizard.js"; +import { resolvePluginProviders } from "../../plugins/providers.js"; +import type { ProviderAuthMethod } from "../../plugins/types.js"; +import { + applyExtensionHostDefaultModel, + mergeExtensionHostConfigPatch, + pickExtensionHostAuthMethod, + resolveExtensionHostProviderMatch, +} from "./provider-auth.js"; +import { runExtensionHostProviderModelSelectedHook } from "./provider-model-selection.js"; + +export type ExtensionHostPluginProviderAuthChoiceOptions = { + authChoice: string; + pluginId: string; + providerId: string; + methodId?: string; + label: string; +}; + +export async function runExtensionHostProviderAuthMethod(params: { + config: ApplyAuthChoiceParams["config"]; + runtime: ApplyAuthChoiceParams["runtime"]; + prompter: ApplyAuthChoiceParams["prompter"]; + method: ProviderAuthMethod; + agentDir?: string; + agentId?: string; + workspaceDir?: string; + emitNotes?: boolean; +}): Promise<{ config: ApplyAuthChoiceParams["config"]; defaultModel?: string }> { + const agentId = params.agentId ?? resolveDefaultAgentId(params.config); + const defaultAgentId = resolveDefaultAgentId(params.config); + const agentDir = + params.agentDir ?? + (agentId === defaultAgentId + ? resolveOpenClawAgentDir() + : resolveAgentDir(params.config, agentId)); + const workspaceDir = + params.workspaceDir ?? + resolveAgentWorkspaceDir(params.config, agentId) ?? + resolveDefaultAgentWorkspaceDir(); + + const isRemote = isRemoteEnvironment(); + const result = await params.method.run({ + config: params.config, + agentDir, + workspaceDir, + prompter: params.prompter, + runtime: params.runtime, + isRemote, + openUrl: async (url) => { + await openUrl(url); + }, + oauth: { + createVpsAwareHandlers: (opts) => createVpsAwareOAuthHandlers(opts), + }, + }); + + let nextConfig = params.config; + if (result.configPatch) { + nextConfig = mergeExtensionHostConfigPatch(nextConfig, result.configPatch); + } + + for (const profile of result.profiles) { + upsertAuthProfile({ + profileId: profile.profileId, + credential: profile.credential, + agentDir, + }); + + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId: profile.profileId, + provider: profile.credential.provider, + mode: profile.credential.type === "token" ? "token" : profile.credential.type, + ...("email" in profile.credential && profile.credential.email + ? { email: profile.credential.email } + : {}), + }); + } + + if (params.emitNotes !== false && result.notes && result.notes.length > 0) { + await params.prompter.note(result.notes.join("\n"), "Provider notes"); + } + + return { + config: nextConfig, + defaultModel: result.defaultModel, + }; +} + +export async function applyExtensionHostLoadedPluginProvider( + params: ApplyAuthChoiceParams, +): Promise { + const agentId = params.agentId ?? resolveDefaultAgentId(params.config); + const workspaceDir = + resolveAgentWorkspaceDir(params.config, agentId) ?? resolveDefaultAgentWorkspaceDir(); + const providers = resolvePluginProviders({ config: params.config, workspaceDir }); + const resolved = resolveProviderPluginChoice({ + providers, + choice: params.authChoice, + }); + if (!resolved) { + return null; + } + + const applied = await runExtensionHostProviderAuthMethod({ + config: params.config, + runtime: params.runtime, + prompter: params.prompter, + method: resolved.method, + agentDir: params.agentDir, + agentId: params.agentId, + workspaceDir, + }); + + let agentModelOverride: string | undefined; + if (applied.defaultModel) { + if (params.setDefaultModel) { + const nextConfig = applyExtensionHostDefaultModel(applied.config, applied.defaultModel); + await runExtensionHostProviderModelSelectedHook({ + config: nextConfig, + model: applied.defaultModel, + prompter: params.prompter, + agentDir: params.agentDir, + workspaceDir, + }); + await params.prompter.note( + `Default model set to ${applied.defaultModel}`, + "Model configured", + ); + return { config: nextConfig }; + } + agentModelOverride = applied.defaultModel; + } + + return { config: applied.config, agentModelOverride }; +} + +export async function applyExtensionHostPluginProvider( + params: ApplyAuthChoiceParams, + options: ExtensionHostPluginProviderAuthChoiceOptions, +): Promise { + if (params.authChoice !== options.authChoice) { + return null; + } + + const enableResult = enablePluginInConfig(params.config, options.pluginId); + let nextConfig = enableResult.config; + if (!enableResult.enabled) { + await params.prompter.note( + `${options.label} plugin is disabled (${enableResult.reason ?? "blocked"}).`, + options.label, + ); + return { config: nextConfig }; + } + + const agentId = params.agentId ?? resolveDefaultAgentId(nextConfig); + const defaultAgentId = resolveDefaultAgentId(nextConfig); + const agentDir = + params.agentDir ?? + (agentId === defaultAgentId ? resolveOpenClawAgentDir() : resolveAgentDir(nextConfig, agentId)); + const workspaceDir = + resolveAgentWorkspaceDir(nextConfig, agentId) ?? resolveDefaultAgentWorkspaceDir(); + + const providers = resolvePluginProviders({ config: nextConfig, workspaceDir }); + const provider = resolveExtensionHostProviderMatch(providers, options.providerId); + if (!provider) { + await params.prompter.note( + `${options.label} auth plugin is not available. Enable it and re-run the wizard.`, + options.label, + ); + return { config: nextConfig }; + } + + const method = pickExtensionHostAuthMethod(provider, options.methodId) ?? provider.auth[0]; + if (!method) { + await params.prompter.note(`${options.label} auth method missing.`, options.label); + return { config: nextConfig }; + } + + const applied = await runExtensionHostProviderAuthMethod({ + config: nextConfig, + runtime: params.runtime, + prompter: params.prompter, + method, + agentDir, + agentId, + workspaceDir, + }); + nextConfig = applied.config; + + let agentModelOverride: string | undefined; + if (applied.defaultModel) { + if (params.setDefaultModel) { + nextConfig = applyExtensionHostDefaultModel(nextConfig, applied.defaultModel); + await runExtensionHostProviderModelSelectedHook({ + config: nextConfig, + model: applied.defaultModel, + prompter: params.prompter, + agentDir, + workspaceDir, + }); + await params.prompter.note( + `Default model set to ${applied.defaultModel}`, + "Model configured", + ); + } else if (params.agentId) { + agentModelOverride = applied.defaultModel; + await params.prompter.note( + `Default model set to ${applied.defaultModel} for agent "${params.agentId}".`, + "Model configured", + ); + } + } + + return { config: nextConfig, agentModelOverride }; +} diff --git a/src/extension-host/contributions/provider-auth.test.ts b/src/extension-host/contributions/provider-auth.test.ts new file mode 100644 index 00000000000..e049d877812 --- /dev/null +++ b/src/extension-host/contributions/provider-auth.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ProviderPlugin } from "../../plugins/types.js"; +import { + applyExtensionHostDefaultModel, + mergeExtensionHostConfigPatch, + pickExtensionHostAuthMethod, + resolveExtensionHostProviderMatch, +} from "./provider-auth.js"; + +function makeProvider(overrides: Partial & Pick) { + return { + auth: [], + ...overrides, + } satisfies ProviderPlugin; +} + +describe("resolveExtensionHostProviderMatch", () => { + it("matches providers by normalized id and aliases", () => { + const providers = [ + makeProvider({ + id: "openrouter", + label: "OpenRouter", + aliases: ["Open Router"], + }), + ]; + + expect(resolveExtensionHostProviderMatch(providers, "openrouter")?.id).toBe("openrouter"); + expect(resolveExtensionHostProviderMatch(providers, " Open Router ")?.id).toBe("openrouter"); + expect(resolveExtensionHostProviderMatch(providers, "missing")).toBeNull(); + }); +}); + +describe("pickExtensionHostAuthMethod", () => { + it("matches auth methods by id or label", () => { + const provider = makeProvider({ + id: "ollama", + label: "Ollama", + auth: [ + { id: "local", label: "Local", kind: "custom", run: vi.fn() }, + { id: "cloud", label: "Cloud", kind: "custom", run: vi.fn() }, + ], + }); + + expect(pickExtensionHostAuthMethod(provider, "local")?.id).toBe("local"); + expect(pickExtensionHostAuthMethod(provider, "cloud")?.id).toBe("cloud"); + expect(pickExtensionHostAuthMethod(provider, "Cloud")?.id).toBe("cloud"); + expect(pickExtensionHostAuthMethod(provider, "missing")).toBeNull(); + }); +}); + +describe("mergeExtensionHostConfigPatch", () => { + it("deep-merges plain record config patches", () => { + expect( + mergeExtensionHostConfigPatch( + { + models: { providers: { ollama: { baseUrl: "http://127.0.0.1:11434" } } }, + auth: { profiles: { existing: { provider: "anthropic" } } }, + }, + { + models: { providers: { ollama: { api: "ollama" } } }, + auth: { profiles: { fresh: { provider: "ollama" } } }, + }, + ), + ).toEqual({ + models: { providers: { ollama: { baseUrl: "http://127.0.0.1:11434", api: "ollama" } } }, + auth: { + profiles: { + existing: { provider: "anthropic" }, + fresh: { provider: "ollama" }, + }, + }, + }); + }); +}); + +describe("applyExtensionHostDefaultModel", () => { + it("sets the primary model while preserving fallback config", () => { + expect( + applyExtensionHostDefaultModel( + { + agents: { + defaults: { + model: { + primary: "anthropic/claude-sonnet-4-5", + fallbacks: ["openai/gpt-5"], + }, + }, + }, + }, + "ollama/qwen3:4b", + ), + ).toEqual({ + agents: { + defaults: { + models: { + "ollama/qwen3:4b": {}, + }, + model: { + primary: "ollama/qwen3:4b", + fallbacks: ["openai/gpt-5"], + }, + }, + }, + }); + }); +}); diff --git a/src/extension-host/contributions/provider-auth.ts b/src/extension-host/contributions/provider-auth.ts new file mode 100644 index 00000000000..9ac2947f8de --- /dev/null +++ b/src/extension-host/contributions/provider-auth.ts @@ -0,0 +1,82 @@ +import { normalizeProviderId } from "../../agents/provider-id.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import type { ProviderAuthMethod, ProviderPlugin } from "../../plugins/types.js"; + +export function resolveExtensionHostProviderMatch( + providers: ProviderPlugin[], + rawProvider?: string, +): ProviderPlugin | null { + const raw = rawProvider?.trim(); + if (!raw) { + return null; + } + const normalized = normalizeProviderId(raw); + return ( + providers.find((provider) => normalizeProviderId(provider.id) === normalized) ?? + providers.find( + (provider) => + provider.aliases?.some((alias) => normalizeProviderId(alias) === normalized) ?? false, + ) ?? + null + ); +} + +export function pickExtensionHostAuthMethod( + provider: ProviderPlugin, + rawMethod?: string, +): ProviderAuthMethod | null { + const raw = rawMethod?.trim(); + if (!raw) { + return null; + } + const normalized = raw.toLowerCase(); + return ( + provider.auth.find((method) => method.id.toLowerCase() === normalized) ?? + provider.auth.find((method) => method.label.toLowerCase() === normalized) ?? + null + ); +} + +function isPlainRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +export function mergeExtensionHostConfigPatch(base: T, patch: unknown): T { + if (!isPlainRecord(base) || !isPlainRecord(patch)) { + return patch as T; + } + + const next: Record = { ...base }; + for (const [key, value] of Object.entries(patch)) { + const existing = next[key]; + if (isPlainRecord(existing) && isPlainRecord(value)) { + next[key] = mergeExtensionHostConfigPatch(existing, value); + } else { + next[key] = value; + } + } + return next as T; +} + +export function applyExtensionHostDefaultModel(cfg: OpenClawConfig, model: string): OpenClawConfig { + const models = { ...cfg.agents?.defaults?.models }; + models[model] = models[model] ?? {}; + + const existingModel = cfg.agents?.defaults?.model; + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + models, + model: { + ...(existingModel && typeof existingModel === "object" && "fallbacks" in existingModel + ? { fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks } + : undefined), + primary: model, + }, + }, + }, + }; +} diff --git a/src/extension-host/contributions/provider-discovery.test.ts b/src/extension-host/contributions/provider-discovery.test.ts new file mode 100644 index 00000000000..cde7c52b822 --- /dev/null +++ b/src/extension-host/contributions/provider-discovery.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import type { ModelProviderConfig } from "../../config/types.js"; +import type { ProviderDiscoveryOrder, ProviderPlugin } from "../../plugins/types.js"; +import { + groupExtensionHostDiscoveryProvidersByOrder, + normalizeExtensionHostDiscoveryResult, + resolveExtensionHostDiscoveryProviders, +} from "./provider-discovery.js"; + +function makeProvider(params: { + id: string; + label?: string; + order?: ProviderDiscoveryOrder; + discovery?: boolean; +}): ProviderPlugin { + return { + id: params.id, + label: params.label ?? params.id, + auth: [], + ...(params.discovery === false + ? {} + : { + discovery: { + ...(params.order ? { order: params.order } : {}), + run: async () => null, + }, + }), + }; +} + +function makeModelProviderConfig(overrides?: Partial): ModelProviderConfig { + return { + baseUrl: "http://127.0.0.1:8000/v1", + models: [], + ...overrides, + }; +} + +describe("resolveExtensionHostDiscoveryProviders", () => { + it("keeps only providers with discovery handlers", () => { + expect( + resolveExtensionHostDiscoveryProviders([ + makeProvider({ id: "simple" }), + makeProvider({ id: "hidden", discovery: false }), + ]).map((provider) => provider.id), + ).toEqual(["simple"]); + }); +}); + +describe("groupExtensionHostDiscoveryProvidersByOrder", () => { + it("groups providers by declared order and sorts labels within each group", () => { + const grouped = groupExtensionHostDiscoveryProvidersByOrder([ + makeProvider({ id: "late-b", label: "Zulu" }), + makeProvider({ id: "late-a", label: "Alpha" }), + makeProvider({ id: "paired", label: "Paired", order: "paired" }), + makeProvider({ id: "profile", label: "Profile", order: "profile" }), + makeProvider({ id: "simple", label: "Simple", order: "simple" }), + ]); + + expect(grouped.simple.map((provider) => provider.id)).toEqual(["simple"]); + expect(grouped.profile.map((provider) => provider.id)).toEqual(["profile"]); + expect(grouped.paired.map((provider) => provider.id)).toEqual(["paired"]); + expect(grouped.late.map((provider) => provider.id)).toEqual(["late-a", "late-b"]); + }); +}); + +describe("normalizeExtensionHostDiscoveryResult", () => { + it("maps a single provider result to the provider id", () => { + const provider = makeProvider({ id: "Ollama" }); + const normalized = normalizeExtensionHostDiscoveryResult({ + provider, + result: { + provider: makeModelProviderConfig({ + baseUrl: "http://127.0.0.1:11434", + api: "ollama", + }), + }, + }); + + expect(normalized).toEqual({ + ollama: { + baseUrl: "http://127.0.0.1:11434", + api: "ollama", + models: [], + }, + }); + }); + + it("normalizes keys for multi-provider discovery results", () => { + const normalized = normalizeExtensionHostDiscoveryResult({ + provider: makeProvider({ id: "ignored" }), + result: { + providers: { + " VLLM ": makeModelProviderConfig(), + "": makeModelProviderConfig({ baseUrl: "http://ignored" }), + }, + }, + }); + + expect(normalized).toEqual({ + vllm: { + baseUrl: "http://127.0.0.1:8000/v1", + models: [], + }, + }); + }); +}); diff --git a/src/extension-host/contributions/provider-discovery.ts b/src/extension-host/contributions/provider-discovery.ts new file mode 100644 index 00000000000..9846e304015 --- /dev/null +++ b/src/extension-host/contributions/provider-discovery.ts @@ -0,0 +1,61 @@ +import { normalizeProviderId } from "../../agents/provider-id.js"; +import type { ModelProviderConfig } from "../../config/types.js"; +import type { ProviderDiscoveryOrder, ProviderPlugin } from "../../plugins/types.js"; + +const DISCOVERY_ORDER: readonly ProviderDiscoveryOrder[] = ["simple", "profile", "paired", "late"]; + +export function resolveExtensionHostDiscoveryProviders( + providers: ProviderPlugin[], +): ProviderPlugin[] { + return providers.filter((provider) => provider.discovery); +} + +export function groupExtensionHostDiscoveryProvidersByOrder( + providers: ProviderPlugin[], +): Record { + const grouped = { + simple: [], + profile: [], + paired: [], + late: [], + } as Record; + + for (const provider of providers) { + const order = provider.discovery?.order ?? "late"; + grouped[order].push(provider); + } + + for (const order of DISCOVERY_ORDER) { + grouped[order].sort((a, b) => a.label.localeCompare(b.label)); + } + + return grouped; +} + +export function normalizeExtensionHostDiscoveryResult(params: { + provider: ProviderPlugin; + result: + | { provider: ModelProviderConfig } + | { providers: Record } + | null + | undefined; +}): Record { + const result = params.result; + if (!result) { + return {}; + } + + if ("provider" in result) { + return { [normalizeProviderId(params.provider.id)]: result.provider }; + } + + const normalized: Record = {}; + for (const [key, value] of Object.entries(result.providers)) { + const normalizedKey = normalizeProviderId(key); + if (!normalizedKey || !value) { + continue; + } + normalized[normalizedKey] = value; + } + return normalized; +} diff --git a/src/extension-host/contributions/provider-model-selection.ts b/src/extension-host/contributions/provider-model-selection.ts new file mode 100644 index 00000000000..cf78b4ab545 --- /dev/null +++ b/src/extension-host/contributions/provider-model-selection.ts @@ -0,0 +1,40 @@ +import { DEFAULT_PROVIDER } from "../../agents/defaults.js"; +import { parseModelRef } from "../../agents/model-ref.js"; +import { normalizeProviderId } from "../../agents/provider-id.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import { resolvePluginProviders } from "../../plugins/providers.js"; +import type { WizardPrompter } from "../../wizard/prompts.js"; + +export async function runExtensionHostProviderModelSelectedHook(params: { + config: OpenClawConfig; + model: string; + prompter: WizardPrompter; + agentDir?: string; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): Promise { + const parsed = parseModelRef(params.model, DEFAULT_PROVIDER); + if (!parsed) { + return; + } + + const providers = resolvePluginProviders({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }); + const provider = providers.find( + (entry) => normalizeProviderId(entry.id) === normalizeProviderId(parsed.provider), + ); + if (!provider?.onModelSelected) { + return; + } + + await provider.onModelSelected({ + config: params.config, + model: params.model, + prompter: params.prompter, + agentDir: params.agentDir, + workspaceDir: params.workspaceDir, + }); +} diff --git a/src/extension-host/contributions/provider-runtime.test.ts b/src/extension-host/contributions/provider-runtime.test.ts new file mode 100644 index 00000000000..722375219e6 --- /dev/null +++ b/src/extension-host/contributions/provider-runtime.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { createEmptyPluginRegistry } from "../../plugins/registry.js"; +import { resolveExtensionHostProviders } from "./provider-runtime.js"; +import { addExtensionHostProviderRegistration } from "./runtime-registry.js"; + +describe("resolveExtensionHostProviders", () => { + it("projects provider registrations into provider plugins with plugin ids", () => { + const registry = createEmptyPluginRegistry(); + addExtensionHostProviderRegistration(registry, { + pluginId: "demo-plugin", + source: "bundled", + provider: { + id: "demo-provider", + label: "Demo Provider", + auth: [], + }, + }); + + expect(resolveExtensionHostProviders({ registry })).toEqual([ + { + id: "demo-provider", + label: "Demo Provider", + auth: [], + pluginId: "demo-plugin", + }, + ]); + }); +}); diff --git a/src/extension-host/contributions/provider-runtime.ts b/src/extension-host/contributions/provider-runtime.ts new file mode 100644 index 00000000000..ab45a16e05e --- /dev/null +++ b/src/extension-host/contributions/provider-runtime.ts @@ -0,0 +1,22 @@ +import type { PluginRegistry } from "../../plugins/registry.js"; +import type { ProviderPlugin } from "../../plugins/types.js"; +import { listExtensionHostProviderRegistrations } from "./runtime-registry.js"; + +export function resolveExtensionHostProviders(params: { + registry: Pick< + PluginRegistry, + | "channels" + | "tools" + | "providers" + | "cliRegistrars" + | "commands" + | "services" + | "httpRoutes" + | "gatewayHandlers" + >; +}): ProviderPlugin[] { + return listExtensionHostProviderRegistrations(params.registry).map((entry) => ({ + ...entry.provider, + pluginId: entry.pluginId, + })); +} diff --git a/src/extension-host/contributions/provider-wizard.test.ts b/src/extension-host/contributions/provider-wizard.test.ts new file mode 100644 index 00000000000..aa6e499122d --- /dev/null +++ b/src/extension-host/contributions/provider-wizard.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ProviderPlugin } from "../../plugins/types.js"; +import { + buildExtensionHostProviderMethodChoice, + resolveExtensionHostProviderChoice, + resolveExtensionHostProviderModelPickerEntries, + resolveExtensionHostProviderWizardOptions, +} from "./provider-wizard.js"; + +function makeProvider(overrides: Partial & Pick) { + return { + auth: [], + ...overrides, + } satisfies ProviderPlugin; +} + +describe("resolveExtensionHostProviderWizardOptions", () => { + it("uses explicit onboarding choice ids and bound method ids", () => { + const provider = makeProvider({ + id: "vllm", + label: "vLLM", + auth: [ + { id: "local", label: "Local", kind: "custom", run: vi.fn() }, + { id: "cloud", label: "Cloud", kind: "custom", run: vi.fn() }, + ], + wizard: { + onboarding: { + choiceId: "self-hosted-vllm", + methodId: "local", + choiceLabel: "vLLM local", + groupId: "local-runtimes", + groupLabel: "Local runtimes", + }, + }, + }); + + expect(resolveExtensionHostProviderWizardOptions([provider])).toEqual([ + { + value: "self-hosted-vllm", + label: "vLLM local", + groupId: "local-runtimes", + groupLabel: "Local runtimes", + }, + ]); + expect( + resolveExtensionHostProviderChoice({ + providers: [provider], + choice: "self-hosted-vllm", + }), + ).toEqual({ + provider, + method: provider.auth[0], + }); + }); +}); + +describe("resolveExtensionHostProviderModelPickerEntries", () => { + it("builds model-picker entries from provider metadata", () => { + const provider = makeProvider({ + id: "sglang", + label: "SGLang", + auth: [ + { id: "server", label: "Server", kind: "custom", run: vi.fn() }, + { id: "cloud", label: "Cloud", kind: "custom", run: vi.fn() }, + ], + wizard: { + modelPicker: { + label: "SGLang server", + hint: "OpenAI-compatible local runtime", + methodId: "server", + }, + }, + }); + + expect(resolveExtensionHostProviderModelPickerEntries([provider])).toEqual([ + { + value: buildExtensionHostProviderMethodChoice("sglang", "server"), + label: "SGLang server", + hint: "OpenAI-compatible local runtime", + }, + ]); + }); +}); diff --git a/src/extension-host/contributions/provider-wizard.ts b/src/extension-host/contributions/provider-wizard.ts new file mode 100644 index 00000000000..afb5fd9055c --- /dev/null +++ b/src/extension-host/contributions/provider-wizard.ts @@ -0,0 +1,201 @@ +import { normalizeProviderId } from "../../agents/provider-id.js"; +import type { + ProviderAuthMethod, + ProviderPlugin, + ProviderPluginWizardModelPicker, + ProviderPluginWizardOnboarding, +} from "../../plugins/types.js"; + +export const EXTENSION_HOST_PROVIDER_CHOICE_PREFIX = "provider-plugin:"; + +export type ExtensionHostProviderWizardOption = { + value: string; + label: string; + hint?: string; + groupId: string; + groupLabel: string; + groupHint?: string; +}; + +export type ExtensionHostProviderModelPickerEntry = { + value: string; + label: string; + hint?: string; +}; + +function normalizeChoiceId(choiceId: string): string { + return choiceId.trim(); +} + +function resolveWizardOnboardingChoiceId( + provider: ProviderPlugin, + wizard: ProviderPluginWizardOnboarding, +): string { + const explicit = wizard.choiceId?.trim(); + if (explicit) { + return explicit; + } + const explicitMethodId = wizard.methodId?.trim(); + if (explicitMethodId) { + return buildExtensionHostProviderMethodChoice(provider.id, explicitMethodId); + } + if (provider.auth.length === 1) { + return provider.id; + } + return buildExtensionHostProviderMethodChoice(provider.id, provider.auth[0]?.id ?? "default"); +} + +function resolveMethodById( + provider: ProviderPlugin, + methodId?: string, +): ProviderAuthMethod | undefined { + const normalizedMethodId = methodId?.trim().toLowerCase(); + if (!normalizedMethodId) { + return provider.auth[0]; + } + return provider.auth.find((method) => method.id.trim().toLowerCase() === normalizedMethodId); +} + +function buildOnboardingOptionForMethod(params: { + provider: ProviderPlugin; + wizard: ProviderPluginWizardOnboarding; + method: ProviderAuthMethod; + value: string; +}): ExtensionHostProviderWizardOption { + const normalizedGroupId = params.wizard.groupId?.trim() || params.provider.id; + return { + value: normalizeChoiceId(params.value), + label: + params.wizard.choiceLabel?.trim() || + (params.provider.auth.length === 1 ? params.provider.label : params.method.label), + hint: params.wizard.choiceHint?.trim() || params.method.hint, + groupId: normalizedGroupId, + groupLabel: params.wizard.groupLabel?.trim() || params.provider.label, + groupHint: params.wizard.groupHint?.trim(), + }; +} + +function resolveModelPickerChoiceValue( + provider: ProviderPlugin, + modelPicker: ProviderPluginWizardModelPicker, +): string { + const explicitMethodId = modelPicker.methodId?.trim(); + if (explicitMethodId) { + return buildExtensionHostProviderMethodChoice(provider.id, explicitMethodId); + } + if (provider.auth.length === 1) { + return provider.id; + } + return buildExtensionHostProviderMethodChoice(provider.id, provider.auth[0]?.id ?? "default"); +} + +export function buildExtensionHostProviderMethodChoice( + providerId: string, + methodId: string, +): string { + return `${EXTENSION_HOST_PROVIDER_CHOICE_PREFIX}${providerId.trim()}:${methodId.trim()}`; +} + +export function resolveExtensionHostProviderWizardOptions( + providers: ProviderPlugin[], +): ExtensionHostProviderWizardOption[] { + const options: ExtensionHostProviderWizardOption[] = []; + + for (const provider of providers) { + const wizard = provider.wizard?.onboarding; + if (!wizard) { + continue; + } + const explicitMethod = resolveMethodById(provider, wizard.methodId); + if (explicitMethod) { + options.push( + buildOnboardingOptionForMethod({ + provider, + wizard, + method: explicitMethod, + value: resolveWizardOnboardingChoiceId(provider, wizard), + }), + ); + continue; + } + + for (const method of provider.auth) { + options.push( + buildOnboardingOptionForMethod({ + provider, + wizard, + method, + value: buildExtensionHostProviderMethodChoice(provider.id, method.id), + }), + ); + } + } + + return options; +} + +export function resolveExtensionHostProviderModelPickerEntries( + providers: ProviderPlugin[], +): ExtensionHostProviderModelPickerEntry[] { + const entries: ExtensionHostProviderModelPickerEntry[] = []; + + for (const provider of providers) { + const modelPicker = provider.wizard?.modelPicker; + if (!modelPicker) { + continue; + } + entries.push({ + value: resolveModelPickerChoiceValue(provider, modelPicker), + label: modelPicker.label?.trim() || `${provider.label} (custom)`, + hint: modelPicker.hint?.trim(), + }); + } + + return entries; +} + +export function resolveExtensionHostProviderChoice(params: { + providers: ProviderPlugin[]; + choice: string; +}): { provider: ProviderPlugin; method: ProviderAuthMethod } | null { + const choice = params.choice.trim(); + if (!choice) { + return null; + } + + if (choice.startsWith(EXTENSION_HOST_PROVIDER_CHOICE_PREFIX)) { + const payload = choice.slice(EXTENSION_HOST_PROVIDER_CHOICE_PREFIX.length); + const separator = payload.indexOf(":"); + const providerId = separator >= 0 ? payload.slice(0, separator) : payload; + const methodId = separator >= 0 ? payload.slice(separator + 1) : undefined; + const provider = params.providers.find( + (entry) => normalizeProviderId(entry.id) === normalizeProviderId(providerId), + ); + if (!provider) { + return null; + } + const method = resolveMethodById(provider, methodId); + return method ? { provider, method } : null; + } + + for (const provider of params.providers) { + const onboarding = provider.wizard?.onboarding; + if (onboarding) { + const onboardingChoiceId = resolveWizardOnboardingChoiceId(provider, onboarding); + if (normalizeChoiceId(onboardingChoiceId) === choice) { + const method = resolveMethodById(provider, onboarding.methodId); + if (method) { + return { provider, method }; + } + } + } + if ( + normalizeProviderId(provider.id) === normalizeProviderId(choice) && + provider.auth.length > 0 + ) { + return { provider, method: provider.auth[0] }; + } + } + + return null; +} diff --git a/src/extension-host/contributions/registry-writes.test.ts b/src/extension-host/contributions/registry-writes.test.ts new file mode 100644 index 00000000000..7d96daa361a --- /dev/null +++ b/src/extension-host/contributions/registry-writes.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it, vi } from "vitest"; +import { createEmptyPluginRegistry, type PluginRecord } from "../../plugins/registry.js"; +import { + addExtensionChannelRegistration, + addExtensionCliRegistration, + addExtensionCommandRegistration, + addExtensionContextEngineRegistration, + addExtensionGatewayMethodRegistration, + addExtensionLegacyHookRegistration, + addExtensionHttpRouteRegistration, + addExtensionProviderRegistration, + addExtensionServiceRegistration, + addExtensionToolRegistration, + addExtensionTypedHookRegistration, +} from "./registry-writes.js"; + +function createRecord(): PluginRecord { + return { + id: "demo", + name: "Demo", + source: "/plugins/demo.ts", + origin: "workspace", + enabled: true, + status: "loaded", + toolNames: [], + hookNames: [], + channelIds: [], + providerIds: [], + gatewayMethods: [], + cliCommands: [], + services: [], + commands: [], + httpRoutes: 0, + hookCount: 0, + configSchema: false, + }; +} + +describe("extension host registry writes", () => { + it("writes tool registrations through the host helper", () => { + const registry = createEmptyPluginRegistry(); + const record = createRecord(); + + addExtensionToolRegistration({ + registry, + record, + names: ["tool-a"], + entry: { + pluginId: record.id, + factory: (() => ({}) as never) as never, + names: ["tool-a"], + optional: false, + source: record.source, + }, + }); + + expect(record.toolNames).toEqual(["tool-a"]); + expect(registry.tools).toHaveLength(1); + }); + + it("writes cli, service, and command registrations through host helpers", () => { + const registry = createEmptyPluginRegistry(); + const record = createRecord(); + + addExtensionCliRegistration({ + registry, + record, + commands: ["demo"], + entry: { + pluginId: record.id, + register: (() => {}) as never, + commands: ["demo"], + source: record.source, + }, + }); + addExtensionServiceRegistration({ + registry, + record, + serviceId: "svc", + entry: { + pluginId: record.id, + service: { id: "svc", start: async () => {}, stop: async () => {} } as never, + source: record.source, + }, + }); + addExtensionCommandRegistration({ + registry, + record, + commandName: "cmd", + entry: { + pluginId: record.id, + command: { name: "cmd", description: "demo", run: async () => {} } as never, + source: record.source, + }, + }); + + expect(record.cliCommands).toEqual(["demo"]); + expect(record.services).toEqual(["svc"]); + expect(record.commands).toEqual(["cmd"]); + expect(registry.cliRegistrars).toHaveLength(1); + expect(registry.services).toHaveLength(1); + expect(registry.commands).toHaveLength(1); + }); + + it("writes gateway, http, channel, and provider registrations through host helpers", () => { + const registry = createEmptyPluginRegistry(); + const record = createRecord(); + + addExtensionGatewayMethodRegistration({ + registry, + record, + method: "demo.method", + handler: (() => {}) as never, + }); + addExtensionHttpRouteRegistration({ + registry, + record, + action: "append", + entry: { + pluginId: record.id, + path: "/demo", + handler: (() => {}) as never, + auth: "optional", + match: "exact", + source: record.source, + }, + }); + addExtensionChannelRegistration({ + registry, + record, + channelId: "demo-channel", + entry: { + pluginId: record.id, + plugin: {} as never, + source: record.source, + }, + }); + addExtensionProviderRegistration({ + registry, + record, + providerId: "demo-provider", + entry: { + pluginId: record.id, + provider: {} as never, + source: record.source, + }, + }); + + expect(record.gatewayMethods).toEqual(["demo.method"]); + expect(record.httpRoutes).toBe(1); + expect(record.channelIds).toEqual(["demo-channel"]); + expect(record.providerIds).toEqual(["demo-provider"]); + expect(registry.gatewayHandlers["demo.method"]).toBeTypeOf("function"); + expect(registry.httpRoutes).toHaveLength(1); + expect(registry.channels).toHaveLength(1); + expect(registry.providers).toHaveLength(1); + expect(registry.providers[0]?.pluginId).toBe("demo"); + }); + + it("writes legacy hooks, typed hooks, and context engines through host helpers", () => { + const registry = createEmptyPluginRegistry(); + const record = createRecord(); + const registerEngine = vi.fn(); + + addExtensionLegacyHookRegistration({ + registry, + record, + hookName: "before_send", + events: ["before_send"], + entry: { + pluginId: record.id, + entry: {} as never, + events: ["before_send"], + source: record.source, + handler: (() => {}) as never, + }, + }); + addExtensionTypedHookRegistration({ + registry, + record, + entry: { + pluginId: record.id, + hookName: "before_send" as never, + handler: (() => {}) as never, + priority: 0, + source: record.source, + } as never, + }); + addExtensionContextEngineRegistration({ + entry: { + engineId: "context-demo", + factory: (() => ({}) as never) as never, + }, + registerEngine, + }); + + expect(record.hookNames).toEqual(["before_send"]); + expect(record.hookCount).toBe(1); + expect(registry.hooks).toHaveLength(1); + expect(registry.typedHooks).toHaveLength(1); + expect(registerEngine).toHaveBeenCalledWith("context-demo", expect.any(Function)); + }); +}); diff --git a/src/extension-host/contributions/registry-writes.ts b/src/extension-host/contributions/registry-writes.ts new file mode 100644 index 00000000000..3e7d4e35ced --- /dev/null +++ b/src/extension-host/contributions/registry-writes.ts @@ -0,0 +1,169 @@ +import { registerContextEngine, type ContextEngineFactory } from "../../context-engine/registry.js"; +import type { GatewayRequestHandler } from "../../gateway/server-methods/types.js"; +import type { + PluginChannelRegistration, + PluginCliRegistration, + PluginCommandRegistration, + PluginHookRegistration, + PluginHttpRouteRegistration, + PluginRecord, + PluginRegistry, + PluginProviderRegistration, + PluginServiceRegistration, + PluginToolRegistration, +} from "../../plugins/registry.js"; +import type { PluginHookRegistration as TypedPluginHookRegistration } from "../../plugins/types.js"; +import type { + ExtensionHostChannelRegistration, + ExtensionHostCliRegistration, + ExtensionHostCommandRegistration, + ExtensionHostContextEngineRegistration, + ExtensionHostLegacyHookRegistration, + ExtensionHostHttpRouteRegistration, + ExtensionHostProviderRegistration, + ExtensionHostServiceRegistration, + ExtensionHostToolRegistration, +} from "./runtime-registrations.js"; +import { + addExtensionHostChannelRegistration, + addExtensionHostCliRegistration, + addExtensionHostCommandRegistration, + addExtensionHostHttpRoute, + addExtensionHostProviderRegistration, + addExtensionHostServiceRegistration, + addExtensionHostToolRegistration, + replaceExtensionHostHttpRoute, + setExtensionHostGatewayHandler, +} from "./runtime-registry.js"; + +export function addExtensionGatewayMethodRegistration(params: { + registry: PluginRegistry; + record: PluginRecord; + method: string; + handler: GatewayRequestHandler; +}): void { + setExtensionHostGatewayHandler({ + registry: params.registry, + method: params.method, + handler: params.handler, + }); + params.record.gatewayMethods.push(params.method); +} + +export function addExtensionHttpRouteRegistration(params: { + registry: PluginRegistry; + record: PluginRecord; + entry: ExtensionHostHttpRouteRegistration; + action: "replace" | "append"; + existingIndex?: number; +}): void { + if (params.action === "replace") { + if (params.existingIndex === undefined) { + return; + } + replaceExtensionHostHttpRoute({ + registry: params.registry, + index: params.existingIndex, + entry: params.entry as PluginHttpRouteRegistration, + }); + return; + } + + params.record.httpRoutes += 1; + addExtensionHostHttpRoute(params.registry, params.entry as PluginHttpRouteRegistration); +} + +export function addExtensionChannelRegistration(params: { + registry: PluginRegistry; + record: PluginRecord; + channelId: string; + entry: ExtensionHostChannelRegistration; +}): void { + params.record.channelIds.push(params.channelId); + addExtensionHostChannelRegistration(params.registry, params.entry as PluginChannelRegistration); +} + +export function addExtensionProviderRegistration(params: { + registry: PluginRegistry; + record: PluginRecord; + providerId: string; + entry: ExtensionHostProviderRegistration; +}): void { + params.record.providerIds.push(params.providerId); + addExtensionHostProviderRegistration(params.registry, params.entry as PluginProviderRegistration); +} + +export function addExtensionLegacyHookRegistration(params: { + registry: PluginRegistry; + record: PluginRecord; + hookName: string; + entry: ExtensionHostLegacyHookRegistration; + events: string[]; +}): void { + params.record.hookNames.push(params.hookName); + params.registry.hooks.push({ + pluginId: params.entry.pluginId, + entry: params.entry.entry, + events: params.events, + source: params.entry.source, + } as PluginHookRegistration); +} + +export function addExtensionTypedHookRegistration(params: { + registry: PluginRegistry; + record: PluginRecord; + entry: TypedPluginHookRegistration; +}): void { + params.record.hookCount += 1; + params.registry.typedHooks.push(params.entry); +} + +export function addExtensionToolRegistration(params: { + registry: PluginRegistry; + record: PluginRecord; + names: string[]; + entry: ExtensionHostToolRegistration; +}): void { + if (params.names.length > 0) { + params.record.toolNames.push(...params.names); + } + addExtensionHostToolRegistration(params.registry, params.entry as PluginToolRegistration); +} + +export function addExtensionCliRegistration(params: { + registry: PluginRegistry; + record: PluginRecord; + commands: string[]; + entry: ExtensionHostCliRegistration; +}): void { + params.record.cliCommands.push(...params.commands); + addExtensionHostCliRegistration(params.registry, params.entry as PluginCliRegistration); +} + +export function addExtensionServiceRegistration(params: { + registry: PluginRegistry; + record: PluginRecord; + serviceId: string; + entry: ExtensionHostServiceRegistration; +}): void { + params.record.services.push(params.serviceId); + addExtensionHostServiceRegistration(params.registry, params.entry as PluginServiceRegistration); +} + +export function addExtensionCommandRegistration(params: { + registry: PluginRegistry; + record: PluginRecord; + commandName: string; + entry: ExtensionHostCommandRegistration; +}): void { + params.record.commands.push(params.commandName); + addExtensionHostCommandRegistration(params.registry, params.entry as PluginCommandRegistration); +} + +export function addExtensionContextEngineRegistration(params: { + entry: ExtensionHostContextEngineRegistration; + registerEngine?: (engineId: string, factory: ContextEngineFactory) => void; +}): void { + const registerEngine = params.registerEngine ?? registerContextEngine; + registerEngine(params.entry.engineId, params.entry.factory); +} diff --git a/src/extension-host/contributions/runtime-registrations.test.ts b/src/extension-host/contributions/runtime-registrations.test.ts new file mode 100644 index 00000000000..6a81f95980c --- /dev/null +++ b/src/extension-host/contributions/runtime-registrations.test.ts @@ -0,0 +1,524 @@ +import { describe, expect, it, vi } from "vitest"; +import type { AnyAgentTool } from "../../agents/tools/common.js"; +import type { ChannelPlugin } from "../../channels/plugins/types.js"; +import type { ContextEngineFactory } from "../../context-engine/registry.js"; +import type { InternalHookHandler } from "../../hooks/internal-hooks.js"; +import type { HookEntry } from "../../hooks/types.js"; +import type { + OpenClawPluginCliContext, + OpenClawPluginCommandDefinition, + OpenClawPluginHookOptions, + OpenClawPluginService, + PluginHookRegistration, + ProviderPlugin, +} from "../../plugins/types.js"; +import { + resolveExtensionChannelRegistration, + resolveExtensionCliRegistration, + resolveExtensionCommandRegistration, + resolveExtensionContextEngineRegistration, + resolveExtensionGatewayMethodRegistration, + resolveExtensionLegacyHookRegistration, + resolveExtensionHttpRouteRegistration, + resolveExtensionProviderRegistration, + resolveExtensionServiceRegistration, + resolveExtensionToolRegistration, + resolveExtensionTypedHookRegistration, + type ExtensionHostChannelRegistration, + type ExtensionHostHttpRouteRegistration, + type ExtensionHostProviderRegistration, +} from "./runtime-registrations.js"; + +function createChannelPlugin(id: string): ChannelPlugin { + return { + id, + meta: { + id, + label: id, + selectionLabel: id, + docsPath: `/channels/${id}`, + blurb: "test", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({}), + }, + }; +} + +function createProviderPlugin(id: string): ProviderPlugin { + return { + id, + label: id, + auth: [], + }; +} + +function createService(id: string): OpenClawPluginService { + return { + id, + start: vi.fn(), + }; +} + +function createCommand(name: string): OpenClawPluginCommandDefinition { + return { + name, + description: "demo command", + handler: vi.fn(), + }; +} + +function createLegacyHookEntry(name: string): HookEntry { + return { + hook: { + name, + description: "hook description", + source: "openclaw-plugin", + pluginId: "demo-plugin", + filePath: "/demo/plugin.ts", + baseDir: "/demo", + handlerPath: "/demo/plugin.ts", + }, + frontmatter: {}, + metadata: { events: ["message:received"] }, + invocation: { enabled: true }, + }; +} + +describe("runtime registration helpers", () => { + it("normalizes tool registration metadata", () => { + const tool = { name: "demo-tool" } as AnyAgentTool; + const result = resolveExtensionToolRegistration({ + ownerPluginId: "tool-plugin", + ownerSource: "tool-source", + tool, + opts: { + names: [" demo-tool ", "alias"], + optional: true, + }, + }); + + expect(result).toMatchObject({ + names: ["demo-tool", "alias"], + entry: { + pluginId: "tool-plugin", + names: ["demo-tool", "alias"], + optional: true, + source: "tool-source", + }, + }); + expect(result.entry.factory({} as never)).toBe(tool); + }); + + it("normalizes cli registration metadata", () => { + const registrar = (_ctx: OpenClawPluginCliContext) => {}; + const result = resolveExtensionCliRegistration({ + ownerPluginId: "cli-plugin", + ownerSource: "cli-source", + registrar, + opts: { commands: [" foo ", "bar", "foo"] }, + }); + + expect(result).toEqual({ + commands: ["foo", "bar"], + entry: { + pluginId: "cli-plugin", + register: registrar, + commands: ["foo", "bar"], + source: "cli-source", + }, + }); + }); + + it("normalizes service registrations", () => { + const result = resolveExtensionServiceRegistration({ + ownerPluginId: "service-plugin", + ownerSource: "service-source", + service: createService(" demo-service "), + }); + + expect(result).toMatchObject({ + ok: true, + serviceId: "demo-service", + entry: { + pluginId: "service-plugin", + source: "service-source", + service: { id: "demo-service" }, + }, + }); + }); + + it("rejects service registrations without ids", () => { + const result = resolveExtensionServiceRegistration({ + ownerPluginId: "service-plugin", + ownerSource: "service-source", + service: createService(" "), + }); + + expect(result).toEqual({ + ok: false, + message: "service registration missing id", + }); + }); + + it("normalizes command registrations", () => { + const result = resolveExtensionCommandRegistration({ + ownerPluginId: "command-plugin", + ownerSource: "command-source", + command: createCommand(" demo "), + }); + + expect(result).toMatchObject({ + ok: true, + commandName: "demo", + entry: { + pluginId: "command-plugin", + source: "command-source", + command: { name: "demo" }, + }, + }); + }); + + it("rejects command registrations without names", () => { + const result = resolveExtensionCommandRegistration({ + ownerPluginId: "command-plugin", + ownerSource: "command-source", + command: createCommand(" "), + }); + + expect(result).toEqual({ + ok: false, + message: "command registration missing name", + }); + }); + + it("normalizes context-engine registrations", () => { + const factory = vi.fn() as unknown as ContextEngineFactory; + const result = resolveExtensionContextEngineRegistration({ + engineId: " demo-engine ", + factory, + }); + + expect(result).toEqual({ + ok: true, + entry: { + engineId: "demo-engine", + factory, + }, + }); + }); + + it("rejects context-engine registrations without ids", () => { + const result = resolveExtensionContextEngineRegistration({ + engineId: " ", + factory: vi.fn() as unknown as ContextEngineFactory, + }); + + expect(result).toEqual({ + ok: false, + message: "context engine registration missing id", + }); + }); + + it("normalizes legacy hook registrations", () => { + const handler = vi.fn() as unknown as InternalHookHandler; + const result = resolveExtensionLegacyHookRegistration({ + ownerPluginId: "hook-plugin", + ownerSource: "/plugins/hook.ts", + events: [" message:received ", "message:received", "message:sent"], + handler, + opts: { + name: "demo-hook", + description: "hook description", + } satisfies OpenClawPluginHookOptions, + }); + + expect(result).toMatchObject({ + ok: true, + hookName: "demo-hook", + events: ["message:received", "message:sent"], + entry: { + pluginId: "hook-plugin", + source: "/plugins/hook.ts", + }, + }); + }); + + it("preserves explicit legacy hook entries while normalizing events", () => { + const result = resolveExtensionLegacyHookRegistration({ + ownerPluginId: "hook-plugin", + ownerSource: "/plugins/hook.ts", + events: " message:received ", + handler: vi.fn() as unknown as InternalHookHandler, + opts: { + entry: createLegacyHookEntry("demo-hook"), + }, + }); + + expect(result).toMatchObject({ + ok: true, + hookName: "demo-hook", + events: ["message:received"], + }); + if (result.ok) { + expect(result.entry.entry.hook.pluginId).toBe("hook-plugin"); + expect(result.entry.entry.metadata?.events).toEqual(["message:received"]); + } + }); + + it("rejects legacy hook registrations without names", () => { + const result = resolveExtensionLegacyHookRegistration({ + ownerPluginId: "hook-plugin", + ownerSource: "/plugins/hook.ts", + events: "message:received", + handler: vi.fn() as unknown as InternalHookHandler, + opts: {}, + }); + + expect(result).toEqual({ + ok: false, + message: "hook registration missing name", + }); + }); + + it("normalizes typed hook registrations", () => { + const handler = vi.fn() as PluginHookRegistration<"before_prompt_build">["handler"]; + const result = resolveExtensionTypedHookRegistration({ + ownerPluginId: "typed-hook-plugin", + ownerSource: "/plugins/typed-hook.ts", + hookName: "before_prompt_build", + handler, + priority: 10, + }); + + expect(result).toEqual({ + ok: true, + hookName: "before_prompt_build", + entry: { + pluginId: "typed-hook-plugin", + hookName: "before_prompt_build", + handler, + priority: 10, + source: "/plugins/typed-hook.ts", + }, + }); + }); + + it("rejects unknown typed hook registrations", () => { + const result = resolveExtensionTypedHookRegistration({ + ownerPluginId: "typed-hook-plugin", + ownerSource: "/plugins/typed-hook.ts", + hookName: "totally_unknown_hook_name", + handler: vi.fn() as never, + priority: 10, + }); + + expect(result).toEqual({ + ok: false, + message: 'unknown typed hook "totally_unknown_hook_name" ignored', + }); + }); + + it("normalizes and accepts a unique channel registration", () => { + const result = resolveExtensionChannelRegistration({ + existing: [], + ownerPluginId: "demo-plugin", + ownerSource: "demo-source", + registration: createChannelPlugin("demo-channel"), + }); + + expect(result).toMatchObject({ + ok: true, + channelId: "demo-channel", + entry: { + pluginId: "demo-plugin", + source: "demo-source", + }, + }); + }); + + it("rejects duplicate channel registrations", () => { + const existing: ExtensionHostChannelRegistration[] = [ + { + pluginId: "demo-a", + plugin: createChannelPlugin("demo-channel"), + source: "demo-a-source", + }, + ]; + + const result = resolveExtensionChannelRegistration({ + existing, + ownerPluginId: "demo-b", + ownerSource: "demo-b-source", + registration: createChannelPlugin("demo-channel"), + }); + + expect(result).toEqual({ + ok: false, + message: "channel already registered: demo-channel (demo-a)", + }); + }); + + it("accepts a unique provider registration", () => { + const result = resolveExtensionProviderRegistration({ + existing: [], + ownerPluginId: "provider-plugin", + ownerSource: "provider-source", + provider: createProviderPlugin("demo-provider"), + }); + + expect(result).toMatchObject({ + ok: true, + providerId: "demo-provider", + entry: { + pluginId: "provider-plugin", + source: "provider-source", + }, + }); + }); + + it("rejects duplicate provider registrations", () => { + const existing: ExtensionHostProviderRegistration[] = [ + { + pluginId: "provider-a", + provider: createProviderPlugin("demo-provider"), + source: "provider-a-source", + }, + ]; + + const result = resolveExtensionProviderRegistration({ + existing, + ownerPluginId: "provider-b", + ownerSource: "provider-b-source", + provider: createProviderPlugin("demo-provider"), + }); + + expect(result).toEqual({ + ok: false, + message: "provider already registered: demo-provider (provider-a)", + }); + }); + + it("accepts a unique http route registration", () => { + const result = resolveExtensionHttpRouteRegistration({ + existing: [], + ownerPluginId: "route-plugin", + ownerSource: "route-source", + route: { + path: "/demo", + auth: "plugin", + handler: vi.fn(), + }, + }); + + expect(result).toMatchObject({ + ok: true, + action: "append", + entry: { + pluginId: "route-plugin", + path: "/demo", + auth: "plugin", + match: "exact", + source: "route-source", + }, + }); + }); + + it("rejects conflicting http routes owned by another plugin", () => { + const existing: ExtensionHostHttpRouteRegistration[] = [ + { + pluginId: "route-a", + path: "/demo", + auth: "plugin", + match: "exact", + handler: vi.fn(), + source: "route-a-source", + }, + ]; + + const result = resolveExtensionHttpRouteRegistration({ + existing, + ownerPluginId: "route-b", + ownerSource: "route-b-source", + route: { + path: "/demo", + auth: "plugin", + handler: vi.fn(), + }, + }); + + expect(result).toEqual({ + ok: false, + message: "http route already registered: /demo (exact) by route-a (route-a-source)", + }); + }); + + it("supports same-owner http route replacement", () => { + const existing: ExtensionHostHttpRouteRegistration[] = [ + { + pluginId: "route-plugin", + path: "/demo", + auth: "plugin", + match: "exact", + handler: vi.fn(), + source: "route-source", + }, + ]; + + const result = resolveExtensionHttpRouteRegistration({ + existing, + ownerPluginId: "route-plugin", + ownerSource: "route-source", + route: { + path: "/demo", + auth: "plugin", + replaceExisting: true, + handler: vi.fn(), + }, + }); + + expect(result).toMatchObject({ + ok: true, + action: "replace", + existingIndex: 0, + entry: { + pluginId: "route-plugin", + path: "/demo", + }, + }); + }); + + it("accepts a unique gateway method registration", () => { + const handler = vi.fn(); + const result = resolveExtensionGatewayMethodRegistration({ + existing: {}, + coreGatewayMethods: new Set(["core.method"]), + method: "plugin.method", + handler, + }); + + expect(result).toEqual({ + ok: true, + method: "plugin.method", + handler, + }); + }); + + it("rejects duplicate gateway method registrations", () => { + const result = resolveExtensionGatewayMethodRegistration({ + existing: { + "plugin.method": vi.fn(), + }, + coreGatewayMethods: new Set(["core.method"]), + method: "plugin.method", + handler: vi.fn(), + }); + + expect(result).toEqual({ + ok: false, + message: "gateway method already registered: plugin.method", + }); + }); +}); diff --git a/src/extension-host/contributions/runtime-registrations.ts b/src/extension-host/contributions/runtime-registrations.ts new file mode 100644 index 00000000000..a1107ac3cb6 --- /dev/null +++ b/src/extension-host/contributions/runtime-registrations.ts @@ -0,0 +1,556 @@ +import path from "node:path"; +import type { AnyAgentTool } from "../../agents/tools/common.js"; +import type { ChannelDock } from "../../channels/dock.js"; +import type { ChannelPlugin } from "../../channels/plugins/types.js"; +import type { ContextEngineFactory } from "../../context-engine/registry.js"; +import type { + GatewayRequestHandler, + GatewayRequestHandlers, +} from "../../gateway/server-methods/types.js"; +import type { InternalHookHandler } from "../../hooks/internal-hooks.js"; +import type { HookEntry } from "../../hooks/types.js"; +import { normalizePluginHttpPath } from "../../plugins/http-path.js"; +import { findOverlappingPluginHttpRoute } from "../../plugins/http-route-overlap.js"; +import type { + OpenClawPluginCliRegistrar, + OpenClawPluginCommandDefinition, + OpenClawPluginChannelRegistration, + OpenClawPluginHookOptions, + OpenClawPluginHttpRouteAuth, + OpenClawPluginHttpRouteHandler, + OpenClawPluginHttpRouteMatch, + OpenClawPluginHttpRouteParams, + OpenClawPluginService, + OpenClawPluginToolContext, + OpenClawPluginToolFactory, + PluginHookHandlerMap, + PluginHookName, + PluginHookRegistration, + ProviderPlugin, +} from "../../plugins/types.js"; +import { isPluginHookName } from "../../plugins/types.js"; + +export type ExtensionHostChannelRegistration = { + pluginId: string; + plugin: ChannelPlugin; + dock?: ChannelDock; + source: string; +}; + +export type ExtensionHostProviderRegistration = { + pluginId: string; + provider: ProviderPlugin; + source: string; +}; + +export type ExtensionHostToolRegistration = { + pluginId: string; + factory: OpenClawPluginToolFactory; + names: string[]; + optional: boolean; + source: string; +}; + +export type ExtensionHostCliRegistration = { + pluginId: string; + register: OpenClawPluginCliRegistrar; + commands: string[]; + source: string; +}; + +export type ExtensionHostServiceRegistration = { + pluginId: string; + service: OpenClawPluginService; + source: string; +}; + +export type ExtensionHostCommandRegistration = { + pluginId: string; + command: OpenClawPluginCommandDefinition; + source: string; +}; + +export type ExtensionHostContextEngineRegistration = { + engineId: string; + factory: ContextEngineFactory; +}; + +export type ExtensionHostLegacyHookRegistration = { + pluginId: string; + entry: HookEntry; + events: string[]; + source: string; + handler: InternalHookHandler; +}; + +export type ExtensionHostHttpRouteRegistration = { + pluginId?: string; + path: string; + handler: OpenClawPluginHttpRouteHandler; + auth: OpenClawPluginHttpRouteAuth; + match: OpenClawPluginHttpRouteMatch; + source?: string; +}; + +function normalizeNameList(names: string[]): string[] { + return Array.from(new Set(names.map((name) => name.trim()).filter(Boolean))); +} + +export function resolveExtensionToolRegistration(params: { + ownerPluginId: string; + ownerSource: string; + tool: AnyAgentTool | OpenClawPluginToolFactory; + opts?: { name?: string; names?: string[]; optional?: boolean }; +}): { + names: string[]; + entry: ExtensionHostToolRegistration; +} { + const names = [...(params.opts?.names ?? []), ...(params.opts?.name ? [params.opts.name] : [])]; + if (typeof params.tool !== "function") { + names.push(params.tool.name); + } + const normalizedNames = normalizeNameList(names); + let factory: OpenClawPluginToolFactory; + if (typeof params.tool === "function") { + factory = params.tool; + } else { + const tool = params.tool; + factory = (_ctx: OpenClawPluginToolContext) => tool; + } + + return { + names: normalizedNames, + entry: { + pluginId: params.ownerPluginId, + factory, + names: normalizedNames, + optional: params.opts?.optional === true, + source: params.ownerSource, + }, + }; +} + +export function resolveExtensionCliRegistration(params: { + ownerPluginId: string; + ownerSource: string; + registrar: OpenClawPluginCliRegistrar; + opts?: { commands?: string[] }; +}): { + commands: string[]; + entry: ExtensionHostCliRegistration; +} { + const commands = normalizeNameList(params.opts?.commands ?? []); + return { + commands, + entry: { + pluginId: params.ownerPluginId, + register: params.registrar, + commands, + source: params.ownerSource, + }, + }; +} + +export function resolveExtensionServiceRegistration(params: { + ownerPluginId: string; + ownerSource: string; + service: OpenClawPluginService; +}): + | { + ok: true; + serviceId: string; + entry: ExtensionHostServiceRegistration; + } + | { + ok: false; + message: string; + } { + const serviceId = params.service.id.trim(); + if (!serviceId) { + return { ok: false, message: "service registration missing id" }; + } + return { + ok: true, + serviceId, + entry: { + pluginId: params.ownerPluginId, + service: { + ...params.service, + id: serviceId, + }, + source: params.ownerSource, + }, + }; +} + +export function resolveExtensionCommandRegistration(params: { + ownerPluginId: string; + ownerSource: string; + command: OpenClawPluginCommandDefinition; +}): + | { + ok: true; + commandName: string; + entry: ExtensionHostCommandRegistration; + } + | { + ok: false; + message: string; + } { + const commandName = params.command.name.trim(); + if (!commandName) { + return { ok: false, message: "command registration missing name" }; + } + return { + ok: true, + commandName, + entry: { + pluginId: params.ownerPluginId, + command: { + ...params.command, + name: commandName, + }, + source: params.ownerSource, + }, + }; +} + +export function resolveExtensionContextEngineRegistration(params: { + engineId: string; + factory: ContextEngineFactory; +}): + | { + ok: true; + entry: ExtensionHostContextEngineRegistration; + } + | { + ok: false; + message: string; + } { + const engineId = params.engineId.trim(); + if (!engineId) { + return { ok: false, message: "context engine registration missing id" }; + } + return { + ok: true, + entry: { + engineId, + factory: params.factory, + }, + }; +} + +export function resolveExtensionLegacyHookRegistration(params: { + ownerPluginId: string; + ownerSource: string; + events: string | string[]; + handler: InternalHookHandler; + opts?: OpenClawPluginHookOptions; +}): + | { + ok: true; + hookName: string; + events: string[]; + entry: ExtensionHostLegacyHookRegistration; + } + | { + ok: false; + message: string; + } { + const eventList = Array.isArray(params.events) ? params.events : [params.events]; + const normalizedEvents = normalizeNameList(eventList); + const entry = params.opts?.entry ?? null; + const hookName = entry?.hook.name ?? params.opts?.name?.trim(); + if (!hookName) { + return { ok: false, message: "hook registration missing name" }; + } + + const description = entry?.hook.description ?? params.opts?.description ?? ""; + const hookEntry: HookEntry = entry + ? { + ...entry, + hook: { + ...entry.hook, + name: hookName, + description, + source: "openclaw-plugin", + pluginId: params.ownerPluginId, + }, + metadata: { + ...entry.metadata, + events: normalizedEvents, + }, + } + : { + hook: { + name: hookName, + description, + source: "openclaw-plugin", + pluginId: params.ownerPluginId, + filePath: params.ownerSource, + baseDir: path.dirname(params.ownerSource), + handlerPath: params.ownerSource, + }, + frontmatter: {}, + metadata: { events: normalizedEvents }, + invocation: { enabled: true }, + }; + + return { + ok: true, + hookName, + events: normalizedEvents, + entry: { + pluginId: params.ownerPluginId, + entry: hookEntry, + events: normalizedEvents, + source: params.ownerSource, + handler: params.handler, + }, + }; +} + +export function resolveExtensionTypedHookRegistration(params: { + ownerPluginId: string; + ownerSource: string; + hookName: unknown; + handler: PluginHookHandlerMap[K]; + priority?: number; +}): + | { + ok: true; + hookName: K; + entry: PluginHookRegistration; + } + | { + ok: false; + message: string; + } { + if (!isPluginHookName(params.hookName)) { + return { + ok: false, + message: `unknown typed hook "${String(params.hookName)}" ignored`, + }; + } + return { + ok: true, + hookName: params.hookName as K, + entry: { + pluginId: params.ownerPluginId, + hookName: params.hookName as K, + handler: params.handler, + priority: params.priority, + source: params.ownerSource, + }, + }; +} + +export function resolveExtensionGatewayMethodRegistration(params: { + existing: GatewayRequestHandlers; + coreGatewayMethods: ReadonlySet; + method: string; + handler: GatewayRequestHandler; +}): + | { + ok: true; + method: string; + handler: GatewayRequestHandler; + } + | { + ok: false; + message: string; + } { + const method = params.method.trim(); + if (!method) { + return { ok: false, message: "gateway method registration missing name" }; + } + if (params.coreGatewayMethods.has(method) || params.existing[method]) { + return { + ok: false, + message: `gateway method already registered: ${method}`, + }; + } + return { + ok: true, + method, + handler: params.handler, + }; +} + +function normalizeChannelRegistration( + registration: OpenClawPluginChannelRegistration | ChannelPlugin, +): { plugin: ChannelPlugin; dock?: ChannelDock } { + return typeof (registration as OpenClawPluginChannelRegistration).plugin === "object" + ? (registration as OpenClawPluginChannelRegistration) + : { plugin: registration as ChannelPlugin }; +} + +export function resolveExtensionChannelRegistration(params: { + existing: ExtensionHostChannelRegistration[]; + ownerPluginId: string; + ownerSource: string; + registration: OpenClawPluginChannelRegistration | ChannelPlugin; +}): + | { + ok: true; + channelId: string; + entry: ExtensionHostChannelRegistration; + } + | { + ok: false; + message: string; + } { + const normalized = normalizeChannelRegistration(params.registration); + const plugin = normalized.plugin; + const channelId = + typeof plugin?.id === "string" ? plugin.id.trim() : String(plugin?.id ?? "").trim(); + if (!channelId) { + return { ok: false, message: "channel registration missing id" }; + } + const existing = params.existing.find((entry) => entry.plugin.id === channelId); + if (existing) { + return { + ok: false, + message: `channel already registered: ${channelId} (${existing.pluginId})`, + }; + } + return { + ok: true, + channelId, + entry: { + pluginId: params.ownerPluginId, + plugin, + dock: normalized.dock, + source: params.ownerSource, + }, + }; +} + +export function resolveExtensionProviderRegistration(params: { + existing: ExtensionHostProviderRegistration[]; + ownerPluginId: string; + ownerSource: string; + provider: ProviderPlugin; +}): + | { + ok: true; + providerId: string; + entry: ExtensionHostProviderRegistration; + } + | { + ok: false; + message: string; + } { + const providerId = params.provider.id; + const existing = params.existing.find((entry) => entry.provider.id === providerId); + if (existing) { + return { + ok: false, + message: `provider already registered: ${providerId} (${existing.pluginId})`, + }; + } + return { + ok: true, + providerId, + entry: { + pluginId: params.ownerPluginId, + provider: params.provider, + source: params.ownerSource, + }, + }; +} + +function describeHttpRouteOwner(entry: ExtensionHostHttpRouteRegistration): string { + const plugin = entry.pluginId?.trim() || "unknown-plugin"; + const source = entry.source?.trim() || "unknown-source"; + return `${plugin} (${source})`; +} + +export function resolveExtensionHttpRouteRegistration(params: { + existing: ExtensionHostHttpRouteRegistration[]; + ownerPluginId: string; + ownerSource: string; + route: OpenClawPluginHttpRouteParams; +}): + | { + ok: true; + action: "append" | "replace"; + entry: ExtensionHostHttpRouteRegistration; + existingIndex?: number; + } + | { + ok: false; + message: string; + } { + const normalizedPath = normalizePluginHttpPath(params.route.path); + if (!normalizedPath) { + return { ok: false, message: "http route registration missing path" }; + } + if (params.route.auth !== "gateway" && params.route.auth !== "plugin") { + return { + ok: false, + message: `http route registration missing or invalid auth: ${normalizedPath}`, + }; + } + + const match = params.route.match ?? "exact"; + const overlappingRoute = findOverlappingPluginHttpRoute(params.existing, { + path: normalizedPath, + match, + }); + if (overlappingRoute && overlappingRoute.auth !== params.route.auth) { + return { + ok: false, + message: + `http route overlap rejected: ${normalizedPath} (${match}, ${params.route.auth}) ` + + `overlaps ${overlappingRoute.path} (${overlappingRoute.match}, ${overlappingRoute.auth}) ` + + `owned by ${describeHttpRouteOwner(overlappingRoute)}`, + }; + } + + const existingIndex = params.existing.findIndex( + (entry) => entry.path === normalizedPath && entry.match === match, + ); + const nextEntry: ExtensionHostHttpRouteRegistration = { + pluginId: params.ownerPluginId, + path: normalizedPath, + handler: params.route.handler, + auth: params.route.auth, + match, + source: params.ownerSource, + }; + + if (existingIndex >= 0) { + const existing = params.existing[existingIndex]; + if (!existing) { + return { + ok: false, + message: `http route registration missing existing route: ${normalizedPath}`, + }; + } + if (!params.route.replaceExisting) { + return { + ok: false, + message: `http route already registered: ${normalizedPath} (${match}) by ${describeHttpRouteOwner(existing)}`, + }; + } + if (existing.pluginId && existing.pluginId !== params.ownerPluginId) { + return { + ok: false, + message: `http route replacement rejected: ${normalizedPath} (${match}) owned by ${describeHttpRouteOwner(existing)}`, + }; + } + return { + ok: true, + action: "replace", + existingIndex, + entry: nextEntry, + }; + } + + return { + ok: true, + action: "append", + entry: nextEntry, + }; +} diff --git a/src/extension-host/contributions/runtime-registry.test.ts b/src/extension-host/contributions/runtime-registry.test.ts new file mode 100644 index 00000000000..2d1e982f7fe --- /dev/null +++ b/src/extension-host/contributions/runtime-registry.test.ts @@ -0,0 +1,343 @@ +import { describe, expect, it, vi } from "vitest"; +import { createEmptyPluginRegistry } from "../../plugins/registry.js"; +import { + addExtensionHostChannelRegistration, + addExtensionHostCliRegistration, + addExtensionHostCommandRegistration, + addExtensionHostHttpRoute, + addExtensionHostProviderRegistration, + addExtensionHostServiceRegistration, + addExtensionHostToolRegistration, + getExtensionHostGatewayHandlers, + hasExtensionHostRuntimeEntries, + listExtensionHostChannelRegistrations, + listExtensionHostCliRegistrations, + listExtensionHostCommandRegistrations, + listExtensionHostHttpRoutes, + listExtensionHostProviderRegistrations, + listExtensionHostServiceRegistrations, + listExtensionHostToolRegistrations, + removeExtensionHostHttpRoute, + replaceExtensionHostHttpRoute, + setExtensionHostGatewayHandler, +} from "./runtime-registry.js"; + +describe("extension host runtime registry accessors", () => { + it("detects runtime entries across non-tool surfaces", () => { + const providerRegistry = createEmptyPluginRegistry(); + addExtensionHostProviderRegistration(providerRegistry, { + pluginId: "provider-demo", + source: "test", + provider: { + id: "provider-demo", + label: "Provider Demo", + auth: [], + }, + }); + expect(hasExtensionHostRuntimeEntries(providerRegistry)).toBe(true); + + const routeRegistry = createEmptyPluginRegistry(); + addExtensionHostHttpRoute(routeRegistry, { + path: "/plugins/demo", + handler: vi.fn(), + auth: "plugin", + match: "exact", + pluginId: "route-demo", + source: "test", + }); + expect(hasExtensionHostRuntimeEntries(routeRegistry)).toBe(true); + + const channelRegistry = createEmptyPluginRegistry(); + addExtensionHostChannelRegistration(channelRegistry, { + pluginId: "channel-demo", + source: "test", + plugin: { + id: "channel-demo", + meta: { + id: "channel-demo", + label: "Channel Demo", + selectionLabel: "Channel Demo", + docsPath: "/channels/channel-demo", + blurb: "demo", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({}), + }, + }, + }); + expect(hasExtensionHostRuntimeEntries(channelRegistry)).toBe(true); + + const gatewayRegistry = createEmptyPluginRegistry(); + setExtensionHostGatewayHandler({ + registry: gatewayRegistry, + method: "demo.echo", + handler: vi.fn(), + }); + expect(hasExtensionHostRuntimeEntries(gatewayRegistry)).toBe(true); + + const cliRegistry = createEmptyPluginRegistry(); + addExtensionHostCliRegistration(cliRegistry, { + pluginId: "cli-demo", + source: "test", + commands: ["demo"], + register: () => undefined, + }); + expect(hasExtensionHostRuntimeEntries(cliRegistry)).toBe(true); + + const commandRegistry = createEmptyPluginRegistry(); + addExtensionHostCommandRegistration(commandRegistry, { + pluginId: "cmd-demo", + source: "test", + command: { + name: "demo", + description: "Demo command", + handler: async () => ({ text: "ok" }), + }, + }); + expect(hasExtensionHostRuntimeEntries(commandRegistry)).toBe(true); + + const serviceRegistry = createEmptyPluginRegistry(); + addExtensionHostServiceRegistration(serviceRegistry, { + pluginId: "svc-demo", + source: "test", + service: { + id: "svc-demo", + start: () => undefined, + }, + }); + expect(hasExtensionHostRuntimeEntries(serviceRegistry)).toBe(true); + }); + + it("returns stable empty views for missing registries", () => { + expect(hasExtensionHostRuntimeEntries(null)).toBe(false); + expect(listExtensionHostProviderRegistrations(null)).toEqual([]); + expect(listExtensionHostChannelRegistrations(null)).toEqual([]); + expect(listExtensionHostToolRegistrations(null)).toEqual([]); + expect(listExtensionHostServiceRegistrations(null)).toEqual([]); + expect(listExtensionHostCliRegistrations(null)).toEqual([]); + expect(listExtensionHostCommandRegistrations(null)).toEqual([]); + expect(listExtensionHostHttpRoutes(null)).toEqual([]); + expect(getExtensionHostGatewayHandlers(null)).toEqual({}); + }); + + it("projects existing registry collections without copying them", () => { + const registry = createEmptyPluginRegistry(); + addExtensionHostToolRegistration(registry, { + pluginId: "tool-demo", + optional: false, + source: "test", + names: ["tool_demo"], + factory: () => ({ + name: "tool_demo", + description: "tool demo", + parameters: { type: "object", properties: {} }, + async execute() { + return { content: [{ type: "text", text: "ok" }] }; + }, + }), + }); + addExtensionHostProviderRegistration(registry, { + pluginId: "provider-demo", + source: "test", + provider: { + id: "provider-demo", + label: "Provider Demo", + auth: [], + }, + }); + addExtensionHostServiceRegistration(registry, { + pluginId: "svc-demo", + source: "test", + service: { + id: "svc-demo", + start: () => undefined, + }, + }); + addExtensionHostCliRegistration(registry, { + pluginId: "cli-demo", + source: "test", + commands: ["demo"], + register: () => undefined, + }); + addExtensionHostCommandRegistration(registry, { + pluginId: "cmd-demo", + source: "test", + command: { + name: "demo", + description: "Demo command", + handler: async () => ({ text: "ok" }), + }, + }); + addExtensionHostHttpRoute(registry, { + path: "/plugins/demo", + handler: vi.fn(), + auth: "plugin", + match: "exact", + pluginId: "route-demo", + source: "test", + }); + const handler = vi.fn(); + setExtensionHostGatewayHandler({ + registry, + method: "demo.echo", + handler, + }); + + addExtensionHostChannelRegistration(registry, { + pluginId: "channel-demo", + source: "test", + plugin: { + id: "channel-demo", + meta: { + id: "channel-demo", + label: "Channel Demo", + selectionLabel: "Channel Demo", + docsPath: "/channels/channel-demo", + blurb: "demo", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({}), + }, + }, + }); + + expect(listExtensionHostChannelRegistrations(registry)).toEqual(registry.channels); + expect(listExtensionHostToolRegistrations(registry)).toEqual(registry.tools); + expect(listExtensionHostProviderRegistrations(registry)).toEqual(registry.providers); + expect(listExtensionHostServiceRegistrations(registry)).toEqual(registry.services); + expect(listExtensionHostCliRegistrations(registry)).toEqual(registry.cliRegistrars); + expect(listExtensionHostCommandRegistrations(registry)).toEqual(registry.commands); + expect(listExtensionHostHttpRoutes(registry)).toEqual(registry.httpRoutes); + expect(getExtensionHostGatewayHandlers(registry)).toEqual(registry.gatewayHandlers); + expect(getExtensionHostGatewayHandlers(registry)["demo.echo"]).toBe(handler); + }); + + it("keeps legacy route and gateway mirrors synchronized with host-owned state", () => { + const registry = createEmptyPluginRegistry(); + const firstHandler = vi.fn(); + const secondHandler = vi.fn(); + const entry = { + path: "/plugins/demo", + handler: firstHandler, + auth: "plugin" as const, + match: "exact" as const, + pluginId: "route-demo", + source: "test", + }; + + addExtensionHostHttpRoute(registry, entry); + setExtensionHostGatewayHandler({ + registry, + method: "demo.echo", + handler: firstHandler, + }); + replaceExtensionHostHttpRoute({ + registry, + index: 0, + entry: { ...entry, handler: secondHandler }, + }); + removeExtensionHostHttpRoute(registry, entry); + + expect(registry.httpRoutes).toHaveLength(1); + expect(registry.httpRoutes[0]?.handler).toBe(secondHandler); + expect(getExtensionHostGatewayHandlers(registry)).toEqual(registry.gatewayHandlers); + }); + + it("keeps legacy CLI and service mirrors synchronized with host-owned state", () => { + const registry = createEmptyPluginRegistry(); + const service = { + id: "svc-demo", + start: () => undefined, + }; + const register = () => undefined; + const command = { + name: "demo", + description: "Demo command", + handler: async () => ({ text: "ok" }), + }; + + addExtensionHostServiceRegistration(registry, { + pluginId: "svc-demo", + source: "test", + service, + }); + addExtensionHostCliRegistration(registry, { + pluginId: "cli-demo", + source: "test", + commands: ["demo"], + register, + }); + addExtensionHostCommandRegistration(registry, { + pluginId: "cmd-demo", + source: "test", + command, + }); + + expect(listExtensionHostServiceRegistrations(registry)).toEqual(registry.services); + expect(listExtensionHostCliRegistrations(registry)).toEqual(registry.cliRegistrars); + expect(listExtensionHostCommandRegistrations(registry)).toEqual(registry.commands); + expect(registry.services[0]?.service).toBe(service); + expect(registry.cliRegistrars[0]?.register).toBe(register); + expect(registry.commands[0]?.command).toBe(command); + }); + + it("keeps legacy tool and provider mirrors synchronized with host-owned state", () => { + const registry = createEmptyPluginRegistry(); + const factory = (() => ({}) as never) as never; + const provider = { + id: "provider-demo", + label: "Provider Demo", + auth: [], + }; + + addExtensionHostToolRegistration(registry, { + pluginId: "tool-demo", + optional: false, + source: "test", + names: ["tool_demo"], + factory, + }); + addExtensionHostProviderRegistration(registry, { + pluginId: "provider-demo", + source: "test", + provider, + }); + + expect(listExtensionHostToolRegistrations(registry)).toEqual(registry.tools); + expect(listExtensionHostProviderRegistrations(registry)).toEqual(registry.providers); + expect(registry.tools[0]?.factory).toBe(factory); + expect(registry.providers[0]?.provider).toBe(provider); + }); + + it("keeps legacy channel mirrors synchronized with host-owned state", () => { + const registry = createEmptyPluginRegistry(); + const plugin = { + id: "channel-demo", + meta: { + id: "channel-demo", + label: "Channel Demo", + selectionLabel: "Channel Demo", + docsPath: "/channels/channel-demo", + blurb: "demo", + }, + capabilities: { chatTypes: ["direct"] as const }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({}), + }, + }; + + addExtensionHostChannelRegistration(registry, { + pluginId: "channel-demo", + source: "test", + plugin, + }); + + expect(listExtensionHostChannelRegistrations(registry)).toEqual(registry.channels); + expect(registry.channels[0]?.plugin).toBe(plugin); + }); +}); diff --git a/src/extension-host/contributions/runtime-registry.ts b/src/extension-host/contributions/runtime-registry.ts new file mode 100644 index 00000000000..919ab3823d9 --- /dev/null +++ b/src/extension-host/contributions/runtime-registry.ts @@ -0,0 +1,604 @@ +import type { GatewayRequestHandlers } from "../../gateway/server-methods/types.js"; +import type { + PluginChannelRegistration, + PluginCliRegistration, + PluginCommandRegistration, + PluginHttpRouteRegistration, + PluginProviderRegistration, + PluginRegistry, + PluginServiceRegistration, + PluginToolRegistration, +} from "../../plugins/registry.js"; + +const EMPTY_PROVIDERS: readonly PluginProviderRegistration[] = []; +const EMPTY_TOOLS: readonly PluginToolRegistration[] = []; +const EMPTY_CHANNELS: readonly PluginChannelRegistration[] = []; +const EMPTY_SERVICES: readonly PluginServiceRegistration[] = []; +const EMPTY_CLI_REGISTRARS: readonly PluginCliRegistration[] = []; +const EMPTY_COMMANDS: readonly PluginCommandRegistration[] = []; +const EMPTY_HTTP_ROUTES: readonly PluginHttpRouteRegistration[] = []; +const EMPTY_GATEWAY_HANDLERS: Readonly = Object.freeze({}); +const EXTENSION_HOST_RUNTIME_REGISTRY_STATE = Symbol.for("openclaw.extensionHostRuntimeRegistry"); + +type ExtensionHostRuntimeRegistryState = { + channels: PluginChannelRegistration[]; + legacyChannels: PluginChannelRegistration[]; + tools: PluginToolRegistration[]; + legacyTools: PluginToolRegistration[]; + providers: PluginProviderRegistration[]; + legacyProviders: PluginProviderRegistration[]; + cliRegistrars: PluginCliRegistration[]; + legacyCliRegistrars: PluginCliRegistration[]; + commands: PluginCommandRegistration[]; + legacyCommands: PluginCommandRegistration[]; + services: PluginServiceRegistration[]; + legacyServices: PluginServiceRegistration[]; + httpRoutes: PluginHttpRouteRegistration[]; + legacyHttpRoutes: PluginHttpRouteRegistration[]; + gatewayHandlers: GatewayRequestHandlers; + legacyGatewayHandlers: GatewayRequestHandlers; +}; + +type RuntimeRegistryBackedPluginRegistry = Pick< + PluginRegistry, + | "channels" + | "tools" + | "providers" + | "cliRegistrars" + | "commands" + | "services" + | "httpRoutes" + | "gatewayHandlers" +> & { + [EXTENSION_HOST_RUNTIME_REGISTRY_STATE]?: ExtensionHostRuntimeRegistryState; +}; + +function ensureExtensionHostRuntimeRegistryState( + registry: RuntimeRegistryBackedPluginRegistry, +): ExtensionHostRuntimeRegistryState { + const existing = registry[EXTENSION_HOST_RUNTIME_REGISTRY_STATE]; + if (existing) { + if (registry.channels !== existing.legacyChannels) { + existing.legacyChannels = registry.channels ?? []; + existing.channels = [...existing.legacyChannels]; + } + if (registry.tools !== existing.legacyTools) { + existing.legacyTools = registry.tools ?? []; + existing.tools = [...existing.legacyTools]; + } + if (registry.providers !== existing.legacyProviders) { + existing.legacyProviders = registry.providers ?? []; + existing.providers = [...existing.legacyProviders]; + } + if (registry.cliRegistrars !== existing.legacyCliRegistrars) { + existing.legacyCliRegistrars = registry.cliRegistrars ?? []; + existing.cliRegistrars = [...existing.legacyCliRegistrars]; + } + if (registry.commands !== existing.legacyCommands) { + existing.legacyCommands = registry.commands ?? []; + existing.commands = [...existing.legacyCommands]; + } + if (registry.services !== existing.legacyServices) { + existing.legacyServices = registry.services ?? []; + existing.services = [...existing.legacyServices]; + } + if (registry.httpRoutes !== existing.legacyHttpRoutes) { + existing.legacyHttpRoutes = registry.httpRoutes ?? []; + existing.httpRoutes = [...existing.legacyHttpRoutes]; + } + if (registry.gatewayHandlers !== existing.legacyGatewayHandlers) { + existing.legacyGatewayHandlers = registry.gatewayHandlers ?? {}; + existing.gatewayHandlers = { ...existing.legacyGatewayHandlers }; + } + return existing; + } + + const legacyHttpRoutes = registry.httpRoutes ?? []; + registry.httpRoutes = legacyHttpRoutes; + const legacyGatewayHandlers = registry.gatewayHandlers ?? {}; + registry.gatewayHandlers = legacyGatewayHandlers; + const legacyCliRegistrars = registry.cliRegistrars ?? []; + registry.cliRegistrars = legacyCliRegistrars; + const legacyCommands = registry.commands ?? []; + registry.commands = legacyCommands; + const legacyServices = registry.services ?? []; + registry.services = legacyServices; + const legacyChannels = registry.channels ?? []; + registry.channels = legacyChannels; + const legacyTools = registry.tools ?? []; + registry.tools = legacyTools; + const legacyProviders = registry.providers ?? []; + registry.providers = legacyProviders; + + const state: ExtensionHostRuntimeRegistryState = { + channels: [...legacyChannels], + legacyChannels, + tools: [...legacyTools], + legacyTools, + providers: [...legacyProviders], + legacyProviders, + cliRegistrars: [...legacyCliRegistrars], + legacyCliRegistrars, + commands: [...legacyCommands], + legacyCommands, + services: [...legacyServices], + legacyServices, + httpRoutes: [...legacyHttpRoutes], + legacyHttpRoutes, + gatewayHandlers: { ...legacyGatewayHandlers }, + legacyGatewayHandlers, + }; + registry[EXTENSION_HOST_RUNTIME_REGISTRY_STATE] = state; + return state; +} + +function syncLegacyChannels(state: ExtensionHostRuntimeRegistryState): void { + state.legacyChannels.splice(0, state.legacyChannels.length, ...state.channels); +} + +function syncLegacyTools(state: ExtensionHostRuntimeRegistryState): void { + state.legacyTools.splice(0, state.legacyTools.length, ...state.tools); +} + +function syncLegacyProviders(state: ExtensionHostRuntimeRegistryState): void { + state.legacyProviders.splice(0, state.legacyProviders.length, ...state.providers); +} + +function syncLegacyCliRegistrars(state: ExtensionHostRuntimeRegistryState): void { + state.legacyCliRegistrars.splice(0, state.legacyCliRegistrars.length, ...state.cliRegistrars); +} + +function syncLegacyCommands(state: ExtensionHostRuntimeRegistryState): void { + state.legacyCommands.splice(0, state.legacyCommands.length, ...state.commands); +} + +function syncLegacyServices(state: ExtensionHostRuntimeRegistryState): void { + state.legacyServices.splice(0, state.legacyServices.length, ...state.services); +} + +function syncLegacyHttpRoutes(state: ExtensionHostRuntimeRegistryState): void { + state.legacyHttpRoutes.splice(0, state.legacyHttpRoutes.length, ...state.httpRoutes); +} + +function syncLegacyGatewayHandlers(state: ExtensionHostRuntimeRegistryState): void { + for (const key of Object.keys(state.legacyGatewayHandlers)) { + if (!(key in state.gatewayHandlers)) { + delete state.legacyGatewayHandlers[key]; + } + } + Object.assign(state.legacyGatewayHandlers, state.gatewayHandlers); +} + +export function hasExtensionHostRuntimeEntries( + registry: + | Pick< + PluginRegistry, + | "plugins" + | "channels" + | "tools" + | "providers" + | "gatewayHandlers" + | "httpRoutes" + | "cliRegistrars" + | "services" + | "commands" + | "hooks" + | "typedHooks" + > + | null + | undefined, +): boolean { + if (!registry) { + return false; + } + return ( + registry.plugins.length > 0 || + listExtensionHostChannelRegistrations(registry).length > 0 || + listExtensionHostToolRegistrations(registry).length > 0 || + listExtensionHostProviderRegistrations(registry).length > 0 || + Object.keys(getExtensionHostGatewayHandlers(registry)).length > 0 || + listExtensionHostHttpRoutes(registry).length > 0 || + listExtensionHostCliRegistrations(registry).length > 0 || + listExtensionHostCommandRegistrations(registry).length > 0 || + listExtensionHostServiceRegistrations(registry).length > 0 || + registry.hooks.length > 0 || + registry.typedHooks.length > 0 + ); +} + +export function listExtensionHostProviderRegistrations( + registry: + | Pick< + PluginRegistry, + | "channels" + | "tools" + | "providers" + | "cliRegistrars" + | "commands" + | "services" + | "httpRoutes" + | "gatewayHandlers" + > + | null + | undefined, +): readonly PluginProviderRegistration[] { + if (!registry) { + return EMPTY_PROVIDERS; + } + return ensureExtensionHostRuntimeRegistryState(registry as RuntimeRegistryBackedPluginRegistry) + .providers; +} + +export function listExtensionHostToolRegistrations( + registry: + | Pick< + PluginRegistry, + | "channels" + | "tools" + | "providers" + | "cliRegistrars" + | "commands" + | "services" + | "httpRoutes" + | "gatewayHandlers" + > + | null + | undefined, +): readonly PluginToolRegistration[] { + if (!registry) { + return EMPTY_TOOLS; + } + return ensureExtensionHostRuntimeRegistryState(registry as RuntimeRegistryBackedPluginRegistry) + .tools; +} + +export function listExtensionHostChannelRegistrations( + registry: + | Pick< + PluginRegistry, + | "channels" + | "tools" + | "providers" + | "cliRegistrars" + | "services" + | "httpRoutes" + | "gatewayHandlers" + > + | null + | undefined, +): readonly PluginChannelRegistration[] { + if (!registry) { + return EMPTY_CHANNELS; + } + return ensureExtensionHostRuntimeRegistryState(registry as RuntimeRegistryBackedPluginRegistry) + .channels; +} + +export function listExtensionHostServiceRegistrations( + registry: + | Pick< + PluginRegistry, + | "channels" + | "tools" + | "providers" + | "cliRegistrars" + | "services" + | "httpRoutes" + | "gatewayHandlers" + > + | null + | undefined, +): readonly PluginServiceRegistration[] { + if (!registry) { + return EMPTY_SERVICES; + } + return ensureExtensionHostRuntimeRegistryState(registry as RuntimeRegistryBackedPluginRegistry) + .services; +} + +export function listExtensionHostCliRegistrations( + registry: + | Pick< + PluginRegistry, + | "channels" + | "tools" + | "providers" + | "cliRegistrars" + | "commands" + | "services" + | "httpRoutes" + | "gatewayHandlers" + > + | null + | undefined, +): readonly PluginCliRegistration[] { + if (!registry) { + return EMPTY_CLI_REGISTRARS; + } + return ensureExtensionHostRuntimeRegistryState(registry as RuntimeRegistryBackedPluginRegistry) + .cliRegistrars; +} + +export function listExtensionHostCommandRegistrations( + registry: + | Pick< + PluginRegistry, + | "channels" + | "tools" + | "providers" + | "cliRegistrars" + | "commands" + | "services" + | "httpRoutes" + | "gatewayHandlers" + > + | null + | undefined, +): readonly PluginCommandRegistration[] { + if (!registry) { + return EMPTY_COMMANDS; + } + return ensureExtensionHostRuntimeRegistryState(registry as RuntimeRegistryBackedPluginRegistry) + .commands; +} + +export function listExtensionHostHttpRoutes( + registry: + | Pick< + PluginRegistry, + | "channels" + | "tools" + | "providers" + | "cliRegistrars" + | "commands" + | "services" + | "httpRoutes" + | "gatewayHandlers" + > + | null + | undefined, +): readonly PluginHttpRouteRegistration[] { + if (!registry) { + return EMPTY_HTTP_ROUTES; + } + return ensureExtensionHostRuntimeRegistryState(registry as RuntimeRegistryBackedPluginRegistry) + .httpRoutes; +} + +export function getExtensionHostGatewayHandlers( + registry: + | Pick< + PluginRegistry, + | "channels" + | "tools" + | "providers" + | "cliRegistrars" + | "commands" + | "services" + | "httpRoutes" + | "gatewayHandlers" + > + | null + | undefined, +): Readonly { + if (!registry) { + return EMPTY_GATEWAY_HANDLERS; + } + return ensureExtensionHostRuntimeRegistryState(registry as RuntimeRegistryBackedPluginRegistry) + .gatewayHandlers; +} + +export function addExtensionHostHttpRoute( + registry: Pick< + PluginRegistry, + | "channels" + | "tools" + | "providers" + | "cliRegistrars" + | "commands" + | "services" + | "httpRoutes" + | "gatewayHandlers" + >, + entry: PluginHttpRouteRegistration, +): void { + const state = ensureExtensionHostRuntimeRegistryState( + registry as RuntimeRegistryBackedPluginRegistry, + ); + state.httpRoutes.push(entry); + syncLegacyHttpRoutes(state); +} + +export function replaceExtensionHostHttpRoute(params: { + registry: Pick< + PluginRegistry, + | "channels" + | "tools" + | "providers" + | "cliRegistrars" + | "commands" + | "services" + | "httpRoutes" + | "gatewayHandlers" + >; + index: number; + entry: PluginHttpRouteRegistration; +}): void { + const state = ensureExtensionHostRuntimeRegistryState( + params.registry as RuntimeRegistryBackedPluginRegistry, + ); + state.httpRoutes[params.index] = params.entry; + syncLegacyHttpRoutes(state); +} + +export function removeExtensionHostHttpRoute( + registry: Pick< + PluginRegistry, + | "channels" + | "tools" + | "providers" + | "cliRegistrars" + | "commands" + | "services" + | "httpRoutes" + | "gatewayHandlers" + >, + entry: PluginHttpRouteRegistration, +): void { + const state = ensureExtensionHostRuntimeRegistryState( + registry as RuntimeRegistryBackedPluginRegistry, + ); + const index = state.httpRoutes.indexOf(entry); + if (index < 0) { + return; + } + state.httpRoutes.splice(index, 1); + syncLegacyHttpRoutes(state); +} + +export function setExtensionHostGatewayHandler(params: { + registry: Pick< + PluginRegistry, + | "channels" + | "tools" + | "providers" + | "cliRegistrars" + | "commands" + | "services" + | "httpRoutes" + | "gatewayHandlers" + >; + method: string; + handler: GatewayRequestHandlers[string]; +}): void { + const state = ensureExtensionHostRuntimeRegistryState( + params.registry as RuntimeRegistryBackedPluginRegistry, + ); + state.gatewayHandlers[params.method] = params.handler; + syncLegacyGatewayHandlers(state); +} + +export function addExtensionHostCliRegistration( + registry: Pick< + PluginRegistry, + | "channels" + | "tools" + | "providers" + | "cliRegistrars" + | "commands" + | "services" + | "httpRoutes" + | "gatewayHandlers" + >, + entry: PluginCliRegistration, +): void { + const state = ensureExtensionHostRuntimeRegistryState( + registry as RuntimeRegistryBackedPluginRegistry, + ); + state.cliRegistrars.push(entry); + syncLegacyCliRegistrars(state); +} + +export function addExtensionHostCommandRegistration( + registry: Pick< + PluginRegistry, + | "channels" + | "tools" + | "providers" + | "cliRegistrars" + | "commands" + | "services" + | "httpRoutes" + | "gatewayHandlers" + >, + entry: PluginCommandRegistration, +): void { + const state = ensureExtensionHostRuntimeRegistryState( + registry as RuntimeRegistryBackedPluginRegistry, + ); + state.commands.push(entry); + syncLegacyCommands(state); +} + +export function addExtensionHostServiceRegistration( + registry: Pick< + PluginRegistry, + | "channels" + | "tools" + | "providers" + | "cliRegistrars" + | "commands" + | "services" + | "httpRoutes" + | "gatewayHandlers" + >, + entry: PluginServiceRegistration, +): void { + const state = ensureExtensionHostRuntimeRegistryState( + registry as RuntimeRegistryBackedPluginRegistry, + ); + state.services.push(entry); + syncLegacyServices(state); +} + +export function addExtensionHostToolRegistration( + registry: Pick< + PluginRegistry, + | "channels" + | "tools" + | "providers" + | "cliRegistrars" + | "commands" + | "services" + | "httpRoutes" + | "gatewayHandlers" + >, + entry: PluginToolRegistration, +): void { + const state = ensureExtensionHostRuntimeRegistryState( + registry as RuntimeRegistryBackedPluginRegistry, + ); + state.tools.push(entry); + syncLegacyTools(state); +} + +export function addExtensionHostProviderRegistration( + registry: Pick< + PluginRegistry, + | "channels" + | "tools" + | "providers" + | "cliRegistrars" + | "commands" + | "services" + | "httpRoutes" + | "gatewayHandlers" + >, + entry: PluginProviderRegistration, +): void { + const state = ensureExtensionHostRuntimeRegistryState( + registry as RuntimeRegistryBackedPluginRegistry, + ); + state.providers.push(entry); + syncLegacyProviders(state); +} + +export function addExtensionHostChannelRegistration( + registry: Pick< + PluginRegistry, + | "channels" + | "tools" + | "providers" + | "cliRegistrars" + | "services" + | "httpRoutes" + | "gatewayHandlers" + >, + entry: PluginChannelRegistration, +): void { + const state = ensureExtensionHostRuntimeRegistryState( + registry as RuntimeRegistryBackedPluginRegistry, + ); + state.channels.push(entry); + syncLegacyChannels(state); +} diff --git a/src/extension-host/contributions/service-lifecycle.test.ts b/src/extension-host/contributions/service-lifecycle.test.ts new file mode 100644 index 00000000000..e4e1c4ff849 --- /dev/null +++ b/src/extension-host/contributions/service-lifecycle.test.ts @@ -0,0 +1,134 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createEmptyPluginRegistry } from "../../plugins/registry.js"; +import type { OpenClawPluginService, OpenClawPluginServiceContext } from "../../plugins/types.js"; + +const mockedLogger = vi.hoisted(() => ({ + info: vi.fn<(msg: string) => void>(), + warn: vi.fn<(msg: string) => void>(), + error: vi.fn<(msg: string) => void>(), + debug: vi.fn<(msg: string) => void>(), +})); + +vi.mock("../../logging/subsystem.js", () => ({ + createSubsystemLogger: () => mockedLogger, +})); + +import { STATE_DIR } from "../../config/paths.js"; +import { startExtensionHostServices } from "./service-lifecycle.js"; + +function createRegistry(services: OpenClawPluginService[]) { + const registry = createEmptyPluginRegistry(); + for (const service of services) { + registry.services.push({ + pluginId: "plugin:test", + service, + source: "test", + rootDir: "/plugins/test-plugin", + }); + } + return registry; +} + +describe("startExtensionHostServices", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("starts services and stops them in reverse order", async () => { + const starts: string[] = []; + const stops: string[] = []; + const contexts: OpenClawPluginServiceContext[] = []; + + const serviceA: OpenClawPluginService = { + id: "service-a", + start: (ctx) => { + starts.push("a"); + contexts.push(ctx); + }, + stop: () => { + stops.push("a"); + }, + }; + const serviceB: OpenClawPluginService = { + id: "service-b", + start: (ctx) => { + starts.push("b"); + contexts.push(ctx); + }, + }; + const serviceC: OpenClawPluginService = { + id: "service-c", + start: (ctx) => { + starts.push("c"); + contexts.push(ctx); + }, + stop: () => { + stops.push("c"); + }, + }; + + const config = {} as Parameters[0]["config"]; + const handle = await startExtensionHostServices({ + registry: createRegistry([serviceA, serviceB, serviceC]), + config, + workspaceDir: "/tmp/workspace", + }); + await handle.stop(); + + expect(starts).toEqual(["a", "b", "c"]); + expect(stops).toEqual(["c", "a"]); + expect(contexts).toHaveLength(3); + for (const ctx of contexts) { + expect(ctx.config).toBe(config); + expect(ctx.workspaceDir).toBe("/tmp/workspace"); + expect(ctx.stateDir).toBe(STATE_DIR); + expect(ctx.logger).toBeDefined(); + expect(typeof ctx.logger.info).toBe("function"); + expect(typeof ctx.logger.warn).toBe("function"); + expect(typeof ctx.logger.error).toBe("function"); + } + }); + + it("logs start and stop failures and continues", async () => { + const stopOk = vi.fn(); + const stopThrows = vi.fn(() => { + throw new Error("stop failed"); + }); + + const handle = await startExtensionHostServices({ + registry: createRegistry([ + { + id: "service-start-fail", + start: () => { + throw new Error("start failed"); + }, + stop: vi.fn(), + }, + { + id: "service-ok", + start: () => undefined, + stop: stopOk, + }, + { + id: "service-stop-fail", + start: () => undefined, + stop: stopThrows, + }, + ]), + config: {} as Parameters[0]["config"], + }); + + await handle.stop(); + + expect(mockedLogger.error).toHaveBeenCalledWith( + expect.stringContaining( + "plugin service failed (service-start-fail, plugin=plugin:test, root=/plugins/test-plugin):", + ), + ); + expect(mockedLogger.warn).toHaveBeenCalledWith( + expect.stringContaining("plugin service stop failed (service-stop-fail):"), + ); + expect(stopOk).toHaveBeenCalledOnce(); + expect(stopThrows).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/extension-host/contributions/service-lifecycle.ts b/src/extension-host/contributions/service-lifecycle.ts new file mode 100644 index 00000000000..446f8167a9a --- /dev/null +++ b/src/extension-host/contributions/service-lifecycle.ts @@ -0,0 +1,80 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import { STATE_DIR } from "../../config/paths.js"; +import { createSubsystemLogger } from "../../logging/subsystem.js"; +import type { PluginRegistry } from "../../plugins/registry.js"; +import type { OpenClawPluginServiceContext, PluginLogger } from "../../plugins/types.js"; +import { listExtensionHostServiceRegistrations } from "./runtime-registry.js"; + +const log = createSubsystemLogger("plugins"); + +function createExtensionHostServiceLogger(): PluginLogger { + return { + info: (msg) => log.info(msg), + warn: (msg) => log.warn(msg), + error: (msg) => log.error(msg), + debug: (msg) => log.debug(msg), + }; +} + +function createExtensionHostServiceContext(params: { + config: OpenClawConfig; + workspaceDir?: string; +}): OpenClawPluginServiceContext { + return { + config: params.config, + workspaceDir: params.workspaceDir, + stateDir: STATE_DIR, + logger: createExtensionHostServiceLogger(), + }; +} + +export type ExtensionHostServicesHandle = { + stop: () => Promise; +}; + +export async function startExtensionHostServices(params: { + registry: PluginRegistry; + config: OpenClawConfig; + workspaceDir?: string; +}): Promise { + const running: Array<{ + id: string; + stop?: () => void | Promise; + }> = []; + const serviceContext = createExtensionHostServiceContext({ + config: params.config, + workspaceDir: params.workspaceDir, + }); + + for (const entry of listExtensionHostServiceRegistrations(params.registry)) { + const service = entry.service; + try { + await service.start(serviceContext); + running.push({ + id: service.id, + stop: service.stop ? () => service.stop?.(serviceContext) : undefined, + }); + } catch (err) { + const error = err as Error; + const stack = error?.stack?.trim(); + log.error( + `plugin service failed (${service.id}, plugin=${entry.pluginId}, root=${entry.rootDir ?? "unknown"}): ${error?.message ?? String(err)}${stack ? `\n${stack}` : ""}`, + ); + } + } + + return { + stop: async () => { + for (const entry of running.toReversed()) { + if (!entry.stop) { + continue; + } + try { + await entry.stop(); + } catch (err) { + log.warn(`plugin service stop failed (${entry.id}): ${String(err)}`); + } + } + }, + }; +} diff --git a/src/extension-host/contributions/tool-runtime.test.ts b/src/extension-host/contributions/tool-runtime.test.ts new file mode 100644 index 00000000000..d16020a47f8 --- /dev/null +++ b/src/extension-host/contributions/tool-runtime.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it, vi } from "vitest"; +import type { AnyAgentTool } from "../../agents/tools/common.js"; +import { createEmptyPluginRegistry } from "../../plugins/registry.js"; +import { addExtensionHostToolRegistration } from "./runtime-registry.js"; +import { getExtensionHostPluginToolMeta, resolveExtensionHostPluginTools } from "./tool-runtime.js"; + +function makeTool(name: string): AnyAgentTool { + return { + name, + description: `${name} tool`, + parameters: { type: "object", properties: {} }, + async execute() { + return { content: [{ type: "text", text: "ok" }] }; + }, + }; +} + +function createContext() { + return { + config: { + plugins: { + enabled: true, + }, + }, + workspaceDir: "/tmp", + }; +} + +describe("resolveExtensionHostPluginTools", () => { + it("allows optional tools through tool, plugin, and plugin-group allowlists", () => { + const registry = createEmptyPluginRegistry(); + addExtensionHostToolRegistration(registry, { + pluginId: "optional-demo", + optional: true, + source: "/tmp/optional-demo.js", + factory: () => makeTool("optional_tool"), + names: ["optional_tool"], + }); + + expect( + resolveExtensionHostPluginTools({ + registry, + context: createContext() as never, + }), + ).toEqual([]); + expect( + resolveExtensionHostPluginTools({ + registry, + context: createContext() as never, + toolAllowlist: ["optional_tool"], + }).map((tool) => tool.name), + ).toEqual(["optional_tool"]); + expect( + resolveExtensionHostPluginTools({ + registry, + context: createContext() as never, + toolAllowlist: ["optional-demo"], + }).map((tool) => tool.name), + ).toEqual(["optional_tool"]); + expect( + resolveExtensionHostPluginTools({ + registry, + context: createContext() as never, + toolAllowlist: ["group:plugins"], + }).map((tool) => tool.name), + ).toEqual(["optional_tool"]); + }); + + it("records conflict diagnostics and preserves tool metadata", () => { + const registry = createEmptyPluginRegistry(); + const extraTool = makeTool("other_tool"); + addExtensionHostToolRegistration(registry, { + pluginId: "message", + optional: false, + source: "/tmp/message.js", + factory: () => makeTool("optional_tool"), + names: ["optional_tool"], + }); + addExtensionHostToolRegistration(registry, { + pluginId: "multi", + optional: false, + source: "/tmp/multi.js", + factory: () => [makeTool("message"), extraTool], + names: ["message", "other_tool"], + }); + + const tools = resolveExtensionHostPluginTools({ + registry, + context: createContext() as never, + existingToolNames: new Set(["message"]), + }); + + expect(tools.map((tool) => tool.name)).toEqual(["other_tool"]); + expect(registry.diagnostics).toHaveLength(2); + expect(registry.diagnostics[0]?.message).toContain("plugin id conflicts with core tool name"); + expect(registry.diagnostics[1]?.message).toContain("plugin tool name conflict"); + expect(getExtensionHostPluginToolMeta(extraTool)).toEqual({ + pluginId: "multi", + optional: false, + }); + }); + + it("skips tool factories that throw", () => { + const registry = createEmptyPluginRegistry(); + const factory = vi.fn(() => { + throw new Error("boom"); + }); + addExtensionHostToolRegistration(registry, { + pluginId: "broken", + optional: false, + source: "/tmp/broken.js", + factory, + names: ["broken_tool"], + }); + + expect( + resolveExtensionHostPluginTools({ + registry, + context: createContext() as never, + }), + ).toEqual([]); + expect(factory).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/extension-host/contributions/tool-runtime.ts b/src/extension-host/contributions/tool-runtime.ts new file mode 100644 index 00000000000..2018be901f5 --- /dev/null +++ b/src/extension-host/contributions/tool-runtime.ts @@ -0,0 +1,138 @@ +import { normalizeToolName } from "../../agents/tool-policy.js"; +import type { AnyAgentTool } from "../../agents/tools/common.js"; +import { createSubsystemLogger } from "../../logging/subsystem.js"; +import type { PluginRegistry } from "../../plugins/registry.js"; +import type { OpenClawPluginToolContext } from "../../plugins/types.js"; +import { listExtensionHostToolRegistrations } from "./runtime-registry.js"; + +const log = createSubsystemLogger("plugins"); + +export type ExtensionHostPluginToolMeta = { + pluginId: string; + optional: boolean; +}; + +const extensionHostPluginToolMeta = new WeakMap(); + +export function getExtensionHostPluginToolMeta( + tool: AnyAgentTool, +): ExtensionHostPluginToolMeta | undefined { + return extensionHostPluginToolMeta.get(tool); +} + +function normalizeAllowlist(list?: string[]) { + return new Set((list ?? []).map(normalizeToolName).filter(Boolean)); +} + +function isOptionalToolAllowed(params: { + toolName: string; + pluginId: string; + allowlist: Set; +}): boolean { + if (params.allowlist.size === 0) { + return false; + } + const toolName = normalizeToolName(params.toolName); + if (params.allowlist.has(toolName)) { + return true; + } + const pluginKey = normalizeToolName(params.pluginId); + if (params.allowlist.has(pluginKey)) { + return true; + } + return params.allowlist.has("group:plugins"); +} + +export function resolveExtensionHostPluginTools(params: { + registry: Pick< + PluginRegistry, + | "channels" + | "tools" + | "providers" + | "cliRegistrars" + | "commands" + | "services" + | "httpRoutes" + | "gatewayHandlers" + | "diagnostics" + >; + context: OpenClawPluginToolContext; + existingToolNames?: Set; + toolAllowlist?: string[]; + suppressNameConflicts?: boolean; +}): AnyAgentTool[] { + const tools: AnyAgentTool[] = []; + const existing = params.existingToolNames ?? new Set(); + const existingNormalized = new Set(Array.from(existing, (tool) => normalizeToolName(tool))); + const allowlist = normalizeAllowlist(params.toolAllowlist); + const blockedPlugins = new Set(); + + for (const entry of listExtensionHostToolRegistrations(params.registry)) { + if (blockedPlugins.has(entry.pluginId)) { + continue; + } + const pluginIdKey = normalizeToolName(entry.pluginId); + if (existingNormalized.has(pluginIdKey)) { + const message = `plugin id conflicts with core tool name (${entry.pluginId})`; + if (!params.suppressNameConflicts) { + log.error(message); + params.registry.diagnostics.push({ + level: "error", + pluginId: entry.pluginId, + source: entry.source, + message, + }); + } + blockedPlugins.add(entry.pluginId); + continue; + } + let resolved: AnyAgentTool | AnyAgentTool[] | null | undefined = null; + try { + resolved = entry.factory(params.context); + } catch (err) { + log.error(`plugin tool failed (${entry.pluginId}): ${String(err)}`); + continue; + } + if (!resolved) { + continue; + } + const listRaw = Array.isArray(resolved) ? resolved : [resolved]; + const list = entry.optional + ? listRaw.filter((tool) => + isOptionalToolAllowed({ + toolName: tool.name, + pluginId: entry.pluginId, + allowlist, + }), + ) + : listRaw; + if (list.length === 0) { + continue; + } + const nameSet = new Set(); + for (const tool of list) { + if (nameSet.has(tool.name) || existing.has(tool.name)) { + const message = `plugin tool name conflict (${entry.pluginId}): ${tool.name}`; + if (!params.suppressNameConflicts) { + log.error(message); + params.registry.diagnostics.push({ + level: "error", + pluginId: entry.pluginId, + source: entry.source, + message, + }); + } + continue; + } + nameSet.add(tool.name); + existing.add(tool.name); + extensionHostPluginToolMeta.set(tool, { + pluginId: entry.pluginId, + optional: entry.optional, + }); + tools.push(tool); + } + } + + return tools; +} diff --git a/src/extension-host/cutover-inventory.md b/src/extension-host/cutover-inventory.md new file mode 100644 index 00000000000..76819fc44ec --- /dev/null +++ b/src/extension-host/cutover-inventory.md @@ -0,0 +1,168 @@ +# Extension Host Cutover Inventory + +Date: 2026-03-15 + +## Purpose + +This document is the Phase 0 cutover inventory for the extension-host migration. + +It tracks: + +- the current plugin-owned surfaces in the repo +- where ownership lives today +- where ownership should move +- what has already moved +- what is still blocked on later phases + +This is an implementation checklist, not a future-design spec. + +## Status Legend + +- `moved`: the host owns the boundary now, with compatibility preserved +- `partial`: host-owned types or views exist, but the legacy plugin path is still the active writer +- `compat-only`: old surface still exists only to preserve callers while the host boundary takes over +- `not started`: no meaningful migration has landed yet + +## Current Inventory + +| Surface | Current implementation | Target owner | Status | How it has been handled so far | +| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Active runtime registry state | `src/plugins/runtime.ts` plus global plugin runtime state | `src/extension-host/static/active-registry.ts` | `moved` | Host-owned active registry exists; `src/plugins/runtime.ts` is now a compatibility facade. | +| Normalized extension descriptor model | plugin manifests and package metadata interpreted ad hoc across `src/plugins/manifest.ts`, `src/plugins/discovery.ts`, `src/plugins/install.ts` | `src/extension-host/manifests/schema.ts` | `partial` | `ResolvedExtension`, `ResolvedContribution`, and `ContributionPolicy` exist; current manifests project into them through compatibility adapters. | +| Resolved static registry | flat rows in `src/plugins/manifest-registry.ts` | `src/extension-host/manifests/resolved-registry.ts` | `partial` | Manifest records now carry `resolvedExtension`; a host-owned resolved registry view exists for static consumers. | +| Manifest/package metadata loading | `src/plugins/manifest.ts`, `src/plugins/discovery.ts`, `src/plugins/install.ts` | `src/extension-host/manifests/schema.ts` and `src/extension-host/manifests/manifest-registry.ts` | `partial` | Package metadata parsing is routed through host schema helpers; legacy loader flow still supplies the source manifests. | +| Loader SDK alias compatibility | `src/plugins/loader.ts` | `src/extension-host/compat/loader-compat.ts` | `partial` | Plugin-SDK alias candidate ordering, alias-file resolution, and scoped alias-map construction now live in host-owned loader compatibility helpers. | +| Loader alias-wired module loader creation | `src/plugins/loader.ts` | `src/extension-host/activation/loader-module-loader.ts` | `partial` | Lazy Jiti creation and SDK-alias-wired module loading now delegate through a host-owned loader-module-loader helper. | +| Loader cache key and registry cache control | `src/plugins/loader.ts` | `src/extension-host/activation/loader-cache.ts` | `partial` | Cache-key construction, LRU registry cache reads and writes, and cache clearing now delegate through host-owned loader-cache helpers while preserving the current cache shape and cap. | +| Loader lazy runtime proxy creation | `src/plugins/loader.ts` | `src/extension-host/activation/loader-runtime-proxy.ts` | `partial` | Lazy plugin runtime creation now delegates through a host-owned loader-runtime-proxy helper instead of remaining inline in the orchestrator. | +| Loader provenance and duplicate-order policy | `src/plugins/loader.ts` | `src/extension-host/policy/loader-policy.ts` | `partial` | Plugin-record creation, duplicate precedence, and provenance indexing now live in host-owned loader-policy helpers. | +| Loader discovery policy results | mixed inside `src/extension-host/policy/loader-policy.ts` and `src/extension-host/activation/loader-orchestrator.ts` | `src/extension-host/policy/loader-discovery-policy.ts` | `partial` | Open-allowlist discovery warnings now resolve through explicit host-owned discovery-policy results before the orchestrator logs them. | +| Loader initial candidate planning and record creation | `src/plugins/loader.ts` | `src/extension-host/activation/loader-records.ts` | `partial` | Duplicate detection, initial record creation, manifest metadata attachment, and first-pass enable-state planning now delegate through host-owned loader-records helpers. | +| Loader entry-path opening and module import | `src/plugins/loader.ts` | `src/extension-host/activation/loader-import.ts` | `partial` | Boundary-checked entry opening and module import now delegate through host-owned loader-import helpers while preserving the current trusted in-process loading model. | +| Loader module-export, config-validation, and memory-slot decisions | `src/plugins/loader.ts` | `src/extension-host/activation/loader-runtime.ts` | `partial` | Module export resolution, export-metadata application, config validation, and early or final memory-slot decisions now delegate through host-owned loader-runtime helpers. | +| Loader post-import planning and register execution | `src/plugins/loader.ts` | `src/extension-host/activation/loader-register.ts` | `partial` | Definition application, post-import validation planning, and `register(...)` execution now delegate through host-owned loader-register helpers while preserving current plugin behavior. | +| Loader per-candidate orchestration | `src/plugins/loader.ts` | `src/extension-host/activation/loader-flow.ts` | `partial` | The per-candidate load flow now runs through a host-owned orchestrator that composes planning, import, runtime validation, register execution, and record-state helpers. | +| Loader top-level load orchestration | `src/plugins/loader.ts` | `src/extension-host/activation/loader-orchestrator.ts` | `partial` | High-level load entry and compatibility facade behavior now route through a host-owned loader orchestrator while `src/plugins/loader.ts` remains the external compatibility surface. | +| Loader host process state | mixed inside `src/plugins/loader.ts` and `src/extension-host/activation/loader-orchestrator.ts` | `src/extension-host/activation/loader-host-state.ts` | `partial` | Shared discovery warning-cache state and loader reset behavior now delegate through a host-owned loader-host-state helper. | +| Loader preflight and cache-hit setup | mixed inside `src/plugins/loader.ts` and `src/extension-host/activation/loader-orchestrator.ts` | `src/extension-host/activation/loader-preflight.ts` | `partial` | Test-default application, config normalization, cache-key construction, cache-hit activation, and command-clear preflight now delegate through a host-owned loader-preflight helper. | +| Loader post-preflight pipeline composition | mixed inside `src/plugins/loader.ts` and `src/extension-host/activation/loader-orchestrator.ts` | `src/extension-host/activation/loader-pipeline.ts` | `partial` | Post-preflight execution setup and session-run composition now delegate through a host-owned loader-pipeline helper. | +| Loader execution setup composition | mixed inside `src/plugins/loader.ts` and `src/extension-host/activation/loader-orchestrator.ts` | `src/extension-host/activation/loader-execution.ts` | `partial` | Runtime creation, registry creation, bootstrap setup, module-loader creation, and session creation now delegate through a host-owned loader-execution helper. | +| Loader discovery and manifest bootstrap | mixed inside `src/plugins/loader.ts` and `src/extension-host/activation/loader-orchestrator.ts` | `src/extension-host/activation/loader-bootstrap.ts` | `partial` | Discovery, manifest loading, manifest diagnostics, discovery-policy logging, provenance building, and candidate ordering now delegate through a host-owned loader-bootstrap helper. | +| Loader mutable activation state session | local variables in `src/extension-host/activation/loader-orchestrator.ts` | `src/extension-host/activation/loader-session.ts` | `partial` | Seen-id tracking, memory-slot selection state, and finalization inputs now live in a host-owned loader session instead of being spread across top-level loader variables. | +| Loader session run and finalization composition | mixed inside `src/extension-host/activation/loader-orchestrator.ts` and `src/extension-host/activation/loader-session.ts` | `src/extension-host/activation/loader-run.ts` | `partial` | Candidate iteration, manifest lookup, per-candidate session processing, and finalization handoff now delegate through a host-owned loader-run helper. | +| Loader activation policy outcomes | open-coded in `src/extension-host/activation/loader-flow.ts` | `src/extension-host/policy/loader-activation-policy.ts` | `partial` | Duplicate precedence, config enablement, and early memory-slot gating now resolve through explicit host-owned activation-policy outcomes instead of remaining as inline loader decisions. | +| Loader record-state transitions | `src/plugins/loader.ts` | `src/extension-host/activation/loader-state.ts` | `partial` | The loader now enforces an explicit lifecycle transition model (`prepared -> imported -> validated -> registered -> ready`, plus terminal `disabled` and `error`) while still mapping back to compatibility `PluginRecord.status` values. | +| Loader finalization policy results | mixed inside `src/extension-host/policy/loader-policy.ts` and `src/extension-host/activation/loader-finalize.ts` | `src/extension-host/policy/loader-finalization-policy.ts` | `partial` | Memory-slot finalization warnings and provenance-based untracked-extension warnings now resolve through explicit host-owned finalization-policy results before the finalizer applies them. | +| Loader final cache, readiness, and activation finalization | `src/plugins/loader.ts` | `src/extension-host/activation/loader-finalize.ts` | `partial` | Cache writes, readiness promotion, and registry activation now delegate through a host-owned loader-finalize helper; broader host lifecycle and policy semantics are still pending. | +| Channel lookup | `src/channels/plugins/index.ts`, `src/channels/plugins/registry-loader.ts`, `src/channels/registry.ts` | extension-host-backed registries plus kernel channel contracts | `partial` | Readers now consume host-owned runtime-registry channel accessors, and channel registrations now also keep host-owned runtime-registry storage with mirrored legacy compatibility arrays. Writes still originate from plugin registration. | +| Dock lookup | `src/channels/dock.ts` | host-owned static descriptors | `partial` | Runtime lookup now uses the host boundary; dock ownership itself has not moved yet. | +| Message-channel normalization | `src/utils/message-channel.ts` | host-owned channel registry view | `partial` | Lookup path now reads through host-owned runtime-registry channel accessors instead of raw legacy channel arrays. | +| Default plugin HTTP route lookup | `src/plugins/http-registry.ts` | host-owned route registry | `partial` | Default route registration and lookup now use host-owned runtime-registry state with a mirrored legacy `registry.httpRoutes` compatibility view. The plugin API still remains the external call surface for static route registration. | +| Channel catalog static metadata | `src/channels/plugins/catalog.ts` | host-owned static descriptors | `partial` | Package metadata parsing now flows through host schema helpers; full canonical catalog migration has not started. | +| Plugin skill discovery | `src/agents/skills/plugin-skills.ts` | host-owned resolved registry | `moved` | Static consumer now reads only resolved-extension data for skill paths and enablement filtering. | +| Plugin auto-enable | `src/config/plugin-auto-enable.ts` | host-owned resolved registry | `partial` | Primary logic runs on resolved-extension data; old manifest-registry injection remains as a compatibility input for older callers and tests. | +| Config validation indexing | `src/config/validation.ts`, `src/config/resolved-extension-validation.ts` | host-owned resolved registry | `moved` | Validation indexing now builds from resolved-extension records instead of flat manifest rows. | +| Config doc baseline generation | `src/config/doc-baseline.ts` | host-owned resolved registry | `moved` | Bundled plugin and channel metadata now load through the resolved-extension registry. | +| Plugin tool resolution and metadata | `src/plugins/tools.ts` | `src/extension-host/contributions/tool-runtime.ts` | `partial` | Optional-tool gating, plugin-id and tool-name conflict handling, tool-factory resolution, and plugin-tool metadata tracking now delegate through a host-owned tool-runtime helper while tool registrations now also keep host-owned runtime-registry storage with a mirrored legacy compatibility view. `src/plugins/tools.ts` remains the loader and config-normalization facade. | +| Plugin provider resolution | `src/plugins/providers.ts` | `src/extension-host/contributions/provider-runtime.ts` | `partial` | Provider projection from registry entries into runtime provider objects now delegates through a host-owned provider-runtime helper while `src/plugins/providers.ts` remains the loader and config-normalization facade. | +| Plugin provider discovery | `src/plugins/provider-discovery.ts` | `src/extension-host/contributions/provider-discovery.ts` | `partial` | Discovery-capable provider filtering, order grouping, and result normalization now delegate through a host-owned provider-discovery helper while `src/plugins/provider-discovery.ts` remains the compatibility facade around the legacy provider loader path. | +| Plugin provider auth helpers | `src/commands/provider-auth-helpers.ts` | `src/extension-host/contributions/provider-auth.ts` | `partial` | Provider matching, auth-method selection, config-patch merging, and default-model application now delegate through a host-owned provider-auth helper while command and onboarding entry points remain compatibility surfaces. | +| Plugin provider wizard metadata and choice resolution | `src/plugins/provider-wizard.ts` | `src/extension-host/contributions/provider-wizard.ts` | `partial` | Onboarding option building, model-picker entry building, and provider-method choice resolution now delegate through a host-owned provider-wizard helper while `src/plugins/provider-wizard.ts` remains the compatibility facade around loader-backed provider access and post-selection hooks. | +| Plugin provider auth application flow | `src/commands/auth-choice.apply.plugin-provider.ts` | `src/extension-host/contributions/provider-auth-flow.ts` | `partial` | Loaded-provider auth application, plugin-enable gating, auth-method execution, and post-auth default-model handling now delegate through a host-owned provider-auth-flow helper while `src/commands/auth-choice.apply.plugin-provider.ts` remains the compatibility entry point. | +| Plugin provider post-selection hook execution | `src/plugins/provider-wizard.ts`, `src/commands/model-picker.ts` | `src/extension-host/contributions/provider-model-selection.ts` | `partial` | Provider post-selection hook lookup and invocation now delegate through a host-owned provider-model-selection helper while `src/plugins/provider-wizard.ts` remains a compatibility facade and existing command consumers continue migrating onto the host-owned surface. | +| Plugin loader activation | `src/plugins/loader.ts` | extension host lifecycle + compatibility loader | `partial` | Activation now routes through `src/extension-host/activation.ts`, but discovery, enablement, provenance, module loading, and policy still live in the legacy plugin loader. | +| Plugin API compatibility facade | `src/plugins/registry.ts` | `src/extension-host/compat/plugin-api.ts` | `partial` | Compatibility `OpenClawPluginApi` composition and logger shaping now delegate through a host-owned helper; concrete registration callbacks now come from `src/extension-host/compat/plugin-registry.ts` while `src/plugins/registry.ts` remains the external wrapper. | +| Plugin registry compatibility facade | `src/plugins/registry.ts` | `src/extension-host/compat/plugin-registry.ts` | `partial` | The compatibility plugin-registry facade now delegates through a host-owned helper; `src/plugins/registry.ts` mainly defines shared types and forwards to the host-owned facade. | +| Plugin registry compatibility policy | mixed inside `src/extension-host/compat/plugin-registry.ts` | `src/extension-host/compat/plugin-registry-compat.ts` | `partial` | Provider normalization, command duplicate enforcement, and registry-local diagnostic shaping now delegate through a host-owned compatibility helper; the underlying provider-validation and plugin-command subsystems still remain legacy-owned. | +| Plugin registry registration actions | mixed inside `src/extension-host/compat/plugin-registry.ts` | `src/extension-host/compat/plugin-registry-registrations.ts` | `partial` | Low-risk registration actions for tools, hooks, gateway methods, HTTP routes, channels, CLI, services, typed hooks, and context engines now delegate through a host-owned helper; the compatibility facade still composes those actions with provider and command compatibility policy. | +| Runtime registry read surface | direct reads of `registry.channels`, `registry.providers`, `registry.tools`, `registry.services`, `registry.cliRegistrars`, `registry.commands`, `registry.gatewayHandlers`, and `registry.httpRoutes` across runtime consumers | `src/extension-host/contributions/runtime-registry.ts` | `partial` | Host-owned runtime-registry accessors now serve channel, provider, tool, service, CLI, command, gateway-method, and HTTP-route consumers. Channel registrations, provider registrations, tool registrations, command registrations, HTTP routes, gateway methods, CLI registrations, and service registrations now also have host-owned runtime-registry storage with mirrored legacy compatibility views, and the CLI pre-load fast path treats any pre-seeded runtime entry surface as already loaded. | +| Channel registration writes | `src/plugins/registry.ts` | host-owned channel registry | `partial` | Validation and normalization now delegate to `src/extension-host/contributions/runtime-registrations.ts`, and writes now land in host-owned runtime-registry channel state via `src/extension-host/contributions/registry-writes.ts` with mirrored legacy compatibility arrays. The legacy plugin API still remains the call surface. | +| Provider registration writes | `src/plugins/registry.ts` | host-owned provider registry | `partial` | Provider normalization and compatibility diagnostics now delegate through `src/extension-host/compat/plugin-registry-compat.ts`, duplicate detection and normalized registration shape still delegate to `src/extension-host/contributions/runtime-registrations.ts`, and writes now land in host-owned runtime-registry provider state via `src/extension-host/contributions/registry-writes.ts` with mirrored legacy compatibility arrays. | +| HTTP route registration writes | `src/plugins/registry.ts` | host-owned route registry | `partial` | Route validation and normalization now delegate to `src/extension-host/contributions/runtime-registrations.ts`, and append or replace writes now land in host-owned runtime-registry route state via `src/extension-host/contributions/registry-writes.ts` with mirrored legacy compatibility arrays. | +| Gateway method registration writes | `src/plugins/registry.ts` | host-owned runtime contribution registry | `partial` | Duplicate detection and normalized method registration now delegate to `src/extension-host/contributions/runtime-registrations.ts`, and writes now land in host-owned runtime-registry gateway-handler state via `src/extension-host/contributions/registry-writes.ts` with a mirrored legacy compatibility map. | +| Tool registration writes | `src/plugins/registry.ts` | host-owned tool registry | `partial` | Tool-name normalization and tool-factory shaping delegate through `src/extension-host/contributions/runtime-registrations.ts`, and writes now land in host-owned runtime-registry tool state via `src/extension-host/contributions/registry-writes.ts` with mirrored legacy compatibility arrays. Duplicate handling still follows the legacy tool path. | +| CLI registration writes | `src/plugins/registry.ts` | host-owned CLI registry | `partial` | CLI command-name normalization delegates through `src/extension-host/contributions/runtime-registrations.ts`, and writes now land in host-owned runtime-registry CLI state via `src/extension-host/contributions/registry-writes.ts` with mirrored legacy compatibility arrays. The legacy plugin API still remains the call surface. | +| Service registration writes | `src/plugins/registry.ts` | host-owned service registry | `partial` | Service-id normalization delegates through `src/extension-host/contributions/runtime-registrations.ts`, writes now land in host-owned runtime-registry service state via `src/extension-host/contributions/registry-writes.ts` with mirrored legacy compatibility arrays, and broader lifecycle ownership now starts in `src/extension-host/contributions/service-lifecycle.ts`. | +| Command registration writes | `src/plugins/registry.ts` | host-owned command registry | `partial` | Command-name normalization and duplicate-enforcement diagnostics now delegate through `src/extension-host/compat/plugin-registry-compat.ts`, and writes now land in host-owned runtime-registry command state via `src/extension-host/contributions/registry-writes.ts` with mirrored legacy compatibility arrays. | +| Plugin command runtime | `src/plugins/commands.ts`, `src/auto-reply/reply/commands-plugin.ts`, `src/auto-reply/status.ts`, `src/extension-host/activation/loader-orchestrator.ts` | `src/extension-host/contributions/command-runtime.ts` | `partial` | Command registration, matching, execution, listing, native command-spec projection, and loader reload clearing now delegate through a host-owned command-runtime helper while `src/plugins/commands.ts` remains the compatibility facade. | +| Context-engine registration writes | `src/plugins/registry.ts` | host-owned context-engine registry | `partial` | Context-engine id normalization now delegates to `src/extension-host/contributions/runtime-registrations.ts`, and the compatibility write now routes through `src/extension-host/contributions/registry-writes.ts`; the actual context-engine registry remains legacy-owned. | +| Legacy hook registration writes | `src/plugins/registry.ts` | host-owned hook registry | `partial` | Hook-entry construction and event normalization now delegate to `src/extension-host/contributions/runtime-registrations.ts`, and the compatibility write now routes through `src/extension-host/contributions/registry-writes.ts`; internal-hook bridging still remains in the legacy plugin registry. | +| Typed-hook registration writes | `src/plugins/registry.ts` | host-owned typed-hook registry | `partial` | Typed-hook record construction and hook-name validation now delegate to `src/extension-host/contributions/runtime-registrations.ts`, and the compatibility write now routes through `src/extension-host/contributions/registry-writes.ts`; prompt-injection policy and execution semantics remain legacy-owned. | +| Hook compatibility policy and bridging | `src/plugins/registry.ts` | `src/extension-host/compat/hook-compat.ts` | `partial` | Legacy internal-hook bridging and typed prompt-injection compatibility policy now delegate through a host-owned helper; actual hook execution ownership remains legacy-owned. | +| Hook execution and global runner | `src/plugins/hook-runner-global.ts`, `src/hooks/internal-hooks.ts`, plugin hook registration in `src/plugins/registry.ts` | canonical kernel event stages + host bridges | `not started` | No canonical event-stage migration has landed yet. | +| Service lifecycle | `src/plugins/services.ts` and plugin service registration | `src/extension-host/contributions/service-lifecycle.ts` | `partial` | Service startup, stop ordering, service-context creation, and failure logging now delegate through a host-owned service-lifecycle helper while `src/plugins/services.ts` remains the compatibility entry point. | +| CLI registration | plugin CLI registration in `src/plugins/registry.ts` and CLI loaders | `src/extension-host/contributions/cli-lifecycle.ts` plus static descriptors where possible | `partial` | CLI duplicate detection, registrar invocation, and async failure logging now delegate through a host-owned CLI-lifecycle helper while `src/plugins/cli.ts` remains the compatibility entry point. | +| Gateway/server methods | `src/plugins/registry.ts` gateway handler registration plus gateway runtime consumers | `src/extension-host/contributions/gateway-methods.ts` plus host-owned runtime contribution registry | `partial` | Gateway method-id aggregation, plugin diagnostic shaping, and extra-handler composition now delegate through a host-owned gateway-methods helper, and gateway handlers now live in host-owned runtime-registry state with a mirrored legacy compatibility map. Request dispatch semantics still live in the gateway server code. | +| Conversation binding ownership, approvals, and restore semantics | `src/plugins/conversation-binding.ts` plus `src/infra/outbound/session-binding-service.ts` | host-owned binding registry and approval policy surface | `not started` | This is a real missing migration surface. The host needs to own plugin-scoped conversation binding, approval persistence, restore-on-restart behavior, detached-binding cleanup, and legacy binding migration without making `src/plugins/conversation-binding.ts` the long-term public surface. | +| Interactive callback routing and namespace ownership | `src/plugins/interactive.ts` plus Telegram and Discord monitor-specific callback wiring | host-owned interaction router and namespace registry | `not started` | This needs to be explicit. The host should own namespace registration, dedupe, fallback rules, and callback dispatch. The first validated rollout may target Telegram and Discord, but the contract itself should stay generic and kernel-agnostic. | +| Ingress claim and bound-route short-circuit semantics | `src/plugins/hooks.ts` typed `inbound_claim` hook plus `src/auto-reply/reply/dispatch-from-config.ts` | canonical event-pipeline ingress claim stage | `not started` | The behavior is conceptually in-scope today, but it should land as a canonical ingress-stage contract with legacy hook bridging only as migration. The key parity rule is first-claim-wins for route ownership while passive observers still run through their own stages. | +| Interactive channel control verbs for bound agents | product-shaped runtime helpers added under `src/plugins/runtime/*` and direct channel-specific helpers in extension code | host-owned adapter runtime contracts and interaction capabilities | `not started` | The host needs a bounded first-cut set of control verbs for interactive agents, such as typing leases plus message or conversation actions. Those verbs should be expressed as generic host-owned adapter capabilities, even if the first validated rollout only exercises them through Telegram and Discord. | +| Slot arbitration | `src/plugins/slots.ts` | host-owned arbitration model | `not started` | Current slot selection remains plugin-era logic. | +| ACP backend registry | `src/acp/runtime/registry.ts` | host-owned runtime-backend registry | `not started` | ACP backends still mutate a global ACP runtime registry directly. | +| Embedding provider registry and fallback routing | `src/memory/embeddings.ts` plus plugin provider capability filtering through `src/plugins/runtime.ts` | host-owned embedding runtime registry for a typed backend family | `not started` | This is a real missing scope area. Embedding providers should be modeled as host-owned subsystem runtimes with explicit capability metadata, request envelopes, provider-id normalization, and fallback rules, not by widening legacy `registerProvider(...)` as the long-term architecture. | +| Media-understanding provider registry and fallback routing | `src/media-understanding/providers/index.ts` plus plugin provider capability filtering through `src/plugins/runtime.ts` | host-owned media runtime registry for a typed backend family | `not started` | Audio transcription, image understanding, and video understanding should be modeled as host-owned subsystem runtimes with capability routing, explicit request envelopes, and fallback behavior rather than as permanent extensions of the plugin-era provider API. | +| TTS provider registry and telephony override routing | `src/tts/providers.ts`, `src/tts/tts.ts`, and plugin provider capability filtering through `src/plugins/runtime.ts` | host-owned TTS runtime registry for a typed backend family | `not started` | TTS providers and telephony TTS overrides should move behind host-owned runtime registries with explicit capability and fallback policy rather than staying coupled to plugin-era provider capabilities and global active-registry reads. | +| Onboarding/install/setup surfaces | `src/plugins/install.ts`, package manifests, channel catalog, onboarding commands | host-owned static descriptors | `partial` | Static metadata normalization has started; full setup/install descriptor migration is not done. | +| Pilot migrations | `extensions/thread-ownership`, `extensions/telegram`, `extensions/acpx` | extension-host path with parity tracking | `not started` | No pilot runs through the host path yet. | + +## Completed Pattern So Far + +The migration pattern used so far is intentional: + +1. Extract a host-owned boundary module. +2. Keep the old plugin-era entry point as a compatibility facade. +3. Move static or lookup-heavy readers first. +4. Add focused seam tests where the dependency graph allows it. +5. Delay loader/lifecycle/event rewrites until more readers already depend on one host-owned boundary. + +That pattern has been used for: + +- active registry ownership +- normalized extension schema and resolved-extension records +- static consumers such as skills, validation, auto-enable, and config baseline generation +- plugin tool resolution, conflict handling, optional-tool gating, and plugin-tool metadata tracking +- plugin provider projection from registry entries into runtime provider objects +- plugin provider discovery filtering, order grouping, and result normalization +- provider matching, auth-method selection, config-patch merging, and default-model application +- provider onboarding option building, model-picker entry building, and provider-method choice resolution +- loaded-provider auth application, plugin-enable gating, auth-method execution, and post-auth default-model handling +- provider post-selection hook lookup and invocation +- loader compatibility, cache control, initial candidate planning, entry-path import, explicit discovery-policy outcomes, explicit activation-policy outcomes, runtime decisions, post-import register flow, per-candidate orchestration, top-level load orchestration, session-owned activation state, explicit loader lifecycle transitions, explicit finalization-policy results, and final cache plus activation finalization +- service startup, stop ordering, and failure logging +- CLI duplicate detection, registrar invocation, and async failure logging +- gateway method-id aggregation, plugin diagnostic shaping, and extra-handler composition +- host-owned runtime registry read accessors for provider, tool, service, CLI, gateway-method, and HTTP-route consumers, plus the broader CLI pre-load fast path those accessors enabled +- explicit scoping of still-unimplemented migration targets: conversation binding ownership, interactive callback routing, ingress claim semantics, and bounded first-cut interactive channel controls +- explicit scoping of still-unimplemented subsystem-runtime targets: embeddings, media understanding, and TTS as host-owned runtime registries with capability routing and fallback +- explicit scoping of extension-backed search as either a canonical tool contribution or an optional host-owned runtime backend, rather than as another universal provider surface + +## Immediate Next Targets + +These are the next lowest-risk cutover steps: + +1. Replace remaining static-only manifest-registry injections with resolved-extension registry inputs where practical. +2. Extend the new loader lifecycle state machine, session-owned activation state, discovery-policy outcomes, activation-policy outcomes, and finalization-policy results into broader activation-state and policy ownership in `src/extension-host/*`. +3. Introduce explicit host-owned registration surfaces for runtime writes, starting with the least-coupled registries. +4. Move minimal SDK compatibility and loader normalization into `src/extension-host/*` without breaking current `openclaw/plugin-sdk/*` loading. +5. Add host-owned surfaces for conversation binding, interactive callback routing, and ingress claim before broadening any legacy plugin-era runtime APIs. +6. Start the first pilot on `extensions/thread-ownership` only after the host-side registry and lifecycle seams are explicit. + +## Explicitly Not Done Yet + +This inventory should not be read as proof that the extension host is fully in charge already. + +The following remain legacy-owned today: + +- activation ordering +- policy gates +- typed and legacy hook execution +- conversation binding ownership, approval persistence, and restore-on-restart behavior +- interaction namespace routing, dedupe, and callback fallback rules +- canonical ingress claim semantics +- generic host-owned interactive channel control contracts +- embedding, media-understanding, and TTS runtime registries +- a clear host-owned split for extension-backed search between canonical tool publication and any optional runtime-internal search backend registry +- slot arbitration +- ACP backend registration +- channel runtime compatibility bridges +- pilot parity tracking diff --git a/src/extension-host/manifests/manifest-registry.test.ts b/src/extension-host/manifests/manifest-registry.test.ts new file mode 100644 index 00000000000..b61e693eea5 --- /dev/null +++ b/src/extension-host/manifests/manifest-registry.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from "vitest"; +import { loadPackageManifest } from "../../plugins/manifest.js"; +import { buildResolvedExtensionRecord } from "./manifest-registry.js"; + +vi.mock("../../plugins/manifest.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadPackageManifest: vi.fn(actual.loadPackageManifest), + }; +}); + +describe("extension host manifest registry", () => { + it("does not fall back to disk package loading when only description is present on the candidate", () => { + const loadPackageManifestMock = vi.mocked(loadPackageManifest); + loadPackageManifestMock.mockClear(); + + buildResolvedExtensionRecord({ + manifest: { + id: "demo", + name: "Demo", + }, + candidate: { + id: "demo", + rootDir: "/plugins/demo", + source: "/plugins/demo/index.ts", + origin: "workspace", + packageDescription: "candidate description only", + }, + manifestPath: "/plugins/demo/plugin.json", + }); + + expect(loadPackageManifestMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/extension-host/manifests/manifest-registry.ts b/src/extension-host/manifests/manifest-registry.ts new file mode 100644 index 00000000000..281270dae83 --- /dev/null +++ b/src/extension-host/manifests/manifest-registry.ts @@ -0,0 +1,53 @@ +import type { PluginCandidate } from "../../plugins/discovery.js"; +import { + loadPackageManifest, + type PackageManifest, + type PluginManifest, +} from "../../plugins/manifest.js"; +import { resolveLegacyExtensionDescriptor, type ResolvedExtension } from "./schema.js"; + +export type ResolvedExtensionRecord = { + extension: ResolvedExtension; + manifestPath: string; + schemaCacheKey?: string; +}; + +export function buildResolvedExtensionRecord(params: { + manifest: PluginManifest; + candidate: PluginCandidate; + manifestPath: string; + schemaCacheKey?: string; + configSchema?: Record; +}): ResolvedExtensionRecord { + const packageDir = params.candidate.packageDir ?? params.candidate.rootDir; + const packageManifest = + params.candidate.packageManifest || + params.candidate.packageName || + params.candidate.packageVersion || + params.candidate.packageDescription + ? ({ + openclaw: params.candidate.packageManifest, + name: params.candidate.packageName, + version: params.candidate.packageVersion, + description: params.candidate.packageDescription, + } as PackageManifest) + : (loadPackageManifest(packageDir, params.candidate.origin !== "bundled") ?? undefined); + + const extension = resolveLegacyExtensionDescriptor({ + manifest: { + ...params.manifest, + configSchema: params.configSchema ?? params.manifest.configSchema, + }, + packageManifest, + origin: params.candidate.origin, + rootDir: params.candidate.rootDir, + source: params.candidate.source, + workspaceDir: params.candidate.workspaceDir, + }); + + return { + extension, + manifestPath: params.manifestPath, + schemaCacheKey: params.schemaCacheKey, + }; +} diff --git a/src/extension-host/manifests/resolved-registry.ts b/src/extension-host/manifests/resolved-registry.ts new file mode 100644 index 00000000000..ccaaf92a557 --- /dev/null +++ b/src/extension-host/manifests/resolved-registry.ts @@ -0,0 +1,70 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import { + loadPluginManifestRegistry, + type PluginManifestRegistry, +} from "../../plugins/manifest-registry.js"; +import type { PluginDiagnostic } from "../../plugins/types.js"; +import type { ResolvedExtension } from "./schema.js"; + +export type ResolvedExtensionRegistryEntry = { + extension: ResolvedExtension; + manifestPath: string; + schemaCacheKey?: string; +}; + +export type ResolvedExtensionRegistry = { + extensions: ResolvedExtensionRegistryEntry[]; + diagnostics: PluginDiagnostic[]; +}; + +export function resolvedExtensionRegistryFromPluginManifestRegistry( + registry: PluginManifestRegistry, +): ResolvedExtensionRegistry { + return { + diagnostics: registry.diagnostics, + extensions: registry.plugins.map((plugin) => ({ + extension: + plugin.resolvedExtension ?? + ({ + id: plugin.id, + name: plugin.name, + description: plugin.description, + version: plugin.version, + kind: plugin.kind, + origin: plugin.origin, + rootDir: plugin.rootDir, + source: plugin.source, + workspaceDir: plugin.workspaceDir, + manifest: { + id: plugin.id, + name: plugin.name, + description: plugin.description, + version: plugin.version, + kind: plugin.kind, + channels: plugin.channels, + providers: plugin.providers, + skills: plugin.skills, + configSchema: plugin.configSchema ?? {}, + uiHints: plugin.configUiHints, + }, + staticMetadata: { + configSchema: plugin.configSchema ?? {}, + configUiHints: plugin.configUiHints, + package: { entries: [] }, + }, + contributions: [], + } satisfies ResolvedExtension), + manifestPath: plugin.manifestPath, + schemaCacheKey: plugin.schemaCacheKey, + })), + }; +} + +export function loadResolvedExtensionRegistry(params: { + config?: OpenClawConfig; + workspaceDir?: string; + cache?: boolean; + env?: NodeJS.ProcessEnv; +}): ResolvedExtensionRegistry { + return resolvedExtensionRegistryFromPluginManifestRegistry(loadPluginManifestRegistry(params)); +} diff --git a/src/extension-host/manifests/schema.test.ts b/src/extension-host/manifests/schema.test.ts new file mode 100644 index 00000000000..89cf344c7d8 --- /dev/null +++ b/src/extension-host/manifests/schema.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_EXTENSION_ENTRY_CANDIDATES, + getExtensionPackageMetadata, + resolveExtensionEntryCandidates, + resolveLegacyExtensionDescriptor, +} from "./schema.js"; + +describe("extension host schema helpers", () => { + it("normalizes package metadata through the host boundary", () => { + const metadata = getExtensionPackageMetadata({ + openclaw: { + channel: { + id: "telegram", + label: "Telegram", + }, + install: { + npmSpec: "@openclaw/telegram", + defaultChoice: "npm", + }, + }, + }); + + expect(metadata).toEqual({ + channel: { + id: "telegram", + label: "Telegram", + }, + install: { + npmSpec: "@openclaw/telegram", + defaultChoice: "npm", + }, + }); + }); + + it("preserves current extension entry resolution semantics", () => { + expect(resolveExtensionEntryCandidates(undefined)).toEqual({ + status: "missing", + entries: [], + }); + expect(DEFAULT_EXTENSION_ENTRY_CANDIDATES).toContain("index.ts"); + expect( + resolveExtensionEntryCandidates({ + openclaw: { + extensions: ["./dist/index.js"], + }, + }), + ).toEqual({ + status: "ok", + entries: ["./dist/index.js"], + }); + }); + + it("builds a normalized legacy extension descriptor", () => { + const resolved = resolveLegacyExtensionDescriptor({ + manifest: { + id: "telegram", + name: "Telegram", + configSchema: { type: "object" }, + channels: ["telegram"], + providers: ["telegram-provider"], + }, + packageManifest: { + openclaw: { + channel: { + id: "telegram", + label: "Telegram", + }, + install: { + npmSpec: "@openclaw/telegram", + defaultChoice: "npm", + }, + }, + }, + origin: "bundled", + rootDir: "/tmp/telegram", + source: "/tmp/telegram/index.ts", + }); + + expect(resolved.id).toBe("telegram"); + expect(resolved.staticMetadata.package.entries).toEqual([ + "index.ts", + "index.js", + "index.mjs", + "index.cjs", + ]); + expect(resolved.contributions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "telegram/config", + kind: "surface.config", + }), + expect.objectContaining({ + id: "telegram/channel/telegram", + kind: "adapter.runtime", + }), + expect.objectContaining({ + id: "telegram/provider/telegram-provider", + kind: "capability.provider-integration", + }), + expect.objectContaining({ + id: "telegram/channel-catalog", + kind: "surface.channel-catalog", + }), + expect.objectContaining({ + id: "telegram/install", + kind: "surface.install", + }), + ]), + ); + }); +}); diff --git a/src/extension-host/manifests/schema.ts b/src/extension-host/manifests/schema.ts new file mode 100644 index 00000000000..35ca87ca847 --- /dev/null +++ b/src/extension-host/manifests/schema.ts @@ -0,0 +1,181 @@ +import { + DEFAULT_PLUGIN_ENTRY_CANDIDATES, + getPackageManifestMetadata, + resolvePackageExtensionEntries, + type OpenClawPackageManifest, + type PackageExtensionResolution, + type PackageManifest, + type PluginManifest, +} from "../../plugins/manifest.js"; +import type { PluginConfigUiHint, PluginKind, PluginOrigin } from "../../plugins/types.js"; + +export type { OpenClawPackageManifest, PackageExtensionResolution, PackageManifest }; + +export const DEFAULT_EXTENSION_ENTRY_CANDIDATES = DEFAULT_PLUGIN_ENTRY_CANDIDATES; + +export type ContributionPolicy = { + promptMutation?: "none" | "append-only" | "replace-allowed"; + routeEffect?: "observe-only" | "augment" | "veto" | "resolve"; + executionMode?: "sync-hot-path" | "sequential" | "parallel"; +}; + +export type ResolvedContributionKind = + | "adapter.runtime" + | "capability.context-engine" + | "capability.memory" + | "capability.provider-integration" + | "surface.channel-catalog" + | "surface.config" + | "surface.install"; + +export type ResolvedContribution = { + id: string; + kind: ResolvedContributionKind; + source: "manifest" | "package"; + policy?: ContributionPolicy; + metadata?: Record; +}; + +export type ResolvedExtensionPackageMetadata = { + entries: string[]; + manifest?: OpenClawPackageManifest; +}; + +export type ResolvedExtensionStaticMetadata = { + configSchema: Record; + configUiHints?: Record; + package: ResolvedExtensionPackageMetadata; +}; + +export type ResolvedExtension = { + id: string; + name?: string; + description?: string; + version?: string; + kind?: PluginKind; + origin?: PluginOrigin; + rootDir?: string; + source?: string; + workspaceDir?: string; + manifest: PluginManifest; + staticMetadata: ResolvedExtensionStaticMetadata; + contributions: ResolvedContribution[]; +}; + +export function getExtensionPackageMetadata( + manifest: PackageManifest | undefined, +): OpenClawPackageManifest | undefined { + return getPackageManifestMetadata(manifest); +} + +export function resolveExtensionEntryCandidates( + manifest: PackageManifest | undefined, +): PackageExtensionResolution { + return resolvePackageExtensionEntries(manifest); +} + +function normalizeResolvedEntries( + packageManifest: PackageManifest | undefined, +): ResolvedExtensionPackageMetadata { + const manifest = getExtensionPackageMetadata(packageManifest); + const entries = resolveExtensionEntryCandidates(packageManifest); + return { + entries: + entries.status === "ok" ? entries.entries : Array.from(DEFAULT_EXTENSION_ENTRY_CANDIDATES), + manifest, + }; +} + +export function resolveLegacyExtensionDescriptor(params: { + manifest: PluginManifest; + packageManifest?: PackageManifest; + origin?: PluginOrigin; + rootDir?: string; + source?: string; + workspaceDir?: string; +}): ResolvedExtension { + const packageMetadata = normalizeResolvedEntries(params.packageManifest); + const contributions: ResolvedContribution[] = [ + { + id: `${params.manifest.id}/config`, + kind: "surface.config", + source: "manifest", + }, + ]; + + for (const channelId of params.manifest.channels ?? []) { + contributions.push({ + id: `${params.manifest.id}/channel/${channelId}`, + kind: "adapter.runtime", + source: "manifest", + metadata: { channelId }, + }); + } + + for (const providerId of params.manifest.providers ?? []) { + contributions.push({ + id: `${params.manifest.id}/provider/${providerId}`, + kind: "capability.provider-integration", + source: "manifest", + metadata: { providerId }, + }); + } + + if (params.manifest.kind === "memory") { + contributions.push({ + id: `${params.manifest.id}/memory`, + kind: "capability.memory", + source: "manifest", + }); + } + + if (params.manifest.kind === "context-engine") { + contributions.push({ + id: `${params.manifest.id}/context-engine`, + kind: "capability.context-engine", + source: "manifest", + }); + } + + if (packageMetadata.manifest?.channel) { + contributions.push({ + id: `${params.manifest.id}/channel-catalog`, + kind: "surface.channel-catalog", + source: "package", + metadata: { + channelId: packageMetadata.manifest.channel.id, + }, + }); + } + + if (packageMetadata.manifest?.install) { + contributions.push({ + id: `${params.manifest.id}/install`, + kind: "surface.install", + source: "package", + metadata: { + defaultChoice: packageMetadata.manifest.install.defaultChoice, + npmSpec: packageMetadata.manifest.install.npmSpec, + }, + }); + } + + return { + id: params.manifest.id, + name: params.manifest.name, + description: params.manifest.description, + version: params.manifest.version, + kind: params.manifest.kind, + origin: params.origin, + rootDir: params.rootDir, + source: params.source, + workspaceDir: params.workspaceDir, + manifest: params.manifest, + staticMetadata: { + configSchema: params.manifest.configSchema, + configUiHints: params.manifest.uiHints, + package: packageMetadata, + }, + contributions, + }; +} diff --git a/src/extension-host/policy/loader-activation-policy.test.ts b/src/extension-host/policy/loader-activation-policy.test.ts new file mode 100644 index 00000000000..bd26c609fde --- /dev/null +++ b/src/extension-host/policy/loader-activation-policy.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import { normalizePluginsConfig } from "../../plugins/config-state.js"; +import type { PluginCandidate } from "../../plugins/discovery.js"; +import type { PluginManifestRecord } from "../../plugins/manifest-registry.js"; +import { resolveExtensionHostActivationPolicy } from "./loader-activation-policy.js"; + +function createCandidate(overrides: Partial = {}): PluginCandidate { + return { + source: "/plugins/demo/index.ts", + rootDir: "/plugins/demo", + packageDir: "/plugins/demo", + origin: "workspace", + workspaceDir: "/workspace", + ...overrides, + }; +} + +function createManifestRecord(overrides: Partial = {}): PluginManifestRecord { + return { + id: "demo", + name: "Demo", + description: "Demo plugin", + version: "1.0.0", + kind: "tool", + channels: [], + providers: [], + skills: [], + origin: "workspace", + workspaceDir: "/workspace", + rootDir: "/plugins/demo", + source: "/plugins/demo/index.ts", + manifestPath: "/plugins/demo/openclaw.plugin.json", + configSchema: { + type: "object", + properties: { + enabled: { type: "boolean" }, + }, + }, + configUiHints: { + enabled: { sensitive: false }, + }, + resolvedExtension: { + id: "demo", + source: "/plugins/demo/index.ts", + origin: "workspace", + rootDir: "/plugins/demo", + workspaceDir: "/workspace", + static: { + package: {}, + config: {}, + setup: {}, + }, + runtime: { + kind: "tool", + contributions: [], + }, + policy: {}, + }, + ...overrides, + }; +} + +describe("extension host loader activation policy", () => { + it("returns duplicate policy outcomes", () => { + const outcome = resolveExtensionHostActivationPolicy({ + candidate: createCandidate(), + manifestRecord: createManifestRecord(), + normalizedConfig: normalizePluginsConfig({}), + rootConfig: {}, + seenIds: new Map([["demo", "bundled" as const]]), + selectedMemoryPluginId: null, + }); + + expect(outcome).toMatchObject({ + kind: "duplicate", + pluginId: "demo", + record: { + status: "disabled", + error: "overridden by bundled plugin", + }, + }); + }); + + it("returns disabled policy outcomes for config-disabled plugins", () => { + const rootConfig: OpenClawConfig = { + plugins: { + entries: { + demo: { + enabled: false, + }, + }, + }, + }; + + const outcome = resolveExtensionHostActivationPolicy({ + candidate: createCandidate(), + manifestRecord: createManifestRecord(), + normalizedConfig: normalizePluginsConfig(rootConfig.plugins), + rootConfig, + seenIds: new Map(), + selectedMemoryPluginId: null, + }); + + expect(outcome).toMatchObject({ + kind: "disabled", + pluginId: "demo", + reason: "disabled in config", + record: { + status: "disabled", + lifecycleState: "disabled", + }, + }); + }); + + it("returns candidate outcomes when policy allows activation", () => { + const outcome = resolveExtensionHostActivationPolicy({ + candidate: createCandidate({ origin: "bundled" }), + manifestRecord: createManifestRecord({ origin: "bundled", kind: "memory" }), + normalizedConfig: normalizePluginsConfig({ + slots: { + memory: "demo", + }, + }), + rootConfig: {}, + seenIds: new Map(), + selectedMemoryPluginId: null, + }); + + expect(outcome).toMatchObject({ + kind: "candidate", + pluginId: "demo", + record: { + lifecycleState: "prepared", + }, + }); + }); +}); diff --git a/src/extension-host/policy/loader-activation-policy.ts b/src/extension-host/policy/loader-activation-policy.ts new file mode 100644 index 00000000000..f0bf717ac3f --- /dev/null +++ b/src/extension-host/policy/loader-activation-policy.ts @@ -0,0 +1,101 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import type { NormalizedPluginsConfig } from "../../plugins/config-state.js"; +import type { PluginCandidate } from "../../plugins/discovery.js"; +import type { PluginManifestRecord } from "../../plugins/manifest-registry.js"; +import type { PluginRecord } from "../../plugins/registry.js"; +import { prepareExtensionHostPluginCandidate } from "../activation/loader-records.js"; +import { resolveExtensionHostEarlyMemoryDecision } from "../activation/loader-runtime.js"; +import { setExtensionHostPluginRecordDisabled } from "../activation/loader-state.js"; + +export type ExtensionHostActivationPolicyOutcome = + | { + kind: "duplicate"; + pluginId: string; + record: PluginRecord; + } + | { + kind: "disabled"; + pluginId: string; + record: PluginRecord; + entry: + | { + enabled?: boolean; + hooks?: { + allowPromptInjection?: boolean; + }; + config?: unknown; + } + | undefined; + reason?: string; + } + | { + kind: "candidate"; + pluginId: string; + record: PluginRecord; + entry: + | { + enabled?: boolean; + hooks?: { + allowPromptInjection?: boolean; + }; + config?: unknown; + } + | undefined; + }; + +export function resolveExtensionHostActivationPolicy(params: { + candidate: PluginCandidate; + manifestRecord: PluginManifestRecord; + normalizedConfig: NormalizedPluginsConfig; + rootConfig: OpenClawConfig; + seenIds: Map; + selectedMemoryPluginId: string | null; +}): ExtensionHostActivationPolicyOutcome { + const preparedCandidate = prepareExtensionHostPluginCandidate({ + candidate: params.candidate, + manifestRecord: params.manifestRecord, + normalizedConfig: params.normalizedConfig, + rootConfig: params.rootConfig, + seenIds: params.seenIds, + }); + if (preparedCandidate.kind === "duplicate") { + return preparedCandidate; + } + + const { pluginId, record, entry, enableState } = preparedCandidate; + if (!enableState.enabled) { + setExtensionHostPluginRecordDisabled(record, enableState.reason); + return { + kind: "disabled", + pluginId, + record, + entry, + reason: enableState.reason, + }; + } + + const earlyMemoryDecision = resolveExtensionHostEarlyMemoryDecision({ + origin: params.candidate.origin, + manifestKind: params.manifestRecord.kind, + recordId: record.id, + memorySlot: params.normalizedConfig.slots.memory, + selectedMemoryPluginId: params.selectedMemoryPluginId, + }); + if (!earlyMemoryDecision.enabled) { + setExtensionHostPluginRecordDisabled(record, earlyMemoryDecision.reason); + return { + kind: "disabled", + pluginId, + record, + entry, + reason: earlyMemoryDecision.reason, + }; + } + + return { + kind: "candidate", + pluginId, + record, + entry, + }; +} diff --git a/src/extension-host/policy/loader-discovery-policy.test.ts b/src/extension-host/policy/loader-discovery-policy.test.ts new file mode 100644 index 00000000000..1cdc8cb793b --- /dev/null +++ b/src/extension-host/policy/loader-discovery-policy.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { resolveExtensionHostDiscoveryPolicy } from "./loader-discovery-policy.js"; + +describe("extension host loader discovery policy", () => { + it("warns when allowlist is open for non-bundled discoverable plugins", () => { + const warningCache = new Set(); + + const result = resolveExtensionHostDiscoveryPolicy({ + pluginsEnabled: true, + allow: [], + warningCacheKey: "warn-key", + warningCache, + discoverablePlugins: [ + { id: "bundled", source: "/bundled/index.js", origin: "bundled" }, + { id: "workspace-demo", source: "/workspace/demo.js", origin: "workspace" }, + ], + }); + + expect(result.warningMessages).toHaveLength(1); + expect(result.warningMessages[0]).toContain("plugins.allow is empty"); + expect(warningCache.has("warn-key")).toBe(true); + }); + + it("does not warn twice for the same cache key", () => { + const warningCache = new Set(["warn-key"]); + + const result = resolveExtensionHostDiscoveryPolicy({ + pluginsEnabled: true, + allow: [], + warningCacheKey: "warn-key", + warningCache, + discoverablePlugins: [ + { id: "workspace-demo", source: "/workspace/demo.js", origin: "workspace" }, + ], + }); + + expect(result.warningMessages).toHaveLength(0); + }); +}); diff --git a/src/extension-host/policy/loader-discovery-policy.ts b/src/extension-host/policy/loader-discovery-policy.ts new file mode 100644 index 00000000000..c1b40e68c1f --- /dev/null +++ b/src/extension-host/policy/loader-discovery-policy.ts @@ -0,0 +1,33 @@ +import type { PluginRecord } from "../../plugins/registry.js"; + +export function resolveExtensionHostDiscoveryPolicy(params: { + pluginsEnabled: boolean; + allow: string[]; + warningCacheKey: string; + warningCache: Set; + discoverablePlugins: Array<{ id: string; source: string; origin: PluginRecord["origin"] }>; +}): { + warningMessages: string[]; +} { + if (!params.pluginsEnabled || params.allow.length > 0) { + return { warningMessages: [] }; + } + + const nonBundled = params.discoverablePlugins.filter((entry) => entry.origin !== "bundled"); + if (nonBundled.length === 0 || params.warningCache.has(params.warningCacheKey)) { + return { warningMessages: [] }; + } + + const preview = nonBundled + .slice(0, 6) + .map((entry) => `${entry.id} (${entry.source})`) + .join(", "); + const extra = nonBundled.length > 6 ? ` (+${nonBundled.length - 6} more)` : ""; + params.warningCache.add(params.warningCacheKey); + + return { + warningMessages: [ + `[plugins] plugins.allow is empty; discovered non-bundled plugins may auto-load: ${preview}${extra}. Set plugins.allow to explicit trusted ids.`, + ], + }; +} diff --git a/src/extension-host/policy/loader-finalization-policy.test.ts b/src/extension-host/policy/loader-finalization-policy.test.ts new file mode 100644 index 00000000000..d10c3360020 --- /dev/null +++ b/src/extension-host/policy/loader-finalization-policy.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import type { PluginRegistry } from "../../plugins/registry.js"; +import { resolveExtensionHostFinalizationPolicy } from "./loader-finalization-policy.js"; + +function createRegistry(): PluginRegistry { + return { + plugins: [], + tools: [], + hooks: [], + typedHooks: [], + channels: [], + providers: [], + gatewayHandlers: {}, + httpRoutes: [], + cliRegistrars: [], + services: [], + commands: [], + diagnostics: [], + }; +} + +describe("extension host loader finalization policy", () => { + it("emits memory-slot diagnostics when no selected memory plugin matched", () => { + const result = resolveExtensionHostFinalizationPolicy({ + registry: createRegistry(), + memorySlot: "memory-a", + memorySlotMatched: false, + provenance: { + loadPathMatcher: { exact: new Set(), dirs: [] }, + installRules: new Map(), + }, + env: process.env, + }); + + expect(result.diagnostics).toContainEqual({ + level: "warn", + message: "memory slot plugin not found or not marked as memory: memory-a", + }); + }); + + it("emits provenance warnings for untracked non-bundled plugins", () => { + const registry = createRegistry(); + registry.plugins.push({ + id: "demo", + name: "demo", + source: "/tmp/demo/index.js", + origin: "workspace", + enabled: true, + status: "loaded", + lifecycleState: "ready", + toolNames: [], + hookNames: [], + channelIds: [], + providerIds: [], + gatewayMethods: [], + cliCommands: [], + services: [], + commands: [], + httpRoutes: 0, + hookCount: 0, + configSchema: false, + }); + + const result = resolveExtensionHostFinalizationPolicy({ + registry, + memorySlotMatched: true, + provenance: { + loadPathMatcher: { exact: new Set(), dirs: [] }, + installRules: new Map(), + }, + env: process.env, + }); + + expect(result.diagnostics).toContainEqual({ + level: "warn", + pluginId: "demo", + source: "/tmp/demo/index.js", + message: + "loaded without install/load-path provenance; treat as untracked local code and pin trust via plugins.allow or install records", + }); + expect(result.warningMessages[0]).toContain("[plugins] demo:"); + }); +}); diff --git a/src/extension-host/policy/loader-finalization-policy.ts b/src/extension-host/policy/loader-finalization-policy.ts new file mode 100644 index 00000000000..2c516022334 --- /dev/null +++ b/src/extension-host/policy/loader-finalization-policy.ts @@ -0,0 +1,57 @@ +import type { PluginRegistry } from "../../plugins/registry.js"; +import type { PluginDiagnostic } from "../../plugins/types.js"; +import { + isExtensionHostTrackedByProvenance, + safeRealpathOrResolveExtensionHostPath, + type ExtensionHostProvenanceIndex, +} from "./loader-provenance.js"; + +export function resolveExtensionHostFinalizationPolicy(params: { + registry: PluginRegistry; + memorySlot?: string | null; + memorySlotMatched: boolean; + provenance: ExtensionHostProvenanceIndex; + env: NodeJS.ProcessEnv; +}): { + diagnostics: PluginDiagnostic[]; + warningMessages: string[]; +} { + const diagnostics: PluginDiagnostic[] = []; + const warningMessages: string[] = []; + + if (typeof params.memorySlot === "string" && !params.memorySlotMatched) { + diagnostics.push({ + level: "warn", + message: `memory slot plugin not found or not marked as memory: ${params.memorySlot}`, + }); + } + + for (const plugin of params.registry.plugins) { + if (plugin.status !== "loaded" || plugin.origin === "bundled") { + continue; + } + if ( + isExtensionHostTrackedByProvenance({ + pluginId: plugin.id, + source: plugin.source, + index: params.provenance, + env: params.env, + }) + ) { + continue; + } + const message = + "loaded without install/load-path provenance; treat as untracked local code and pin trust via plugins.allow or install records"; + diagnostics.push({ + level: "warn", + pluginId: plugin.id, + source: plugin.source, + message, + }); + warningMessages.push( + `[plugins] ${plugin.id}: ${message} (${safeRealpathOrResolveExtensionHostPath(plugin.source)})`, + ); + } + + return { diagnostics, warningMessages }; +} diff --git a/src/extension-host/policy/loader-policy.test.ts b/src/extension-host/policy/loader-policy.test.ts new file mode 100644 index 00000000000..c71dfd001be --- /dev/null +++ b/src/extension-host/policy/loader-policy.test.ts @@ -0,0 +1,93 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import type { PluginCandidate } from "../../plugins/discovery.js"; +import { + buildExtensionHostProvenanceIndex, + compareExtensionHostDuplicateCandidateOrder, + createExtensionHostPluginRecord, +} from "./loader-policy.js"; + +const tempDirs: string[] = []; + +function makeTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-loader-policy-")); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("extension host loader policy", () => { + it("creates normalized plugin records", () => { + const record = createExtensionHostPluginRecord({ + id: "demo-plugin", + source: "/plugins/demo/index.js", + origin: "workspace", + enabled: true, + configSchema: true, + }); + + expect(record).toMatchObject({ + id: "demo-plugin", + name: "demo-plugin", + source: "/plugins/demo/index.js", + origin: "workspace", + enabled: true, + status: "loaded", + lifecycleState: "prepared", + configSchema: true, + }); + }); + + it("prefers explicit global installs over auto-discovered globals", () => { + const installDir = makeTempDir(); + const autoDir = makeTempDir(); + const env = { ...process.env, HOME: makeTempDir() }; + const provenance = buildExtensionHostProvenanceIndex({ + config: { + plugins: { + installs: { + demo: { + installPath: installDir, + }, + }, + }, + }, + normalizedLoadPaths: [], + env, + }); + + const manifestByRoot = new Map([ + [installDir, { id: "demo" }], + [autoDir, { id: "demo" }], + ]); + const explicitCandidate: PluginCandidate = { + idHint: "demo", + source: path.join(installDir, "index.js"), + rootDir: installDir, + origin: "global", + }; + const autoCandidate: PluginCandidate = { + idHint: "demo", + source: path.join(autoDir, "index.js"), + rootDir: autoDir, + origin: "global", + }; + + expect( + compareExtensionHostDuplicateCandidateOrder({ + left: explicitCandidate, + right: autoCandidate, + manifestByRoot, + provenance, + env, + }), + ).toBeLessThan(0); + }); +}); diff --git a/src/extension-host/policy/loader-policy.ts b/src/extension-host/policy/loader-policy.ts new file mode 100644 index 00000000000..6ff98e5c534 --- /dev/null +++ b/src/extension-host/policy/loader-policy.ts @@ -0,0 +1,188 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import type { PluginCandidate } from "../../plugins/discovery.js"; +import type { PluginRecord, PluginRegistry } from "../../plugins/registry.js"; +import type { PluginDiagnostic, PluginLogger } from "../../plugins/types.js"; +import { + appendExtensionHostPluginRecord, + setExtensionHostPluginRecordLifecycleState, +} from "../activation/loader-state.js"; +import { + addExtensionHostPathToMatcher, + createExtensionHostPathMatcher, + matchesExplicitExtensionHostInstallRule, + type ExtensionHostInstallTrackingRule, + type ExtensionHostProvenanceIndex, +} from "./loader-provenance.js"; + +export function createExtensionHostPluginRecord(params: { + id: string; + name?: string; + description?: string; + version?: string; + source: string; + origin: PluginRecord["origin"]; + workspaceDir?: string; + enabled: boolean; + configSchema: boolean; +}): PluginRecord { + const record: PluginRecord = { + id: params.id, + name: params.name ?? params.id, + description: params.description, + version: params.version, + source: params.source, + origin: params.origin, + workspaceDir: params.workspaceDir, + enabled: params.enabled, + status: params.enabled ? "loaded" : "disabled", + toolNames: [], + hookNames: [], + channelIds: [], + providerIds: [], + gatewayMethods: [], + cliCommands: [], + services: [], + commands: [], + httpRoutes: 0, + hookCount: 0, + configSchema: params.configSchema, + configUiHints: undefined, + configJsonSchema: undefined, + }; + return setExtensionHostPluginRecordLifecycleState( + record, + params.enabled ? "prepared" : "disabled", + ); +} + +export function recordExtensionHostPluginError(params: { + logger: PluginLogger; + registry: PluginRegistry; + record: PluginRecord; + seenIds: Map; + pluginId: string; + origin: PluginRecord["origin"]; + error: unknown; + logPrefix: string; + diagnosticMessagePrefix: string; +}): void { + const errorText = String(params.error); + const deprecatedApiHint = + errorText.includes("api.registerHttpHandler") && errorText.includes("is not a function") + ? "deprecated api.registerHttpHandler(...) was removed; use api.registerHttpRoute(...) for plugin-owned routes or registerPluginHttpRoute(...) for dynamic lifecycle routes" + : null; + const displayError = deprecatedApiHint ? `${deprecatedApiHint} (${errorText})` : errorText; + params.logger.error(`${params.logPrefix}${displayError}`); + setExtensionHostPluginRecordLifecycleState(params.record, "error", { error: displayError }); + appendExtensionHostPluginRecord({ + registry: params.registry, + record: params.record, + seenIds: params.seenIds, + pluginId: params.pluginId, + origin: params.origin, + }); + params.registry.diagnostics.push({ + level: "error", + pluginId: params.record.id, + source: params.record.source, + message: `${params.diagnosticMessagePrefix}${displayError}`, + }); +} + +export function pushExtensionHostDiagnostics( + diagnostics: PluginDiagnostic[], + append: PluginDiagnostic[], +): void { + diagnostics.push(...append); +} + +export function buildExtensionHostProvenanceIndex(params: { + config: OpenClawConfig; + normalizedLoadPaths: string[]; + env: NodeJS.ProcessEnv; +}): ExtensionHostProvenanceIndex { + const loadPathMatcher = createExtensionHostPathMatcher(); + for (const loadPath of params.normalizedLoadPaths) { + addExtensionHostPathToMatcher(loadPathMatcher, loadPath, params.env); + } + + const installRules = new Map(); + const installs = params.config.plugins?.installs ?? {}; + for (const [pluginId, install] of Object.entries(installs)) { + const rule: ExtensionHostInstallTrackingRule = { + trackedWithoutPaths: false, + matcher: createExtensionHostPathMatcher(), + }; + const trackedPaths = [install.installPath, install.sourcePath] + .map((entry) => (typeof entry === "string" ? entry.trim() : "")) + .filter(Boolean); + if (trackedPaths.length === 0) { + rule.trackedWithoutPaths = true; + } else { + for (const trackedPath of trackedPaths) { + addExtensionHostPathToMatcher(rule.matcher, trackedPath, params.env); + } + } + installRules.set(pluginId, rule); + } + + return { loadPathMatcher, installRules }; +} + +function resolveCandidateDuplicateRank(params: { + candidate: PluginCandidate; + manifestByRoot: Map; + provenance: ExtensionHostProvenanceIndex; + env: NodeJS.ProcessEnv; +}): number { + const manifestRecord = params.manifestByRoot.get(params.candidate.rootDir); + const pluginId = manifestRecord?.id; + const isExplicitInstall = + params.candidate.origin === "global" && + pluginId !== undefined && + matchesExplicitExtensionHostInstallRule({ + pluginId, + source: params.candidate.source, + index: params.provenance, + env: params.env, + }); + + switch (params.candidate.origin) { + case "config": + return 0; + case "workspace": + return 1; + case "global": + return isExplicitInstall ? 2 : 4; + case "bundled": + return 3; + } +} + +export function compareExtensionHostDuplicateCandidateOrder(params: { + left: PluginCandidate; + right: PluginCandidate; + manifestByRoot: Map; + provenance: ExtensionHostProvenanceIndex; + env: NodeJS.ProcessEnv; +}): number { + const leftPluginId = params.manifestByRoot.get(params.left.rootDir)?.id; + const rightPluginId = params.manifestByRoot.get(params.right.rootDir)?.id; + if (!leftPluginId || leftPluginId !== rightPluginId) { + return 0; + } + return ( + resolveCandidateDuplicateRank({ + candidate: params.left, + manifestByRoot: params.manifestByRoot, + provenance: params.provenance, + env: params.env, + }) - + resolveCandidateDuplicateRank({ + candidate: params.right, + manifestByRoot: params.manifestByRoot, + provenance: params.provenance, + env: params.env, + }) + ); +} diff --git a/src/extension-host/policy/loader-provenance.test.ts b/src/extension-host/policy/loader-provenance.test.ts new file mode 100644 index 00000000000..74964ce5288 --- /dev/null +++ b/src/extension-host/policy/loader-provenance.test.ts @@ -0,0 +1,88 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + addExtensionHostPathToMatcher, + createExtensionHostPathMatcher, + isExtensionHostTrackedByProvenance, + matchesExplicitExtensionHostInstallRule, + safeRealpathOrResolveExtensionHostPath, + type ExtensionHostProvenanceIndex, +} from "./loader-provenance.js"; + +const tempDirs: string[] = []; + +function makeTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-loader-provenance-")); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("extension host loader provenance", () => { + it("tracks plugins by load path directories", () => { + const trackedDir = makeTempDir(); + const trackedFile = path.join(trackedDir, "tracked.js"); + fs.writeFileSync(trackedFile, "export {};\n", "utf8"); + + const loadPathMatcher = createExtensionHostPathMatcher(); + addExtensionHostPathToMatcher(loadPathMatcher, trackedDir); + + const index: ExtensionHostProvenanceIndex = { + loadPathMatcher, + installRules: new Map(), + }; + + expect( + isExtensionHostTrackedByProvenance({ + pluginId: "tracked", + source: trackedFile, + index, + env: process.env, + }), + ).toBe(true); + }); + + it("matches explicit install rules only when tracked paths are present", () => { + const installDir = makeTempDir(); + const installFile = path.join(installDir, "plugin.js"); + fs.writeFileSync(installFile, "export {};\n", "utf8"); + + const installMatcher = createExtensionHostPathMatcher(); + addExtensionHostPathToMatcher(installMatcher, installDir); + + const index: ExtensionHostProvenanceIndex = { + loadPathMatcher: createExtensionHostPathMatcher(), + installRules: new Map([ + [ + "demo", + { + trackedWithoutPaths: false, + matcher: installMatcher, + }, + ], + ]), + }; + + expect( + matchesExplicitExtensionHostInstallRule({ + pluginId: "demo", + source: installFile, + index, + env: process.env, + }), + ).toBe(true); + }); + + it("falls back to resolved paths when realpath fails", () => { + const missingPath = path.join(makeTempDir(), "missing.js"); + + expect(safeRealpathOrResolveExtensionHostPath(missingPath)).toBe(path.resolve(missingPath)); + }); +}); diff --git a/src/extension-host/policy/loader-provenance.ts b/src/extension-host/policy/loader-provenance.ts new file mode 100644 index 00000000000..2ebc07d62bd --- /dev/null +++ b/src/extension-host/policy/loader-provenance.ts @@ -0,0 +1,98 @@ +import fs from "node:fs"; +import path from "node:path"; +import { isPathInside, safeStatSync } from "../../plugins/path-safety.js"; +import { resolveUserPath } from "../../utils.js"; + +export type ExtensionHostPathMatcher = { + exact: Set; + dirs: string[]; +}; + +export type ExtensionHostInstallTrackingRule = { + trackedWithoutPaths: boolean; + matcher: ExtensionHostPathMatcher; +}; + +export type ExtensionHostProvenanceIndex = { + loadPathMatcher: ExtensionHostPathMatcher; + installRules: Map; +}; + +export function safeRealpathOrResolveExtensionHostPath(value: string): string { + try { + return fs.realpathSync(value); + } catch { + return path.resolve(value); + } +} + +export function createExtensionHostPathMatcher(): ExtensionHostPathMatcher { + return { exact: new Set(), dirs: [] }; +} + +export function addExtensionHostPathToMatcher( + matcher: ExtensionHostPathMatcher, + rawPath: string, + env: NodeJS.ProcessEnv = process.env, +): void { + const trimmed = rawPath.trim(); + if (!trimmed) { + return; + } + const resolved = resolveUserPath(trimmed, env); + if (!resolved) { + return; + } + if (matcher.exact.has(resolved) || matcher.dirs.includes(resolved)) { + return; + } + const stat = safeStatSync(resolved); + if (stat?.isDirectory()) { + matcher.dirs.push(resolved); + return; + } + matcher.exact.add(resolved); +} + +export function matchesExtensionHostPathMatcher( + matcher: ExtensionHostPathMatcher, + sourcePath: string, +): boolean { + if (matcher.exact.has(sourcePath)) { + return true; + } + return matcher.dirs.some((dirPath) => isPathInside(dirPath, sourcePath)); +} + +export function isExtensionHostTrackedByProvenance(params: { + pluginId: string; + source: string; + index: ExtensionHostProvenanceIndex; + env: NodeJS.ProcessEnv; +}): boolean { + const sourcePath = resolveUserPath(params.source, params.env); + const installRule = params.index.installRules.get(params.pluginId); + if (installRule) { + if (installRule.trackedWithoutPaths) { + return true; + } + if (matchesExtensionHostPathMatcher(installRule.matcher, sourcePath)) { + return true; + } + } + return matchesExtensionHostPathMatcher(params.index.loadPathMatcher, sourcePath); +} + +export function matchesExplicitExtensionHostInstallRule(params: { + pluginId: string; + source: string; + index: ExtensionHostProvenanceIndex; + env: NodeJS.ProcessEnv; +}): boolean { + const sourcePath = resolveUserPath(params.source, params.env); + const installRule = params.index.installRules.get(params.pluginId); + if (!installRule || installRule.trackedWithoutPaths) { + return false; + } + return matchesExtensionHostPathMatcher(installRule.matcher, sourcePath); +} diff --git a/src/extension-host/static/active-registry.test.ts b/src/extension-host/static/active-registry.test.ts new file mode 100644 index 00000000000..679d5b3d7da --- /dev/null +++ b/src/extension-host/static/active-registry.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { createEmptyPluginRegistry } from "../../plugins/registry.js"; +import { + createEmptyExtensionHostRegistry, + getActiveExtensionHostRegistry, + getActiveExtensionHostRegistryKey, + getActiveExtensionHostRegistryVersion, + requireActiveExtensionHostRegistry, + setActiveExtensionHostRegistry, +} from "./active-registry.js"; + +describe("extension host active registry", () => { + it("initializes with an empty registry", () => { + const emptyRegistry = createEmptyExtensionHostRegistry(); + setActiveExtensionHostRegistry(emptyRegistry, "empty"); + const registry = requireActiveExtensionHostRegistry(); + expect(registry).toBeDefined(); + expect(registry).toBe(emptyRegistry); + expect(registry.channels).toEqual([]); + expect(registry.plugins).toEqual([]); + }); + + it("tracks registry replacement and cache keys", () => { + const baseVersion = getActiveExtensionHostRegistryVersion(); + const registry = createEmptyPluginRegistry(); + registry.plugins.push({ + id: "host-test", + name: "host-test", + source: "test", + origin: "workspace", + enabled: true, + status: "loaded", + toolNames: [], + hookNames: [], + channelIds: [], + providerIds: [], + gatewayMethods: [], + cliCommands: [], + services: [], + commands: [], + httpRoutes: 0, + hookCount: 0, + configSchema: false, + }); + + setActiveExtensionHostRegistry(registry, "host-registry"); + + expect(getActiveExtensionHostRegistry()).toBe(registry); + expect(getActiveExtensionHostRegistryKey()).toBe("host-registry"); + expect(getActiveExtensionHostRegistryVersion()).toBe(baseVersion + 1); + }); + + it("can create a fresh empty registry", () => { + const registry = createEmptyExtensionHostRegistry(); + expect(registry).not.toBe(getActiveExtensionHostRegistry()); + expect(registry).toEqual(createEmptyPluginRegistry()); + }); +}); diff --git a/src/extension-host/static/active-registry.ts b/src/extension-host/static/active-registry.ts new file mode 100644 index 00000000000..777ae393a78 --- /dev/null +++ b/src/extension-host/static/active-registry.ts @@ -0,0 +1,58 @@ +import { createEmptyPluginRegistry, type PluginRegistry } from "../../plugins/registry.js"; + +const EXTENSION_HOST_REGISTRY_STATE = Symbol.for("openclaw.extensionHostRegistryState"); + +export type ExtensionHostRegistry = PluginRegistry; + +type ExtensionHostRegistryState = { + registry: ExtensionHostRegistry | null; + key: string | null; + version: number; +}; + +const state: ExtensionHostRegistryState = (() => { + const globalState = globalThis as typeof globalThis & { + [EXTENSION_HOST_REGISTRY_STATE]?: ExtensionHostRegistryState; + }; + if (!globalState[EXTENSION_HOST_REGISTRY_STATE]) { + globalState[EXTENSION_HOST_REGISTRY_STATE] = { + registry: createEmptyExtensionHostRegistry(), + key: null, + version: 0, + }; + } + return globalState[EXTENSION_HOST_REGISTRY_STATE]; +})(); + +export function createEmptyExtensionHostRegistry(): ExtensionHostRegistry { + return createEmptyPluginRegistry(); +} + +export function setActiveExtensionHostRegistry( + registry: ExtensionHostRegistry, + cacheKey?: string, +): void { + state.registry = registry; + state.key = cacheKey ?? null; + state.version += 1; +} + +export function getActiveExtensionHostRegistry(): ExtensionHostRegistry | null { + return state.registry; +} + +export function requireActiveExtensionHostRegistry(): ExtensionHostRegistry { + if (!state.registry) { + state.registry = createEmptyExtensionHostRegistry(); + state.version += 1; + } + return state.registry; +} + +export function getActiveExtensionHostRegistryKey(): string | null { + return state.key; +} + +export function getActiveExtensionHostRegistryVersion(): number { + return state.version; +} diff --git a/src/gateway/server-plugins.ts b/src/gateway/server-plugins.ts index 7d8b2a8a051..c19ec089a59 100644 --- a/src/gateway/server-plugins.ts +++ b/src/gateway/server-plugins.ts @@ -1,5 +1,9 @@ import { randomUUID } from "node:crypto"; import type { loadConfig } from "../config/config.js"; +import { + logExtensionHostPluginDiagnostics, + resolveExtensionHostGatewayMethods, +} from "../extension-host/contributions/gateway-methods.js"; import { loadOpenClawPlugins } from "../plugins/loader.js"; import { getPluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.js"; import type { PluginRuntime } from "../plugins/runtime/types.js"; @@ -187,25 +191,13 @@ export function loadGatewayPlugins(params: { subagent: createGatewaySubagentRuntime(), }, }); - const pluginMethods = Object.keys(pluginRegistry.gatewayHandlers); - const gatewayMethods = Array.from(new Set([...params.baseMethods, ...pluginMethods])); - if (pluginRegistry.diagnostics.length > 0) { - for (const diag of pluginRegistry.diagnostics) { - const details = [ - diag.pluginId ? `plugin=${diag.pluginId}` : null, - diag.source ? `source=${diag.source}` : null, - ] - .filter((entry): entry is string => Boolean(entry)) - .join(", "); - const message = details - ? `[plugins] ${diag.message} (${details})` - : `[plugins] ${diag.message}`; - if (diag.level === "error") { - params.log.error(message); - } else { - params.log.info(message); - } - } - } + const gatewayMethods = resolveExtensionHostGatewayMethods({ + registry: pluginRegistry, + baseMethods: params.baseMethods, + }); + logExtensionHostPluginDiagnostics({ + diagnostics: pluginRegistry.diagnostics, + log: params.log, + }); return { pluginRegistry, gatewayMethods }; } diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 5453ff8fcee..ccb9f227509 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -21,6 +21,7 @@ import { import { formatConfigIssueLines } from "../config/issue-format.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import { resolveMainSessionKey } from "../config/sessions.js"; +import { createExtensionHostGatewayExtraHandlers } from "../extension-host/contributions/gateway-methods.js"; import { clearAgentRunContext, onAgentEvent } from "../infra/agent-events.js"; import { ensureControlUiAssetsBuilt, @@ -892,11 +893,13 @@ export async function startGatewayServer( logGateway: log, logHealth, logWsControl, - extraHandlers: { - ...pluginRegistry.gatewayHandlers, - ...execApprovalHandlers, - ...secretsHandlers, - }, + extraHandlers: createExtensionHostGatewayExtraHandlers({ + registry: pluginRegistry, + extraHandlers: { + ...execApprovalHandlers, + ...secretsHandlers, + }, + }), broadcast, context: gatewayRequestContext, }); diff --git a/src/gateway/server/plugins-http.ts b/src/gateway/server/plugins-http.ts index 6147e1bee99..8e2ad7a355b 100644 --- a/src/gateway/server/plugins-http.ts +++ b/src/gateway/server/plugins-http.ts @@ -1,4 +1,5 @@ import type { IncomingMessage, ServerResponse } from "node:http"; +import { listExtensionHostHttpRoutes } from "../../extension-host/contributions/runtime-registry.js"; import type { createSubsystemLogger } from "../../logging/subsystem.js"; import type { PluginRegistry } from "../../plugins/registry.js"; import { withPluginRuntimeGatewayRequestScope } from "../../plugins/runtime/gateway-request-scope.js"; @@ -65,7 +66,7 @@ export function createGatewayPluginRequestHandler(params: { }): PluginHttpRequestHandler { const { registry, log } = params; return async (req, res, providedPathContext, dispatchContext) => { - const routes = registry.httpRoutes ?? []; + const routes = listExtensionHostHttpRoutes(registry); if (routes.length === 0) { return false; } diff --git a/src/gateway/server/plugins-http/route-match.ts b/src/gateway/server/plugins-http/route-match.ts index bab082c813e..4c84f9b12cc 100644 --- a/src/gateway/server/plugins-http/route-match.ts +++ b/src/gateway/server/plugins-http/route-match.ts @@ -1,3 +1,4 @@ +import { listExtensionHostHttpRoutes } from "../../../extension-host/contributions/runtime-registry.js"; import type { PluginRegistry } from "../../../plugins/registry.js"; import { canonicalizePathVariant } from "../../security-path.js"; import { @@ -23,7 +24,7 @@ export function findMatchingPluginHttpRoutes( registry: PluginRegistry, context: PluginRoutePathContext, ): PluginHttpRouteEntry[] { - const routes = registry.httpRoutes ?? []; + const routes = listExtensionHostHttpRoutes(registry); if (routes.length === 0) { return []; } diff --git a/src/plugins/cli.ts b/src/plugins/cli.ts index 4d8af51e3db..c105319999b 100644 --- a/src/plugins/cli.ts +++ b/src/plugins/cli.ts @@ -2,6 +2,7 @@ import type { Command } from "commander"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import type { OpenClawConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js"; +import { registerExtensionHostCliCommands } from "../extension-host/contributions/cli-lifecycle.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { loadOpenClawPlugins } from "./loader.js"; import type { PluginLogger } from "./types.js"; @@ -27,38 +28,11 @@ export function registerPluginCliCommands( env, logger, }); - - const existingCommands = new Set(program.commands.map((cmd) => cmd.name())); - - for (const entry of registry.cliRegistrars) { - if (entry.commands.length > 0) { - const overlaps = entry.commands.filter((command) => existingCommands.has(command)); - if (overlaps.length > 0) { - log.debug( - `plugin CLI register skipped (${entry.pluginId}): command already registered (${overlaps.join( - ", ", - )})`, - ); - continue; - } - } - try { - const result = entry.register({ - program, - config, - workspaceDir, - logger, - }); - if (result && typeof result.then === "function") { - void result.catch((err) => { - log.warn(`plugin CLI register failed (${entry.pluginId}): ${String(err)}`); - }); - } - for (const command of entry.commands) { - existingCommands.add(command); - } - } catch (err) { - log.warn(`plugin CLI register failed (${entry.pluginId}): ${String(err)}`); - } - } + registerExtensionHostCliCommands({ + program, + registry, + config, + workspaceDir, + logger, + }); } diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index c102ffc80c7..6ea66a8b76f 100644 --- a/src/plugins/discovery.ts +++ b/src/plugins/discovery.ts @@ -1,15 +1,15 @@ import fs from "node:fs"; import path from "node:path"; +import { + getExtensionPackageMetadata, + resolveExtensionEntryCandidates, + type PackageManifest, + type OpenClawPackageManifest, +} from "../extension-host/manifests/schema.js"; import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; import { resolveUserPath } from "../utils.js"; import { detectBundleManifestFormat, loadBundleManifest } from "./bundle-manifest.js"; -import { - DEFAULT_PLUGIN_ENTRY_CANDIDATES, - getPackageManifestMetadata, - resolvePackageExtensionEntries, - type OpenClawPackageManifest, - type PackageManifest, -} from "./manifest.js"; +import { DEFAULT_PLUGIN_ENTRY_CANDIDATES, loadPackageManifest } from "./manifest.js"; import { formatPosixMode, isPathInside, safeRealpathSync, safeStatSync } from "./path-safety.js"; import { resolvePluginCacheInputs, resolvePluginSourceRoots } from "./roots.js"; import type { PluginBundleFormat, PluginDiagnostic, PluginFormat, PluginOrigin } from "./types.js"; @@ -299,27 +299,6 @@ function shouldIgnoreScannedDirectory(dirName: string): boolean { return false; } -function readPackageManifest(dir: string, rejectHardlinks = true): PackageManifest | null { - const manifestPath = path.join(dir, "package.json"); - const opened = openBoundaryFileSync({ - absolutePath: manifestPath, - rootPath: dir, - boundaryLabel: "plugin package directory", - rejectHardlinks, - }); - if (!opened.ok) { - return null; - } - try { - const raw = fs.readFileSync(opened.fd, "utf-8"); - return JSON.parse(raw) as PackageManifest; - } catch { - return null; - } finally { - fs.closeSync(opened.fd); - } -} - function deriveIdHint(params: { filePath: string; packageName?: string; @@ -394,7 +373,7 @@ function addCandidate(params: { packageVersion: manifest?.version?.trim() || undefined, packageDescription: manifest?.description?.trim() || undefined, packageDir: params.packageDir, - packageManifest: getPackageManifestMetadata(manifest ?? undefined), + packageManifest: getExtensionPackageMetadata(manifest ?? undefined), }); } @@ -517,8 +496,8 @@ function discoverInDirectory(params: { } const rejectHardlinks = params.origin !== "bundled"; - const manifest = readPackageManifest(fullPath, rejectHardlinks); - const extensionResolution = resolvePackageExtensionEntries(manifest ?? undefined); + const manifest = loadPackageManifest(fullPath, rejectHardlinks); + const extensionResolution = resolveExtensionEntryCandidates(manifest ?? undefined); const extensions = extensionResolution.status === "ok" ? extensionResolution.entries : []; if (extensions.length > 0) { @@ -634,8 +613,8 @@ function discoverFromPath(params: { if (stat.isDirectory()) { const rejectHardlinks = params.origin !== "bundled"; - const manifest = readPackageManifest(resolved, rejectHardlinks); - const extensionResolution = resolvePackageExtensionEntries(manifest ?? undefined); + const manifest = loadPackageManifest(resolved, rejectHardlinks); + const extensionResolution = resolveExtensionEntryCandidates(manifest ?? undefined); const extensions = extensionResolution.status === "ok" ? extensionResolution.entries : []; if (extensions.length > 0) { diff --git a/src/plugins/http-registry.ts b/src/plugins/http-registry.ts index bf45f1b076a..2d70ef9f8f7 100644 --- a/src/plugins/http-registry.ts +++ b/src/plugins/http-registry.ts @@ -1,8 +1,14 @@ import type { IncomingMessage, ServerResponse } from "node:http"; +import { + addExtensionHostHttpRoute, + listExtensionHostHttpRoutes, + removeExtensionHostHttpRoute, + replaceExtensionHostHttpRoute, +} from "../extension-host/contributions/runtime-registry.js"; +import { requireActiveExtensionHostRegistry } from "../extension-host/static/active-registry.js"; import { normalizePluginHttpPath } from "./http-path.js"; import { findOverlappingPluginHttpRoute } from "./http-route-overlap.js"; import type { PluginHttpRouteRegistration, PluginRegistry } from "./registry.js"; -import { requireActivePluginRegistry } from "./runtime.js"; export type PluginHttpRouteHandler = ( req: IncomingMessage, @@ -22,9 +28,8 @@ export function registerPluginHttpRoute(params: { log?: (message: string) => void; registry?: PluginRegistry; }): () => void { - const registry = params.registry ?? requireActivePluginRegistry(); - const routes = registry.httpRoutes ?? []; - registry.httpRoutes = routes; + const registry = params.registry ?? requireActiveExtensionHostRegistry(); + const routes = listExtensionHostHttpRoutes(registry); const normalizedPath = normalizePluginHttpPath(params.path, params.fallbackPath); const suffix = params.accountId ? ` for account "${params.accountId}"` : ""; @@ -70,7 +75,6 @@ export function registerPluginHttpRoute(params: { params.log?.( `plugin: replacing stale webhook path ${normalizedPath} (${routeMatch})${suffix}${pluginHint}`, ); - routes.splice(existingIndex, 1); } const entry: PluginHttpRouteRegistration = { @@ -81,12 +85,17 @@ export function registerPluginHttpRoute(params: { pluginId: params.pluginId, source: params.source, }; - routes.push(entry); + if (existingIndex >= 0) { + replaceExtensionHostHttpRoute({ + registry, + index: existingIndex, + entry, + }); + } else { + addExtensionHostHttpRoute(registry, entry); + } return () => { - const index = routes.indexOf(entry); - if (index >= 0) { - routes.splice(index, 1); - } + removeExtensionHostHttpRoute(registry, entry); }; } diff --git a/src/plugins/install.ts b/src/plugins/install.ts index e6b66381970..9c8ab9ab733 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -1,5 +1,9 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { + resolveExtensionEntryCandidates, + type PackageManifest as PluginPackageManifest, +} from "../extension-host/manifests/schema.js"; import { fileExists, readJsonFile, resolveArchiveKind } from "../infra/archive.js"; import { writeFileFromPathWithinRoot } from "../infra/fs-safe.js"; import { resolveExistingInstallPath, withExtractedArchiveRoot } from "../infra/install-flow.js"; @@ -32,11 +36,7 @@ import { extensionUsesSkippedScannerPath, isPathInside } from "../security/scan- import * as skillScanner from "../security/skill-scanner.js"; import { CONFIG_DIR, resolveUserPath } from "../utils.js"; import { detectBundleManifestFormat, loadBundleManifest } from "./bundle-manifest.js"; -import { - loadPluginManifest, - resolvePackageExtensionEntries, - type PackageManifest as PluginPackageManifest, -} from "./manifest.js"; +import { loadPluginManifest } from "./manifest.js"; type PluginInstallLogger = { info?: (message: string) => void; @@ -158,7 +158,7 @@ function ensureOpenClawExtensions(params: { manifest: PackageManifest }): error: string; code: PluginInstallErrorCode; } { - const resolved = resolvePackageExtensionEntries(params.manifest); + const resolved = resolveExtensionEntryCandidates(params.manifest); if (resolved.status === "missing") { return { ok: false, diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 319b0ae90d7..2d9040740e0 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -1,133 +1,34 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { createJiti } from "jiti"; -import type { OpenClawConfig } from "../config/config.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 { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; -import { createSubsystemLogger } from "../logging/subsystem.js"; -import { resolveUserPath } from "../utils.js"; -import { clearPluginCommands } from "./commands.js"; +import { MAX_EXTENSION_HOST_REGISTRY_CACHE_ENTRIES } from "../extension-host/activation/loader-cache.js"; import { - applyTestPluginDefaults, - normalizePluginsConfig, - resolveEffectiveEnableState, - resolveMemorySlotDecision, - type NormalizedPluginsConfig, -} from "./config-state.js"; -import { discoverOpenClawPlugins } from "./discovery.js"; -import { initializeGlobalHookRunner } from "./hook-runner-global.js"; -import { clearPluginInteractiveHandlers } from "./interactive.js"; -import { loadPluginManifestRegistry } from "./manifest-registry.js"; -import { isPathInside, safeStatSync } from "./path-safety.js"; -import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js"; -import { resolvePluginCacheInputs } from "./roots.js"; -import { setActivePluginRegistry } from "./runtime.js"; -import { createPluginRuntime, type CreatePluginRuntimeOptions } from "./runtime/index.js"; -import type { PluginRuntime } from "./runtime/types.js"; -import { validateJsonSchemaValue } from "./schema-validator.js"; -import type { - OpenClawPluginDefinition, - OpenClawPluginModule, - PluginDiagnostic, - PluginBundleFormat, - PluginFormat, - PluginLogger, -} from "./types.js"; + clearExtensionHostLoaderState, + loadExtensionHostPluginRegistry, +} from "../extension-host/activation/loader-orchestrator.js"; +import { + listPluginSdkAliasCandidates, + listPluginSdkExportedSubpaths, + resolvePluginSdkAliasCandidateOrder, + resolvePluginSdkAliasFile, +} from "../extension-host/compat/loader-compat.js"; +import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; +import type { PluginRegistry } from "./registry.js"; export type PluginLoadResult = PluginRegistry; -export type PluginLoadOptions = { - config?: OpenClawConfig; - workspaceDir?: string; - // Allows callers to resolve plugin roots and load paths against an explicit env - // instead of the process-global environment. - env?: NodeJS.ProcessEnv; - logger?: PluginLogger; - coreGatewayHandlers?: Record; - runtimeOptions?: CreatePluginRuntimeOptions; - cache?: boolean; - mode?: "full" | "validate"; -}; - -const MAX_PLUGIN_REGISTRY_CACHE_ENTRIES = 32; -const registryCache = new Map(); -const openAllowlistWarningCache = new Set(); +export type PluginLoadOptions = + import("../extension-host/activation/loader-orchestrator.js").ExtensionHostPluginLoadOptions; export function clearPluginLoaderCache(): void { - registryCache.clear(); - openAllowlistWarningCache.clear(); + clearExtensionHostLoaderState(); } -const defaultLogger = () => createSubsystemLogger("plugins"); - -type PluginSdkAliasCandidateKind = "dist" | "src"; - -function resolvePluginSdkAliasCandidateOrder(params: { - modulePath: string; - isProduction: boolean; -}): PluginSdkAliasCandidateKind[] { - const normalizedModulePath = params.modulePath.replace(/\\/g, "/"); - const isDistRuntime = normalizedModulePath.includes("/dist/"); - return isDistRuntime || params.isProduction ? ["dist", "src"] : ["src", "dist"]; +export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegistry { + return loadExtensionHostPluginRegistry(options); } -function listPluginSdkAliasCandidates(params: { - srcFile: string; - distFile: string; - modulePath: string; -}) { - const orderedKinds = resolvePluginSdkAliasCandidateOrder({ - modulePath: params.modulePath, - isProduction: process.env.NODE_ENV === "production", - }); - let cursor = path.dirname(params.modulePath); - const candidates: string[] = []; - for (let i = 0; i < 6; i += 1) { - const candidateMap = { - src: path.join(cursor, "src", "plugin-sdk", params.srcFile), - dist: path.join(cursor, "dist", "plugin-sdk", params.distFile), - } as const; - for (const kind of orderedKinds) { - candidates.push(candidateMap[kind]); - } - const parent = path.dirname(cursor); - if (parent === cursor) { - break; - } - cursor = parent; - } - return candidates; -} - -const resolvePluginSdkAliasFile = (params: { - srcFile: string; - distFile: string; - modulePath?: string; -}): string | null => { - try { - const modulePath = params.modulePath ?? fileURLToPath(import.meta.url); - for (const candidate of listPluginSdkAliasCandidates({ - srcFile: params.srcFile, - distFile: params.distFile, - modulePath, - })) { - if (fs.existsSync(candidate)) { - return candidate; - } - } - } catch { - // ignore - } - return null; -}; - -const resolvePluginSdkAlias = (): string | null => - resolvePluginSdkAliasFile({ srcFile: "root-alias.cjs", distFile: "root-alias.cjs" }); - -const resolveExtensionApiAlias = (params: { modulePath?: string } = {}): string | null => { +function resolveExtensionApiAlias(params: { modulePath?: string } = {}): string | null { try { const modulePath = params.modulePath ?? fileURLToPath(import.meta.url); const packageRoot = resolveOpenClawPackageRootSync({ @@ -155,919 +56,13 @@ const resolveExtensionApiAlias = (params: { modulePath?: string } = {}): string // ignore } return null; -}; - -const cachedPluginSdkExportedSubpaths = new Map(); - -function listPluginSdkExportedSubpaths(params: { modulePath?: string } = {}): string[] { - const modulePath = params.modulePath ?? fileURLToPath(import.meta.url); - const packageRoot = resolveOpenClawPackageRootSync({ - cwd: path.dirname(modulePath), - }); - if (!packageRoot) { - return []; - } - const cached = cachedPluginSdkExportedSubpaths.get(packageRoot); - if (cached) { - return cached; - } - try { - const pkgRaw = fs.readFileSync(path.join(packageRoot, "package.json"), "utf-8"); - const pkg = JSON.parse(pkgRaw) as { - exports?: Record; - }; - const subpaths = Object.keys(pkg.exports ?? {}) - .filter((key) => key.startsWith("./plugin-sdk/")) - .map((key) => key.slice("./plugin-sdk/".length)) - .filter((subpath) => Boolean(subpath) && !subpath.includes("/")) - .toSorted(); - cachedPluginSdkExportedSubpaths.set(packageRoot, subpaths); - return subpaths; - } catch { - return []; - } } -const resolvePluginSdkScopedAliasMap = (): Record => { - const aliasMap: Record = {}; - for (const subpath of listPluginSdkExportedSubpaths()) { - const resolved = resolvePluginSdkAliasFile({ - srcFile: `${subpath}.ts`, - distFile: `${subpath}.js`, - }); - if (resolved) { - aliasMap[`openclaw/plugin-sdk/${subpath}`] = resolved; - } - } - return aliasMap; -}; - export const __testing = { listPluginSdkAliasCandidates, listPluginSdkExportedSubpaths, resolveExtensionApiAlias, resolvePluginSdkAliasCandidateOrder, resolvePluginSdkAliasFile, - maxPluginRegistryCacheEntries: MAX_PLUGIN_REGISTRY_CACHE_ENTRIES, + maxPluginRegistryCacheEntries: MAX_EXTENSION_HOST_REGISTRY_CACHE_ENTRIES, }; - -function getCachedPluginRegistry(cacheKey: string): PluginRegistry | undefined { - const cached = registryCache.get(cacheKey); - if (!cached) { - return undefined; - } - // Refresh insertion order so frequently reused registries survive eviction. - registryCache.delete(cacheKey); - registryCache.set(cacheKey, cached); - return cached; -} - -function setCachedPluginRegistry(cacheKey: string, registry: PluginRegistry): void { - if (registryCache.has(cacheKey)) { - registryCache.delete(cacheKey); - } - registryCache.set(cacheKey, registry); - while (registryCache.size > MAX_PLUGIN_REGISTRY_CACHE_ENTRIES) { - const oldestKey = registryCache.keys().next().value; - if (!oldestKey) { - break; - } - registryCache.delete(oldestKey); - } -} - -function buildCacheKey(params: { - workspaceDir?: string; - plugins: NormalizedPluginsConfig; - installs?: Record; - env: NodeJS.ProcessEnv; -}): string { - const { roots, loadPaths } = resolvePluginCacheInputs({ - workspaceDir: params.workspaceDir, - loadPaths: params.plugins.loadPaths, - env: params.env, - }); - const installs = Object.fromEntries( - Object.entries(params.installs ?? {}).map(([pluginId, install]) => [ - pluginId, - { - ...install, - installPath: - typeof install.installPath === "string" - ? resolveUserPath(install.installPath, params.env) - : install.installPath, - sourcePath: - typeof install.sourcePath === "string" - ? resolveUserPath(install.sourcePath, params.env) - : install.sourcePath, - }, - ]), - ); - return `${roots.workspace ?? ""}::${roots.global ?? ""}::${roots.stock ?? ""}::${JSON.stringify({ - ...params.plugins, - installs, - loadPaths, - })}`; -} - -function validatePluginConfig(params: { - schema?: Record; - cacheKey?: string; - value?: unknown; -}): { ok: boolean; value?: Record; errors?: string[] } { - const schema = params.schema; - if (!schema) { - return { ok: true, value: params.value as Record | undefined }; - } - const cacheKey = params.cacheKey ?? JSON.stringify(schema); - const result = validateJsonSchemaValue({ - schema, - cacheKey, - value: params.value ?? {}, - }); - if (result.ok) { - return { ok: true, value: params.value as Record | undefined }; - } - return { ok: false, errors: result.errors.map((error) => error.text) }; -} - -function resolvePluginModuleExport(moduleExport: unknown): { - definition?: OpenClawPluginDefinition; - register?: OpenClawPluginDefinition["register"]; -} { - const resolved = - moduleExport && - typeof moduleExport === "object" && - "default" in (moduleExport as Record) - ? (moduleExport as { default: unknown }).default - : moduleExport; - if (typeof resolved === "function") { - return { - register: resolved as OpenClawPluginDefinition["register"], - }; - } - if (resolved && typeof resolved === "object") { - const def = resolved as OpenClawPluginDefinition; - const register = def.register ?? def.activate; - return { definition: def, register }; - } - return {}; -} - -function createPluginRecord(params: { - id: string; - name?: string; - description?: string; - version?: string; - format?: PluginFormat; - bundleFormat?: PluginBundleFormat; - bundleCapabilities?: string[]; - source: string; - rootDir?: string; - origin: PluginRecord["origin"]; - workspaceDir?: string; - enabled: boolean; - configSchema: boolean; -}): PluginRecord { - return { - id: params.id, - name: params.name ?? params.id, - description: params.description, - version: params.version, - format: params.format ?? "openclaw", - bundleFormat: params.bundleFormat, - bundleCapabilities: params.bundleCapabilities, - source: params.source, - rootDir: params.rootDir, - origin: params.origin, - workspaceDir: params.workspaceDir, - enabled: params.enabled, - status: params.enabled ? "loaded" : "disabled", - toolNames: [], - hookNames: [], - channelIds: [], - providerIds: [], - gatewayMethods: [], - cliCommands: [], - services: [], - commands: [], - httpRoutes: 0, - hookCount: 0, - configSchema: params.configSchema, - configUiHints: undefined, - configJsonSchema: undefined, - }; -} - -function recordPluginError(params: { - logger: PluginLogger; - registry: PluginRegistry; - record: PluginRecord; - seenIds: Map; - pluginId: string; - origin: PluginRecord["origin"]; - error: unknown; - logPrefix: string; - diagnosticMessagePrefix: string; -}) { - const errorText = String(params.error); - const deprecatedApiHint = - errorText.includes("api.registerHttpHandler") && errorText.includes("is not a function") - ? "deprecated api.registerHttpHandler(...) was removed; use api.registerHttpRoute(...) for plugin-owned routes or registerPluginHttpRoute(...) for dynamic lifecycle routes" - : null; - const displayError = deprecatedApiHint ? `${deprecatedApiHint} (${errorText})` : errorText; - params.logger.error(`${params.logPrefix}${displayError}`); - params.record.status = "error"; - params.record.error = displayError; - params.registry.plugins.push(params.record); - params.seenIds.set(params.pluginId, params.origin); - params.registry.diagnostics.push({ - level: "error", - pluginId: params.record.id, - source: params.record.source, - message: `${params.diagnosticMessagePrefix}${displayError}`, - }); -} - -function pushDiagnostics(diagnostics: PluginDiagnostic[], append: PluginDiagnostic[]) { - diagnostics.push(...append); -} - -type PathMatcher = { - exact: Set; - dirs: string[]; -}; - -type InstallTrackingRule = { - trackedWithoutPaths: boolean; - matcher: PathMatcher; -}; - -type PluginProvenanceIndex = { - loadPathMatcher: PathMatcher; - installRules: Map; -}; - -function createPathMatcher(): PathMatcher { - return { exact: new Set(), dirs: [] }; -} - -function addPathToMatcher( - matcher: PathMatcher, - rawPath: string, - env: NodeJS.ProcessEnv = process.env, -): void { - const trimmed = rawPath.trim(); - if (!trimmed) { - return; - } - const resolved = resolveUserPath(trimmed, env); - if (!resolved) { - return; - } - if (matcher.exact.has(resolved) || matcher.dirs.includes(resolved)) { - return; - } - const stat = safeStatSync(resolved); - if (stat?.isDirectory()) { - matcher.dirs.push(resolved); - return; - } - matcher.exact.add(resolved); -} - -function matchesPathMatcher(matcher: PathMatcher, sourcePath: string): boolean { - if (matcher.exact.has(sourcePath)) { - return true; - } - return matcher.dirs.some((dirPath) => isPathInside(dirPath, sourcePath)); -} - -function buildProvenanceIndex(params: { - config: OpenClawConfig; - normalizedLoadPaths: string[]; - env: NodeJS.ProcessEnv; -}): PluginProvenanceIndex { - const loadPathMatcher = createPathMatcher(); - for (const loadPath of params.normalizedLoadPaths) { - addPathToMatcher(loadPathMatcher, loadPath, params.env); - } - - const installRules = new Map(); - const installs = params.config.plugins?.installs ?? {}; - for (const [pluginId, install] of Object.entries(installs)) { - const rule: InstallTrackingRule = { - trackedWithoutPaths: false, - matcher: createPathMatcher(), - }; - const trackedPaths = [install.installPath, install.sourcePath] - .map((entry) => (typeof entry === "string" ? entry.trim() : "")) - .filter(Boolean); - if (trackedPaths.length === 0) { - rule.trackedWithoutPaths = true; - } else { - for (const trackedPath of trackedPaths) { - addPathToMatcher(rule.matcher, trackedPath, params.env); - } - } - installRules.set(pluginId, rule); - } - - return { loadPathMatcher, installRules }; -} - -function isTrackedByProvenance(params: { - pluginId: string; - source: string; - index: PluginProvenanceIndex; - env: NodeJS.ProcessEnv; -}): boolean { - const sourcePath = resolveUserPath(params.source, params.env); - const installRule = params.index.installRules.get(params.pluginId); - if (installRule) { - if (installRule.trackedWithoutPaths) { - return true; - } - if (matchesPathMatcher(installRule.matcher, sourcePath)) { - return true; - } - } - return matchesPathMatcher(params.index.loadPathMatcher, sourcePath); -} - -function matchesExplicitInstallRule(params: { - pluginId: string; - source: string; - index: PluginProvenanceIndex; - env: NodeJS.ProcessEnv; -}): boolean { - const sourcePath = resolveUserPath(params.source, params.env); - const installRule = params.index.installRules.get(params.pluginId); - if (!installRule || installRule.trackedWithoutPaths) { - return false; - } - return matchesPathMatcher(installRule.matcher, sourcePath); -} - -function resolveCandidateDuplicateRank(params: { - candidate: ReturnType["candidates"][number]; - manifestByRoot: Map["plugins"][number]>; - provenance: PluginProvenanceIndex; - env: NodeJS.ProcessEnv; -}): number { - const manifestRecord = params.manifestByRoot.get(params.candidate.rootDir); - const pluginId = manifestRecord?.id; - const isExplicitInstall = - params.candidate.origin === "global" && - pluginId !== undefined && - matchesExplicitInstallRule({ - pluginId, - source: params.candidate.source, - index: params.provenance, - env: params.env, - }); - - if (params.candidate.origin === "config") { - return 0; - } - if (params.candidate.origin === "global" && isExplicitInstall) { - return 1; - } - if (params.candidate.origin === "bundled") { - // Bundled plugin ids stay reserved unless the operator configured an override. - return 2; - } - if (params.candidate.origin === "workspace") { - return 3; - } - return 4; -} - -function compareDuplicateCandidateOrder(params: { - left: ReturnType["candidates"][number]; - right: ReturnType["candidates"][number]; - manifestByRoot: Map["plugins"][number]>; - provenance: PluginProvenanceIndex; - env: NodeJS.ProcessEnv; -}): number { - const leftPluginId = params.manifestByRoot.get(params.left.rootDir)?.id; - const rightPluginId = params.manifestByRoot.get(params.right.rootDir)?.id; - if (!leftPluginId || leftPluginId !== rightPluginId) { - return 0; - } - return ( - resolveCandidateDuplicateRank({ - candidate: params.left, - manifestByRoot: params.manifestByRoot, - provenance: params.provenance, - env: params.env, - }) - - resolveCandidateDuplicateRank({ - candidate: params.right, - manifestByRoot: params.manifestByRoot, - provenance: params.provenance, - env: params.env, - }) - ); -} - -function warnWhenAllowlistIsOpen(params: { - logger: PluginLogger; - pluginsEnabled: boolean; - allow: string[]; - warningCacheKey: string; - discoverablePlugins: Array<{ id: string; source: string; origin: PluginRecord["origin"] }>; -}) { - if (!params.pluginsEnabled) { - return; - } - if (params.allow.length > 0) { - return; - } - const nonBundled = params.discoverablePlugins.filter((entry) => entry.origin !== "bundled"); - if (nonBundled.length === 0) { - return; - } - if (openAllowlistWarningCache.has(params.warningCacheKey)) { - return; - } - const preview = nonBundled - .slice(0, 6) - .map((entry) => `${entry.id} (${entry.source})`) - .join(", "); - const extra = nonBundled.length > 6 ? ` (+${nonBundled.length - 6} more)` : ""; - openAllowlistWarningCache.add(params.warningCacheKey); - params.logger.warn( - `[plugins] plugins.allow is empty; discovered non-bundled plugins may auto-load: ${preview}${extra}. Set plugins.allow to explicit trusted ids.`, - ); -} - -function warnAboutUntrackedLoadedPlugins(params: { - registry: PluginRegistry; - provenance: PluginProvenanceIndex; - logger: PluginLogger; - env: NodeJS.ProcessEnv; -}) { - for (const plugin of params.registry.plugins) { - if (plugin.status !== "loaded" || plugin.origin === "bundled") { - continue; - } - if ( - isTrackedByProvenance({ - pluginId: plugin.id, - source: plugin.source, - index: params.provenance, - env: params.env, - }) - ) { - continue; - } - const message = - "loaded without install/load-path provenance; treat as untracked local code and pin trust via plugins.allow or install records"; - params.registry.diagnostics.push({ - level: "warn", - pluginId: plugin.id, - source: plugin.source, - message, - }); - params.logger.warn(`[plugins] ${plugin.id}: ${message} (${plugin.source})`); - } -} - -function activatePluginRegistry(registry: PluginRegistry, cacheKey: string): void { - setActivePluginRegistry(registry, cacheKey); - initializeGlobalHookRunner(registry); -} - -export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegistry { - const env = options.env ?? process.env; - // Test env: default-disable plugins unless explicitly configured. - // This keeps unit/gateway suites fast and avoids loading heavyweight plugin deps by accident. - const cfg = applyTestPluginDefaults(options.config ?? {}, env); - const logger = options.logger ?? defaultLogger(); - const validateOnly = options.mode === "validate"; - const normalized = normalizePluginsConfig(cfg.plugins); - const cacheKey = buildCacheKey({ - workspaceDir: options.workspaceDir, - plugins: normalized, - installs: cfg.plugins?.installs, - env, - }); - const cacheEnabled = options.cache !== false; - if (cacheEnabled) { - const cached = getCachedPluginRegistry(cacheKey); - if (cached) { - activatePluginRegistry(cached, cacheKey); - return cached; - } - } - - // Clear previously registered plugin commands before reloading - clearPluginCommands(); - clearPluginInteractiveHandlers(); - - // Lazily initialize the runtime so startup paths that discover/skip plugins do - // not eagerly load every channel runtime dependency. - let resolvedRuntime: PluginRuntime | null = null; - const resolveRuntime = (): PluginRuntime => { - resolvedRuntime ??= createPluginRuntime(options.runtimeOptions); - return resolvedRuntime; - }; - const runtime = new Proxy({} as PluginRuntime, { - get(_target, prop, receiver) { - return Reflect.get(resolveRuntime(), prop, receiver); - }, - set(_target, prop, value, receiver) { - return Reflect.set(resolveRuntime(), prop, value, receiver); - }, - has(_target, prop) { - return Reflect.has(resolveRuntime(), prop); - }, - ownKeys() { - return Reflect.ownKeys(resolveRuntime() as object); - }, - getOwnPropertyDescriptor(_target, prop) { - return Reflect.getOwnPropertyDescriptor(resolveRuntime() as object, prop); - }, - defineProperty(_target, prop, attributes) { - return Reflect.defineProperty(resolveRuntime() as object, prop, attributes); - }, - deleteProperty(_target, prop) { - return Reflect.deleteProperty(resolveRuntime() as object, prop); - }, - getPrototypeOf() { - return Reflect.getPrototypeOf(resolveRuntime() as object); - }, - }); - const { registry, createApi } = createPluginRegistry({ - logger, - runtime, - coreGatewayHandlers: options.coreGatewayHandlers as Record, - }); - - const discovery = discoverOpenClawPlugins({ - workspaceDir: options.workspaceDir, - extraPaths: normalized.loadPaths, - cache: options.cache, - env, - }); - const manifestRegistry = loadPluginManifestRegistry({ - config: cfg, - workspaceDir: options.workspaceDir, - cache: options.cache, - env, - candidates: discovery.candidates, - diagnostics: discovery.diagnostics, - }); - pushDiagnostics(registry.diagnostics, manifestRegistry.diagnostics); - warnWhenAllowlistIsOpen({ - logger, - pluginsEnabled: normalized.enabled, - allow: normalized.allow, - warningCacheKey: cacheKey, - discoverablePlugins: manifestRegistry.plugins.map((plugin) => ({ - id: plugin.id, - source: plugin.source, - origin: plugin.origin, - })), - }); - const provenance = buildProvenanceIndex({ - config: cfg, - normalizedLoadPaths: normalized.loadPaths, - env, - }); - - // Lazy: avoid creating the Jiti loader when all plugins are disabled (common in unit tests). - let jitiLoader: ReturnType | null = null; - const getJiti = () => { - if (jitiLoader) { - return jitiLoader; - } - const pluginSdkAlias = resolvePluginSdkAlias(); - const extensionApiAlias = resolveExtensionApiAlias(); - const aliasMap = { - ...(extensionApiAlias ? { "openclaw/extension-api": extensionApiAlias } : {}), - ...(pluginSdkAlias ? { "openclaw/plugin-sdk": pluginSdkAlias } : {}), - ...resolvePluginSdkScopedAliasMap(), - }; - jitiLoader = createJiti(import.meta.url, { - interopDefault: true, - extensions: [".ts", ".tsx", ".mts", ".cts", ".mtsx", ".ctsx", ".js", ".mjs", ".cjs", ".json"], - ...(Object.keys(aliasMap).length > 0 - ? { - alias: aliasMap, - } - : {}), - }); - return jitiLoader; - }; - - const manifestByRoot = new Map( - manifestRegistry.plugins.map((record) => [record.rootDir, record]), - ); - const orderedCandidates = [...discovery.candidates].toSorted((left, right) => { - return compareDuplicateCandidateOrder({ - left, - right, - manifestByRoot, - provenance, - env, - }); - }); - - const seenIds = new Map(); - const memorySlot = normalized.slots.memory; - let selectedMemoryPluginId: string | null = null; - let memorySlotMatched = false; - - for (const candidate of orderedCandidates) { - const manifestRecord = manifestByRoot.get(candidate.rootDir); - if (!manifestRecord) { - continue; - } - const pluginId = manifestRecord.id; - const existingOrigin = seenIds.get(pluginId); - if (existingOrigin) { - const record = createPluginRecord({ - id: pluginId, - name: manifestRecord.name ?? pluginId, - description: manifestRecord.description, - version: manifestRecord.version, - format: manifestRecord.format, - bundleFormat: manifestRecord.bundleFormat, - bundleCapabilities: manifestRecord.bundleCapabilities, - source: candidate.source, - rootDir: candidate.rootDir, - origin: candidate.origin, - workspaceDir: candidate.workspaceDir, - enabled: false, - configSchema: Boolean(manifestRecord.configSchema), - }); - record.status = "disabled"; - record.error = `overridden by ${existingOrigin} plugin`; - registry.plugins.push(record); - continue; - } - - const enableState = resolveEffectiveEnableState({ - id: pluginId, - origin: candidate.origin, - config: normalized, - rootConfig: cfg, - }); - const entry = normalized.entries[pluginId]; - const record = createPluginRecord({ - id: pluginId, - name: manifestRecord.name ?? pluginId, - description: manifestRecord.description, - version: manifestRecord.version, - format: manifestRecord.format, - bundleFormat: manifestRecord.bundleFormat, - bundleCapabilities: manifestRecord.bundleCapabilities, - source: candidate.source, - rootDir: candidate.rootDir, - origin: candidate.origin, - workspaceDir: candidate.workspaceDir, - enabled: enableState.enabled, - configSchema: Boolean(manifestRecord.configSchema), - }); - record.kind = manifestRecord.kind; - record.configUiHints = manifestRecord.configUiHints; - record.configJsonSchema = manifestRecord.configSchema; - const pushPluginLoadError = (message: string) => { - record.status = "error"; - record.error = message; - registry.plugins.push(record); - seenIds.set(pluginId, candidate.origin); - registry.diagnostics.push({ - level: "error", - pluginId: record.id, - source: record.source, - message: record.error, - }); - }; - - if (!enableState.enabled) { - record.status = "disabled"; - record.error = enableState.reason; - registry.plugins.push(record); - seenIds.set(pluginId, candidate.origin); - continue; - } - - if (record.format === "bundle") { - const unsupportedCapabilities = (record.bundleCapabilities ?? []).filter( - (capability) => - capability !== "skills" && - capability !== "settings" && - !( - capability === "commands" && - (record.bundleFormat === "claude" || record.bundleFormat === "cursor") - ) && - !(capability === "hooks" && record.bundleFormat === "codex"), - ); - for (const capability of unsupportedCapabilities) { - registry.diagnostics.push({ - level: "warn", - pluginId: record.id, - source: record.source, - message: `bundle capability detected but not wired into OpenClaw yet: ${capability}`, - }); - } - registry.plugins.push(record); - seenIds.set(pluginId, candidate.origin); - continue; - } - - // Fast-path bundled memory plugins that are guaranteed disabled by slot policy. - // This avoids opening/importing heavy memory plugin modules that will never register. - if (candidate.origin === "bundled" && manifestRecord.kind === "memory") { - const earlyMemoryDecision = resolveMemorySlotDecision({ - id: record.id, - kind: "memory", - slot: memorySlot, - selectedId: selectedMemoryPluginId, - }); - if (!earlyMemoryDecision.enabled) { - record.enabled = false; - record.status = "disabled"; - record.error = earlyMemoryDecision.reason; - registry.plugins.push(record); - seenIds.set(pluginId, candidate.origin); - continue; - } - } - - if (!manifestRecord.configSchema) { - pushPluginLoadError("missing config schema"); - continue; - } - - const pluginRoot = safeRealpathOrResolve(candidate.rootDir); - const opened = openBoundaryFileSync({ - absolutePath: candidate.source, - rootPath: pluginRoot, - boundaryLabel: "plugin root", - rejectHardlinks: candidate.origin !== "bundled", - skipLexicalRootCheck: true, - }); - if (!opened.ok) { - pushPluginLoadError("plugin entry path escapes plugin root or fails alias checks"); - continue; - } - const safeSource = opened.path; - fs.closeSync(opened.fd); - - let mod: OpenClawPluginModule | null = null; - try { - mod = getJiti()(safeSource) as OpenClawPluginModule; - } catch (err) { - recordPluginError({ - logger, - registry, - record, - seenIds, - pluginId, - origin: candidate.origin, - error: err, - logPrefix: `[plugins] ${record.id} failed to load from ${record.source}: `, - diagnosticMessagePrefix: "failed to load plugin: ", - }); - continue; - } - - const resolved = resolvePluginModuleExport(mod); - const definition = resolved.definition; - const register = resolved.register; - - if (definition?.id && definition.id !== record.id) { - pushPluginLoadError( - `plugin id mismatch (config uses "${record.id}", export uses "${definition.id}")`, - ); - continue; - } - - record.name = definition?.name ?? record.name; - record.description = definition?.description ?? record.description; - record.version = definition?.version ?? record.version; - const manifestKind = record.kind as string | undefined; - const exportKind = definition?.kind as string | undefined; - if (manifestKind && exportKind && exportKind !== manifestKind) { - registry.diagnostics.push({ - level: "warn", - pluginId: record.id, - source: record.source, - message: `plugin kind mismatch (manifest uses "${manifestKind}", export uses "${exportKind}")`, - }); - } - record.kind = definition?.kind ?? record.kind; - - if (record.kind === "memory" && memorySlot === record.id) { - memorySlotMatched = true; - } - - const memoryDecision = resolveMemorySlotDecision({ - id: record.id, - kind: record.kind, - slot: memorySlot, - selectedId: selectedMemoryPluginId, - }); - - if (!memoryDecision.enabled) { - record.enabled = false; - record.status = "disabled"; - record.error = memoryDecision.reason; - registry.plugins.push(record); - seenIds.set(pluginId, candidate.origin); - continue; - } - - if (memoryDecision.selected && record.kind === "memory") { - selectedMemoryPluginId = record.id; - } - - const validatedConfig = validatePluginConfig({ - schema: manifestRecord.configSchema, - cacheKey: manifestRecord.schemaCacheKey, - value: entry?.config, - }); - - if (!validatedConfig.ok) { - logger.error(`[plugins] ${record.id} invalid config: ${validatedConfig.errors?.join(", ")}`); - pushPluginLoadError(`invalid config: ${validatedConfig.errors?.join(", ")}`); - continue; - } - - if (validateOnly) { - registry.plugins.push(record); - seenIds.set(pluginId, candidate.origin); - continue; - } - - if (typeof register !== "function") { - logger.error(`[plugins] ${record.id} missing register/activate export`); - pushPluginLoadError("plugin export missing register/activate"); - continue; - } - - const api = createApi(record, { - config: cfg, - pluginConfig: validatedConfig.value, - hookPolicy: entry?.hooks, - }); - - try { - const result = register(api); - if (result && typeof result.then === "function") { - registry.diagnostics.push({ - level: "warn", - pluginId: record.id, - source: record.source, - message: "plugin register returned a promise; async registration is ignored", - }); - } - registry.plugins.push(record); - seenIds.set(pluginId, candidate.origin); - } catch (err) { - recordPluginError({ - logger, - registry, - record, - seenIds, - pluginId, - origin: candidate.origin, - error: err, - logPrefix: `[plugins] ${record.id} failed during register from ${record.source}: `, - diagnosticMessagePrefix: "plugin failed during register: ", - }); - } - } - - if (typeof memorySlot === "string" && !memorySlotMatched) { - registry.diagnostics.push({ - level: "warn", - message: `memory slot plugin not found or not marked as memory: ${memorySlot}`, - }); - } - - warnAboutUntrackedLoadedPlugins({ - registry, - provenance, - logger, - env, - }); - - if (cacheEnabled) { - setCachedPluginRegistry(cacheKey, registry); - } - activatePluginRegistry(registry, cacheKey); - return registry; -} - -function safeRealpathOrResolve(value: string): string { - try { - return fs.realpathSync(value); - } catch { - return path.resolve(value); - } -} diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index b0f98b3beef..75cfe659d4c 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -1,10 +1,15 @@ import fs from "node:fs"; import type { OpenClawConfig } from "../config/config.js"; +import { + buildResolvedExtensionRecord, + type ResolvedExtensionRecord, +} from "../extension-host/manifests/manifest-registry.js"; +import { resolveLegacyExtensionDescriptor } from "../extension-host/manifests/schema.js"; import { resolveUserPath } from "../utils.js"; import { loadBundleManifest } from "./bundle-manifest.js"; import { normalizePluginsConfig, type NormalizedPluginsConfig } from "./config-state.js"; import { discoverOpenClawPlugins, type PluginCandidate } from "./discovery.js"; -import { loadPluginManifest, type PluginManifest } from "./manifest.js"; +import { loadPluginManifest, type PackageManifest, type PluginManifest } from "./manifest.js"; import { isPathInside, safeRealpathSync } from "./path-safety.js"; import { resolvePluginCacheInputs } from "./roots.js"; import type { @@ -52,6 +57,7 @@ export type PluginManifestRecord = { schemaCacheKey?: string; configSchema?: Record; configUiHints?: Record; + resolvedExtension: ResolvedExtensionRecord["extension"]; }; export type PluginManifestRegistry = { @@ -129,6 +135,7 @@ function buildRecord(params: { schemaCacheKey?: string; configSchema?: Record; }): PluginManifestRecord { + const resolved = buildResolvedExtensionRecord(params); return { id: params.manifest.id, name: normalizeManifestLabel(params.manifest.name) ?? params.candidate.packageName, @@ -151,6 +158,7 @@ function buildRecord(params: { schemaCacheKey: params.schemaCacheKey, configSchema: params.configSchema, configUiHints: params.manifest.uiHints, + resolvedExtension: resolved.extension, }; } @@ -168,6 +176,35 @@ function buildBundleRecord(params: { candidate: PluginCandidate; manifestPath: string; }): PluginManifestRecord { + const packageManifest = + params.candidate.packageManifest || + params.candidate.packageName || + params.candidate.packageVersion || + params.candidate.packageDescription + ? ({ + openclaw: params.candidate.packageManifest, + name: params.candidate.packageName, + version: params.candidate.packageVersion, + description: params.candidate.packageDescription, + } as PackageManifest) + : undefined; + const resolvedExtension = resolveLegacyExtensionDescriptor({ + manifest: { + id: params.manifest.id, + configSchema: {}, + channels: [], + providers: [], + skills: params.manifest.skills ?? [], + name: params.manifest.name, + description: params.manifest.description, + version: params.manifest.version, + }, + packageManifest, + origin: params.candidate.origin, + rootDir: params.candidate.rootDir, + source: params.candidate.source, + workspaceDir: params.candidate.workspaceDir, + }); return { id: params.manifest.id, name: normalizeManifestLabel(params.manifest.name) ?? params.candidate.idHint, @@ -189,6 +226,7 @@ function buildBundleRecord(params: { schemaCacheKey: undefined, configSchema: undefined, configUiHints: undefined, + resolvedExtension, }; } diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index 3a3abe0a620..19490a19b25 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -172,6 +172,27 @@ export type PackageManifest = { description?: string; } & Partial>; +export function loadPackageManifest(dir: string, rejectHardlinks = true): PackageManifest | null { + const manifestPath = path.join(dir, "package.json"); + const opened = openBoundaryFileSync({ + absolutePath: manifestPath, + rootPath: dir, + boundaryLabel: "plugin package directory", + rejectHardlinks, + }); + if (!opened.ok) { + return null; + } + try { + const raw = fs.readFileSync(opened.fd, "utf-8"); + return JSON.parse(raw) as PackageManifest; + } catch { + return null; + } finally { + fs.closeSync(opened.fd); + } +} + export function getPackageManifestMetadata( manifest: PackageManifest | undefined, ): OpenClawPackageManifest | undefined { diff --git a/src/plugins/provider-discovery.ts b/src/plugins/provider-discovery.ts index e249bf6e45a..42d22a3e87c 100644 --- a/src/plugins/provider-discovery.ts +++ b/src/plugins/provider-discovery.ts @@ -1,6 +1,6 @@ -import { normalizeProviderId } from "../agents/model-selection.js"; import type { OpenClawConfig } from "../config/config.js"; import type { ModelProviderConfig } from "../config/types.js"; +import { normalizeExtensionHostDiscoveryResult } from "../extension-host/contributions/provider-discovery.js"; import { resolvePluginProviders } from "./providers.js"; import type { ProviderDiscoveryOrder, ProviderPlugin } from "./types.js"; @@ -51,24 +51,7 @@ export function normalizePluginDiscoveryResult(params: { | null | undefined; }): Record { - const result = params.result; - if (!result) { - return {}; - } - - if ("provider" in result) { - return { [normalizeProviderId(params.provider.id)]: result.provider }; - } - - const normalized: Record = {}; - for (const [key, value] of Object.entries(result.providers)) { - const normalizedKey = normalizeProviderId(key); - if (!normalizedKey || !value) { - continue; - } - normalized[normalizedKey] = value; - } - return normalized; + return normalizeExtensionHostDiscoveryResult(params); } export function runProviderCatalog(params: { diff --git a/src/plugins/provider-wizard.ts b/src/plugins/provider-wizard.ts index 4b02fcd3cf7..557478793c0 100644 --- a/src/plugins/provider-wizard.ts +++ b/src/plugins/provider-wizard.ts @@ -1,15 +1,14 @@ -import { DEFAULT_PROVIDER } from "../agents/defaults.js"; -import { parseModelRef } from "../agents/model-selection.js"; -import { normalizeProviderId } from "../agents/model-selection.js"; import type { OpenClawConfig } from "../config/config.js"; +import { runExtensionHostProviderModelSelectedHook } from "../extension-host/contributions/provider-model-selection.js"; +import { + buildExtensionHostProviderMethodChoice, + resolveExtensionHostProviderChoice, + resolveExtensionHostProviderModelPickerEntries, + resolveExtensionHostProviderWizardOptions, +} from "../extension-host/contributions/provider-wizard.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { resolvePluginProviders } from "./providers.js"; -import type { - ProviderAuthMethod, - ProviderPlugin, - ProviderPluginWizardModelPicker, - ProviderPluginWizardOnboarding, -} from "./types.js"; +import type { ProviderAuthMethod, ProviderPlugin } from "./types.js"; export const PROVIDER_PLUGIN_CHOICE_PREFIX = "provider-plugin:"; @@ -28,60 +27,8 @@ export type ProviderModelPickerEntry = { hint?: string; }; -function normalizeChoiceId(choiceId: string): string { - return choiceId.trim(); -} - -function resolveWizardOnboardingChoiceId( - provider: ProviderPlugin, - wizard: ProviderPluginWizardOnboarding, -): string { - const explicit = wizard.choiceId?.trim(); - if (explicit) { - return explicit; - } - const explicitMethodId = wizard.methodId?.trim(); - if (explicitMethodId) { - return buildProviderPluginMethodChoice(provider.id, explicitMethodId); - } - if (provider.auth.length === 1) { - return provider.id; - } - return buildProviderPluginMethodChoice(provider.id, provider.auth[0]?.id ?? "default"); -} - -function resolveMethodById( - provider: ProviderPlugin, - methodId?: string, -): ProviderAuthMethod | undefined { - const normalizedMethodId = methodId?.trim().toLowerCase(); - if (!normalizedMethodId) { - return provider.auth[0]; - } - return provider.auth.find((method) => method.id.trim().toLowerCase() === normalizedMethodId); -} - -function buildOnboardingOptionForMethod(params: { - provider: ProviderPlugin; - wizard: ProviderPluginWizardOnboarding; - method: ProviderAuthMethod; - value: string; -}): ProviderWizardOption { - const normalizedGroupId = params.wizard.groupId?.trim() || params.provider.id; - return { - value: normalizeChoiceId(params.value), - label: - params.wizard.choiceLabel?.trim() || - (params.provider.auth.length === 1 ? params.provider.label : params.method.label), - hint: params.wizard.choiceHint?.trim() || params.method.hint, - groupId: normalizedGroupId, - groupLabel: params.wizard.groupLabel?.trim() || params.provider.label, - groupHint: params.wizard.groupHint?.trim(), - }; -} - export function buildProviderPluginMethodChoice(providerId: string, methodId: string): string { - return `${PROVIDER_PLUGIN_CHOICE_PREFIX}${providerId.trim()}:${methodId.trim()}`; + return buildExtensionHostProviderMethodChoice(providerId, methodId); } export function resolveProviderWizardOptions(params: { @@ -89,54 +36,7 @@ export function resolveProviderWizardOptions(params: { workspaceDir?: string; env?: NodeJS.ProcessEnv; }): ProviderWizardOption[] { - const providers = resolvePluginProviders(params); - const options: ProviderWizardOption[] = []; - - for (const provider of providers) { - const wizard = provider.wizard?.onboarding; - if (!wizard) { - continue; - } - const explicitMethod = resolveMethodById(provider, wizard.methodId); - if (explicitMethod) { - options.push( - buildOnboardingOptionForMethod({ - provider, - wizard, - method: explicitMethod, - value: resolveWizardOnboardingChoiceId(provider, wizard), - }), - ); - continue; - } - - for (const method of provider.auth) { - options.push( - buildOnboardingOptionForMethod({ - provider, - wizard, - method, - value: buildProviderPluginMethodChoice(provider.id, method.id), - }), - ); - } - } - - return options; -} - -function resolveModelPickerChoiceValue( - provider: ProviderPlugin, - modelPicker: ProviderPluginWizardModelPicker, -): string { - const explicitMethodId = modelPicker.methodId?.trim(); - if (explicitMethodId) { - return buildProviderPluginMethodChoice(provider.id, explicitMethodId); - } - if (provider.auth.length === 1) { - return provider.id; - } - return buildProviderPluginMethodChoice(provider.id, provider.auth[0]?.id ?? "default"); + return resolveExtensionHostProviderWizardOptions(resolvePluginProviders(params)); } export function resolveProviderModelPickerEntries(params: { @@ -144,68 +44,14 @@ export function resolveProviderModelPickerEntries(params: { workspaceDir?: string; env?: NodeJS.ProcessEnv; }): ProviderModelPickerEntry[] { - const providers = resolvePluginProviders(params); - const entries: ProviderModelPickerEntry[] = []; - - for (const provider of providers) { - const modelPicker = provider.wizard?.modelPicker; - if (!modelPicker) { - continue; - } - entries.push({ - value: resolveModelPickerChoiceValue(provider, modelPicker), - label: modelPicker.label?.trim() || `${provider.label} (custom)`, - hint: modelPicker.hint?.trim(), - }); - } - - return entries; + return resolveExtensionHostProviderModelPickerEntries(resolvePluginProviders(params)); } export function resolveProviderPluginChoice(params: { providers: ProviderPlugin[]; choice: string; }): { provider: ProviderPlugin; method: ProviderAuthMethod } | null { - const choice = params.choice.trim(); - if (!choice) { - return null; - } - - if (choice.startsWith(PROVIDER_PLUGIN_CHOICE_PREFIX)) { - const payload = choice.slice(PROVIDER_PLUGIN_CHOICE_PREFIX.length); - const separator = payload.indexOf(":"); - const providerId = separator >= 0 ? payload.slice(0, separator) : payload; - const methodId = separator >= 0 ? payload.slice(separator + 1) : undefined; - const provider = params.providers.find( - (entry) => normalizeProviderId(entry.id) === normalizeProviderId(providerId), - ); - if (!provider) { - return null; - } - const method = resolveMethodById(provider, methodId); - return method ? { provider, method } : null; - } - - for (const provider of params.providers) { - const onboarding = provider.wizard?.onboarding; - if (onboarding) { - const onboardingChoiceId = resolveWizardOnboardingChoiceId(provider, onboarding); - if (normalizeChoiceId(onboardingChoiceId) === choice) { - const method = resolveMethodById(provider, onboarding.methodId); - if (method) { - return { provider, method }; - } - } - } - if ( - normalizeProviderId(provider.id) === normalizeProviderId(choice) && - provider.auth.length > 0 - ) { - return { provider, method: provider.auth[0] }; - } - } - - return null; + return resolveExtensionHostProviderChoice(params); } export async function runProviderModelSelectedHook(params: { @@ -216,28 +62,5 @@ export async function runProviderModelSelectedHook(params: { workspaceDir?: string; env?: NodeJS.ProcessEnv; }): Promise { - const parsed = parseModelRef(params.model, DEFAULT_PROVIDER); - if (!parsed) { - return; - } - - const providers = resolvePluginProviders({ - config: params.config, - workspaceDir: params.workspaceDir, - env: params.env, - }); - const provider = providers.find( - (entry) => normalizeProviderId(entry.id) === normalizeProviderId(parsed.provider), - ); - if (!provider?.onModelSelected) { - return; - } - - await provider.onModelSelected({ - config: params.config, - model: params.model, - prompter: params.prompter, - agentDir: params.agentDir, - workspaceDir: params.workspaceDir, - }); + await runExtensionHostProviderModelSelectedHook(params); } diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index dda000e2641..aa50c05fe9e 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -1,3 +1,4 @@ +import { resolveExtensionHostProviders } from "../extension-host/contributions/provider-runtime.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { loadOpenClawPlugins, type PluginLoadOptions } from "./loader.js"; import { createPluginLoaderLogger } from "./logger.js"; @@ -82,8 +83,5 @@ export function resolvePluginProviders(params: { logger: createPluginLoaderLogger(log), }); - return registry.providers.map((entry) => ({ - ...entry.provider, - pluginId: entry.pluginId, - })); + return resolveExtensionHostProviders({ registry }); } diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index d754d928f15..4ec98ca768d 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -1,51 +1,26 @@ -import path from "node:path"; -import type { AnyAgentTool } from "../agents/tools/common.js"; import type { ChannelDock } from "../channels/dock.js"; import type { ChannelPlugin } from "../channels/plugins/types.js"; -import { registerContextEngineForOwner } from "../context-engine/registry.js"; -import type { - GatewayRequestHandler, - GatewayRequestHandlers, -} from "../gateway/server-methods/types.js"; -import { registerInternalHook } from "../hooks/internal-hooks.js"; +import { createExtensionHostPluginRegistry } from "../extension-host/compat/plugin-registry.js"; +import type { GatewayRequestHandlers } from "../gateway/server-methods/types.js"; import type { HookEntry } from "../hooks/types.js"; -import { resolveUserPath } from "../utils.js"; -import { registerPluginCommand } from "./commands.js"; -import { normalizePluginHttpPath } from "./http-path.js"; -import { findOverlappingPluginHttpRoute } from "./http-route-overlap.js"; -import { registerPluginInteractiveHandler } from "./interactive.js"; -import { normalizeRegisteredProvider } from "./provider-validation.js"; import type { PluginRuntime } from "./runtime/types.js"; -import { defaultSlotIdForKey } from "./slots.js"; -import { - isPluginHookName, - isPromptInjectionHookName, - stripPromptMutationFieldsFromLegacyHookResult, -} from "./types.js"; import type { - OpenClawPluginApi, - OpenClawPluginChannelRegistration, OpenClawPluginCliRegistrar, OpenClawPluginCommandDefinition, OpenClawPluginHttpRouteAuth, - OpenClawPluginHttpRouteMatch, OpenClawPluginHttpRouteHandler, - OpenClawPluginHttpRouteParams, - OpenClawPluginHookOptions, - ProviderPlugin, + OpenClawPluginHttpRouteMatch, OpenClawPluginService, - OpenClawPluginToolContext, OpenClawPluginToolFactory, + PluginBundleFormat, PluginConfigUiHint, PluginDiagnostic, - PluginBundleFormat, PluginFormat, + PluginKind, PluginLogger, PluginOrigin, - PluginKind, - PluginHookName, - PluginHookHandlerMap, PluginHookRegistration as TypedPluginHookRegistration, + ProviderPlugin, } from "./types.js"; export type PluginToolRegistration = { @@ -117,6 +92,15 @@ export type PluginCommandRegistration = { rootDir?: string; }; +export type PluginRecordLifecycleState = + | "prepared" + | "imported" + | "disabled" + | "validated" + | "registered" + | "ready" + | "error"; + export type PluginRecord = { id: string; name: string; @@ -132,6 +116,7 @@ export type PluginRecord = { workspaceDir?: string; enabled: boolean; status: "loaded" | "disabled" | "error"; + lifecycleState?: PluginRecordLifecycleState; error?: string; toolNames: string[]; hookNames: string[]; @@ -169,24 +154,6 @@ export type PluginRegistryParams = { runtime: PluginRuntime; }; -type PluginTypedHookPolicy = { - allowPromptInjection?: boolean; -}; - -const constrainLegacyPromptInjectionHook = ( - handler: PluginHookHandlerMap["before_agent_start"], -): PluginHookHandlerMap["before_agent_start"] => { - return (event, ctx) => { - const result = handler(event, ctx); - if (result && typeof result === "object" && "then" in result) { - return Promise.resolve(result).then((resolved) => - stripPromptMutationFieldsFromLegacyHookResult(resolved), - ); - } - return stripPromptMutationFieldsFromLegacyHookResult(result); - }; -}; - export function createEmptyPluginRegistry(): PluginRegistry { return { plugins: [], @@ -205,545 +172,8 @@ export function createEmptyPluginRegistry(): PluginRegistry { } export function createPluginRegistry(registryParams: PluginRegistryParams) { - const registry = createEmptyPluginRegistry(); - const coreGatewayMethods = new Set(Object.keys(registryParams.coreGatewayHandlers ?? {})); - - const pushDiagnostic = (diag: PluginDiagnostic) => { - registry.diagnostics.push(diag); - }; - - const registerTool = ( - record: PluginRecord, - tool: AnyAgentTool | OpenClawPluginToolFactory, - opts?: { name?: string; names?: string[]; optional?: boolean }, - ) => { - const names = opts?.names ?? (opts?.name ? [opts.name] : []); - const optional = opts?.optional === true; - const factory: OpenClawPluginToolFactory = - typeof tool === "function" ? tool : (_ctx: OpenClawPluginToolContext) => tool; - - if (typeof tool !== "function") { - names.push(tool.name); - } - - const normalized = names.map((name) => name.trim()).filter(Boolean); - if (normalized.length > 0) { - record.toolNames.push(...normalized); - } - registry.tools.push({ - pluginId: record.id, - pluginName: record.name, - factory, - names: normalized, - optional, - source: record.source, - rootDir: record.rootDir, - }); - }; - - const registerHook = ( - record: PluginRecord, - events: string | string[], - handler: Parameters[1], - opts: OpenClawPluginHookOptions | undefined, - config: OpenClawPluginApi["config"], - ) => { - const eventList = Array.isArray(events) ? events : [events]; - const normalizedEvents = eventList.map((event) => event.trim()).filter(Boolean); - const entry = opts?.entry ?? null; - const name = entry?.hook.name ?? opts?.name?.trim(); - if (!name) { - pushDiagnostic({ - level: "warn", - pluginId: record.id, - source: record.source, - message: "hook registration missing name", - }); - return; - } - const existingHook = registry.hooks.find((entry) => entry.entry.hook.name === name); - if (existingHook) { - pushDiagnostic({ - level: "error", - pluginId: record.id, - source: record.source, - message: `hook already registered: ${name} (${existingHook.pluginId})`, - }); - return; - } - - const description = entry?.hook.description ?? opts?.description ?? ""; - const hookEntry: HookEntry = entry - ? { - ...entry, - hook: { - ...entry.hook, - name, - description, - source: "openclaw-plugin", - pluginId: record.id, - }, - metadata: { - ...entry.metadata, - events: normalizedEvents, - }, - } - : { - hook: { - name, - description, - source: "openclaw-plugin", - pluginId: record.id, - filePath: record.source, - baseDir: path.dirname(record.source), - handlerPath: record.source, - }, - frontmatter: {}, - metadata: { events: normalizedEvents }, - invocation: { enabled: true }, - }; - - record.hookNames.push(name); - registry.hooks.push({ - pluginId: record.id, - entry: hookEntry, - events: normalizedEvents, - source: record.source, - }); - - const hookSystemEnabled = config?.hooks?.internal?.enabled === true; - if (!hookSystemEnabled || opts?.register === false) { - return; - } - - for (const event of normalizedEvents) { - registerInternalHook(event, handler); - } - }; - - const registerGatewayMethod = ( - record: PluginRecord, - method: string, - handler: GatewayRequestHandler, - ) => { - const trimmed = method.trim(); - if (!trimmed) { - return; - } - if (coreGatewayMethods.has(trimmed) || registry.gatewayHandlers[trimmed]) { - pushDiagnostic({ - level: "error", - pluginId: record.id, - source: record.source, - message: `gateway method already registered: ${trimmed}`, - }); - return; - } - registry.gatewayHandlers[trimmed] = handler; - record.gatewayMethods.push(trimmed); - }; - - const describeHttpRouteOwner = (entry: PluginHttpRouteRegistration): string => { - const plugin = entry.pluginId?.trim() || "unknown-plugin"; - const source = entry.source?.trim() || "unknown-source"; - return `${plugin} (${source})`; - }; - - const registerHttpRoute = (record: PluginRecord, params: OpenClawPluginHttpRouteParams) => { - const normalizedPath = normalizePluginHttpPath(params.path); - if (!normalizedPath) { - pushDiagnostic({ - level: "warn", - pluginId: record.id, - source: record.source, - message: "http route registration missing path", - }); - return; - } - if (params.auth !== "gateway" && params.auth !== "plugin") { - pushDiagnostic({ - level: "error", - pluginId: record.id, - source: record.source, - message: `http route registration missing or invalid auth: ${normalizedPath}`, - }); - return; - } - const match = params.match ?? "exact"; - const overlappingRoute = findOverlappingPluginHttpRoute(registry.httpRoutes, { - path: normalizedPath, - match, - }); - if (overlappingRoute && overlappingRoute.auth !== params.auth) { - pushDiagnostic({ - level: "error", - pluginId: record.id, - source: record.source, - message: - `http route overlap rejected: ${normalizedPath} (${match}, ${params.auth}) ` + - `overlaps ${overlappingRoute.path} (${overlappingRoute.match}, ${overlappingRoute.auth}) ` + - `owned by ${describeHttpRouteOwner(overlappingRoute)}`, - }); - return; - } - const existingIndex = registry.httpRoutes.findIndex( - (entry) => entry.path === normalizedPath && entry.match === match, - ); - if (existingIndex >= 0) { - const existing = registry.httpRoutes[existingIndex]; - if (!existing) { - return; - } - if (!params.replaceExisting) { - pushDiagnostic({ - level: "error", - pluginId: record.id, - source: record.source, - message: `http route already registered: ${normalizedPath} (${match}) by ${describeHttpRouteOwner(existing)}`, - }); - return; - } - if (existing.pluginId && existing.pluginId !== record.id) { - pushDiagnostic({ - level: "error", - pluginId: record.id, - source: record.source, - message: `http route replacement rejected: ${normalizedPath} (${match}) owned by ${describeHttpRouteOwner(existing)}`, - }); - return; - } - registry.httpRoutes[existingIndex] = { - pluginId: record.id, - path: normalizedPath, - handler: params.handler, - auth: params.auth, - match, - source: record.source, - }; - return; - } - record.httpRoutes += 1; - registry.httpRoutes.push({ - pluginId: record.id, - path: normalizedPath, - handler: params.handler, - auth: params.auth, - match, - source: record.source, - }); - }; - - const registerChannel = ( - record: PluginRecord, - registration: OpenClawPluginChannelRegistration | ChannelPlugin, - ) => { - const normalized = - typeof (registration as OpenClawPluginChannelRegistration).plugin === "object" - ? (registration as OpenClawPluginChannelRegistration) - : { plugin: registration as ChannelPlugin }; - const plugin = normalized.plugin; - const id = typeof plugin?.id === "string" ? plugin.id.trim() : String(plugin?.id ?? "").trim(); - if (!id) { - pushDiagnostic({ - level: "error", - pluginId: record.id, - source: record.source, - message: "channel registration missing id", - }); - return; - } - const existing = registry.channels.find((entry) => entry.plugin.id === id); - if (existing) { - pushDiagnostic({ - level: "error", - pluginId: record.id, - source: record.source, - message: `channel already registered: ${id} (${existing.pluginId})`, - }); - return; - } - record.channelIds.push(id); - registry.channels.push({ - pluginId: record.id, - pluginName: record.name, - plugin, - dock: normalized.dock, - source: record.source, - rootDir: record.rootDir, - }); - }; - - const registerProvider = (record: PluginRecord, provider: ProviderPlugin) => { - const normalizedProvider = normalizeRegisteredProvider({ - pluginId: record.id, - source: record.source, - provider, - pushDiagnostic, - }); - if (!normalizedProvider) { - return; - } - const id = normalizedProvider.id; - const existing = registry.providers.find((entry) => entry.provider.id === id); - if (existing) { - pushDiagnostic({ - level: "error", - pluginId: record.id, - source: record.source, - message: `provider already registered: ${id} (${existing.pluginId})`, - }); - return; - } - record.providerIds.push(id); - registry.providers.push({ - pluginId: record.id, - pluginName: record.name, - provider: normalizedProvider, - source: record.source, - rootDir: record.rootDir, - }); - }; - - const registerCli = ( - record: PluginRecord, - registrar: OpenClawPluginCliRegistrar, - opts?: { commands?: string[] }, - ) => { - const commands = (opts?.commands ?? []).map((cmd) => cmd.trim()).filter(Boolean); - if (commands.length === 0) { - pushDiagnostic({ - level: "error", - pluginId: record.id, - source: record.source, - message: "cli registration missing explicit commands metadata", - }); - return; - } - const existing = registry.cliRegistrars.find((entry) => - entry.commands.some((command) => commands.includes(command)), - ); - if (existing) { - const overlap = commands.find((command) => existing.commands.includes(command)); - pushDiagnostic({ - level: "error", - pluginId: record.id, - source: record.source, - message: `cli command already registered: ${overlap ?? commands[0]} (${existing.pluginId})`, - }); - return; - } - record.cliCommands.push(...commands); - registry.cliRegistrars.push({ - pluginId: record.id, - pluginName: record.name, - register: registrar, - commands, - source: record.source, - rootDir: record.rootDir, - }); - }; - - const registerService = (record: PluginRecord, service: OpenClawPluginService) => { - const id = service.id.trim(); - if (!id) { - return; - } - const existing = registry.services.find((entry) => entry.service.id === id); - if (existing) { - pushDiagnostic({ - level: "error", - pluginId: record.id, - source: record.source, - message: `service already registered: ${id} (${existing.pluginId})`, - }); - return; - } - record.services.push(id); - registry.services.push({ - pluginId: record.id, - pluginName: record.name, - service, - source: record.source, - rootDir: record.rootDir, - }); - }; - - const registerCommand = (record: PluginRecord, command: OpenClawPluginCommandDefinition) => { - const name = command.name.trim(); - if (!name) { - pushDiagnostic({ - level: "error", - pluginId: record.id, - source: record.source, - message: "command registration missing name", - }); - return; - } - - // Register with the plugin command system (validates name and checks for duplicates) - const result = registerPluginCommand(record.id, command, { - pluginName: record.name, - pluginRoot: record.rootDir, - }); - if (!result.ok) { - pushDiagnostic({ - level: "error", - pluginId: record.id, - source: record.source, - message: `command registration failed: ${result.error}`, - }); - return; - } - - record.commands.push(name); - registry.commands.push({ - pluginId: record.id, - pluginName: record.name, - command, - source: record.source, - rootDir: record.rootDir, - }); - }; - - const registerTypedHook = ( - record: PluginRecord, - hookName: K, - handler: PluginHookHandlerMap[K], - opts?: { priority?: number }, - policy?: PluginTypedHookPolicy, - ) => { - if (!isPluginHookName(hookName)) { - pushDiagnostic({ - level: "warn", - pluginId: record.id, - source: record.source, - message: `unknown typed hook "${String(hookName)}" ignored`, - }); - return; - } - let effectiveHandler = handler; - if (policy?.allowPromptInjection === false && isPromptInjectionHookName(hookName)) { - if (hookName === "before_prompt_build") { - pushDiagnostic({ - level: "warn", - pluginId: record.id, - source: record.source, - message: `typed hook "${hookName}" blocked by plugins.entries.${record.id}.hooks.allowPromptInjection=false`, - }); - return; - } - if (hookName === "before_agent_start") { - pushDiagnostic({ - level: "warn", - pluginId: record.id, - source: record.source, - message: `typed hook "${hookName}" prompt fields constrained by plugins.entries.${record.id}.hooks.allowPromptInjection=false`, - }); - effectiveHandler = constrainLegacyPromptInjectionHook( - handler as PluginHookHandlerMap["before_agent_start"], - ) as PluginHookHandlerMap[K]; - } - } - record.hookCount += 1; - registry.typedHooks.push({ - pluginId: record.id, - hookName, - handler: effectiveHandler, - priority: opts?.priority, - source: record.source, - } as TypedPluginHookRegistration); - }; - - const normalizeLogger = (logger: PluginLogger): PluginLogger => ({ - info: logger.info, - warn: logger.warn, - error: logger.error, - debug: logger.debug, + return createExtensionHostPluginRegistry({ + registry: createEmptyPluginRegistry(), + registryParams, }); - - const createApi = ( - record: PluginRecord, - params: { - config: OpenClawPluginApi["config"]; - pluginConfig?: Record; - hookPolicy?: PluginTypedHookPolicy; - }, - ): OpenClawPluginApi => { - return { - id: record.id, - name: record.name, - version: record.version, - description: record.description, - source: record.source, - rootDir: record.rootDir, - config: params.config, - pluginConfig: params.pluginConfig, - runtime: registryParams.runtime, - logger: normalizeLogger(registryParams.logger), - registerTool: (tool, opts) => registerTool(record, tool, opts), - registerHook: (events, handler, opts) => - registerHook(record, events, handler, opts, params.config), - registerHttpRoute: (params) => registerHttpRoute(record, params), - registerChannel: (registration) => registerChannel(record, registration), - registerProvider: (provider) => registerProvider(record, provider), - registerGatewayMethod: (method, handler) => registerGatewayMethod(record, method, handler), - registerCli: (registrar, opts) => registerCli(record, registrar, opts), - registerService: (service) => registerService(record, service), - registerInteractiveHandler: (registration) => { - const result = registerPluginInteractiveHandler(record.id, registration, { - pluginName: record.name, - pluginRoot: record.rootDir, - }); - if (!result.ok) { - pushDiagnostic({ - level: "warn", - pluginId: record.id, - source: record.source, - message: result.error ?? "interactive handler registration failed", - }); - } - }, - registerCommand: (command) => registerCommand(record, command), - registerContextEngine: (id, factory) => { - if (id === defaultSlotIdForKey("contextEngine")) { - pushDiagnostic({ - level: "error", - pluginId: record.id, - source: record.source, - message: `context engine id reserved by core: ${id}`, - }); - return; - } - const result = registerContextEngineForOwner(id, factory, `plugin:${record.id}`, { - allowSameOwnerRefresh: true, - }); - if (!result.ok) { - pushDiagnostic({ - level: "error", - pluginId: record.id, - source: record.source, - message: `context engine already registered: ${id} (${result.existingOwner})`, - }); - } - }, - resolvePath: (input: string) => resolveUserPath(input), - on: (hookName, handler, opts) => - registerTypedHook(record, hookName, handler, opts, params.hookPolicy), - }; - }; - - return { - registry, - createApi, - pushDiagnostic, - registerTool, - registerChannel, - registerProvider, - registerGatewayMethod, - registerCli, - registerService, - registerCommand, - registerHook, - registerTypedHook, - }; } diff --git a/src/plugins/runtime.ts b/src/plugins/runtime.ts index 752908ddf75..fa3043a8b66 100644 --- a/src/plugins/runtime.ts +++ b/src/plugins/runtime.ts @@ -1,49 +1,32 @@ -import { createEmptyPluginRegistry, type PluginRegistry } from "./registry.js"; +import { + getActiveExtensionHostRegistry, + getActiveExtensionHostRegistryKey, + getActiveExtensionHostRegistryVersion, + requireActiveExtensionHostRegistry, + setActiveExtensionHostRegistry, + type ExtensionHostRegistry, +} from "../extension-host/static/active-registry.js"; -const REGISTRY_STATE = Symbol.for("openclaw.pluginRegistryState"); - -type RegistryState = { - registry: PluginRegistry | null; - key: string | null; - version: number; -}; - -const state: RegistryState = (() => { - const globalState = globalThis as typeof globalThis & { - [REGISTRY_STATE]?: RegistryState; - }; - if (!globalState[REGISTRY_STATE]) { - globalState[REGISTRY_STATE] = { - registry: createEmptyPluginRegistry(), - key: null, - version: 0, - }; - } - return globalState[REGISTRY_STATE]; -})(); +export type PluginRegistry = ExtensionHostRegistry; +// Compatibility facade: legacy plugin runtime callers still import from this module, +// but the active registry now lives under the extension-host boundary. export function setActivePluginRegistry(registry: PluginRegistry, cacheKey?: string) { - state.registry = registry; - state.key = cacheKey ?? null; - state.version += 1; + setActiveExtensionHostRegistry(registry, cacheKey); } export function getActivePluginRegistry(): PluginRegistry | null { - return state.registry; + return getActiveExtensionHostRegistry(); } export function requireActivePluginRegistry(): PluginRegistry { - if (!state.registry) { - state.registry = createEmptyPluginRegistry(); - state.version += 1; - } - return state.registry; + return requireActiveExtensionHostRegistry(); } export function getActivePluginRegistryKey(): string | null { - return state.key; + return getActiveExtensionHostRegistryKey(); } export function getActivePluginRegistryVersion(): number { - return state.version; + return getActiveExtensionHostRegistryVersion(); } diff --git a/src/plugins/services.ts b/src/plugins/services.ts index 07746e1650a..a653da9ad2d 100644 --- a/src/plugins/services.ts +++ b/src/plugins/services.ts @@ -1,79 +1,16 @@ import type { OpenClawConfig } from "../config/config.js"; -import { STATE_DIR } from "../config/paths.js"; -import { createSubsystemLogger } from "../logging/subsystem.js"; +import { + startExtensionHostServices, + type ExtensionHostServicesHandle, +} from "../extension-host/contributions/service-lifecycle.js"; import type { PluginRegistry } from "./registry.js"; -import type { OpenClawPluginServiceContext, PluginLogger } from "./types.js"; -const log = createSubsystemLogger("plugins"); - -function createPluginLogger(): PluginLogger { - return { - info: (msg) => log.info(msg), - warn: (msg) => log.warn(msg), - error: (msg) => log.error(msg), - debug: (msg) => log.debug(msg), - }; -} - -function createServiceContext(params: { - config: OpenClawConfig; - workspaceDir?: string; -}): OpenClawPluginServiceContext { - return { - config: params.config, - workspaceDir: params.workspaceDir, - stateDir: STATE_DIR, - logger: createPluginLogger(), - }; -} - -export type PluginServicesHandle = { - stop: () => Promise; -}; +export type PluginServicesHandle = ExtensionHostServicesHandle; export async function startPluginServices(params: { registry: PluginRegistry; config: OpenClawConfig; workspaceDir?: string; }): Promise { - const running: Array<{ - id: string; - stop?: () => void | Promise; - }> = []; - const serviceContext = createServiceContext({ - config: params.config, - workspaceDir: params.workspaceDir, - }); - - for (const entry of params.registry.services) { - const service = entry.service; - try { - await service.start(serviceContext); - running.push({ - id: service.id, - stop: service.stop ? () => service.stop?.(serviceContext) : undefined, - }); - } catch (err) { - const error = err as Error; - const stack = error?.stack?.trim(); - log.error( - `plugin service failed (${service.id}, plugin=${entry.pluginId}, root=${entry.rootDir ?? "unknown"}): ${error?.message ?? String(err)}${stack ? `\n${stack}` : ""}`, - ); - } - } - - return { - stop: async () => { - for (const entry of running.toReversed()) { - if (!entry.stop) { - continue; - } - try { - await entry.stop(); - } catch (err) { - log.warn(`plugin service stop failed (${entry.id}): ${String(err)}`); - } - } - }, - }; + return startExtensionHostServices(params); } diff --git a/src/plugins/tools.ts b/src/plugins/tools.ts index ebf96ec6a4c..805774af57f 100644 --- a/src/plugins/tools.ts +++ b/src/plugins/tools.ts @@ -1,5 +1,9 @@ -import { normalizeToolName } from "../agents/tool-policy.js"; import type { AnyAgentTool } from "../agents/tools/common.js"; +import { + getExtensionHostPluginToolMeta, + resolveExtensionHostPluginTools, + type ExtensionHostPluginToolMeta, +} from "../extension-host/contributions/tool-runtime.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { applyTestPluginDefaults, normalizePluginsConfig } from "./config-state.js"; import { loadOpenClawPlugins } from "./loader.js"; @@ -8,38 +12,10 @@ import type { OpenClawPluginToolContext } from "./types.js"; const log = createSubsystemLogger("plugins"); -type PluginToolMeta = { - pluginId: string; - optional: boolean; -}; - -const pluginToolMeta = new WeakMap(); +type PluginToolMeta = ExtensionHostPluginToolMeta; export function getPluginToolMeta(tool: AnyAgentTool): PluginToolMeta | undefined { - return pluginToolMeta.get(tool); -} - -function normalizeAllowlist(list?: string[]) { - return new Set((list ?? []).map(normalizeToolName).filter(Boolean)); -} - -function isOptionalToolAllowed(params: { - toolName: string; - pluginId: string; - allowlist: Set; -}): boolean { - if (params.allowlist.size === 0) { - return false; - } - const toolName = normalizeToolName(params.toolName); - if (params.allowlist.has(toolName)) { - return true; - } - const pluginKey = normalizeToolName(params.pluginId); - if (params.allowlist.has(pluginKey)) { - return true; - } - return params.allowlist.has("group:plugins"); + return getExtensionHostPluginToolMeta(tool); } export function resolvePluginTools(params: { @@ -65,78 +41,11 @@ export function resolvePluginTools(params: { logger: createPluginLoaderLogger(log), }); - const tools: AnyAgentTool[] = []; - const existing = params.existingToolNames ?? new Set(); - const existingNormalized = new Set(Array.from(existing, (tool) => normalizeToolName(tool))); - const allowlist = normalizeAllowlist(params.toolAllowlist); - const blockedPlugins = new Set(); - - for (const entry of registry.tools) { - if (blockedPlugins.has(entry.pluginId)) { - continue; - } - const pluginIdKey = normalizeToolName(entry.pluginId); - if (existingNormalized.has(pluginIdKey)) { - const message = `plugin id conflicts with core tool name (${entry.pluginId})`; - if (!params.suppressNameConflicts) { - log.error(message); - registry.diagnostics.push({ - level: "error", - pluginId: entry.pluginId, - source: entry.source, - message, - }); - } - blockedPlugins.add(entry.pluginId); - continue; - } - let resolved: AnyAgentTool | AnyAgentTool[] | null | undefined = null; - try { - resolved = entry.factory(params.context); - } catch (err) { - log.error(`plugin tool failed (${entry.pluginId}): ${String(err)}`); - continue; - } - if (!resolved) { - continue; - } - const listRaw = Array.isArray(resolved) ? resolved : [resolved]; - const list = entry.optional - ? listRaw.filter((tool) => - isOptionalToolAllowed({ - toolName: tool.name, - pluginId: entry.pluginId, - allowlist, - }), - ) - : listRaw; - if (list.length === 0) { - continue; - } - const nameSet = new Set(); - for (const tool of list) { - if (nameSet.has(tool.name) || existing.has(tool.name)) { - const message = `plugin tool name conflict (${entry.pluginId}): ${tool.name}`; - if (!params.suppressNameConflicts) { - log.error(message); - registry.diagnostics.push({ - level: "error", - pluginId: entry.pluginId, - source: entry.source, - message, - }); - } - continue; - } - nameSet.add(tool.name); - existing.add(tool.name); - pluginToolMeta.set(tool, { - pluginId: entry.pluginId, - optional: entry.optional, - }); - tools.push(tool); - } - } - - return tools; + return resolveExtensionHostPluginTools({ + registry, + context: params.context, + existingToolNames: params.existingToolNames, + toolAllowlist: params.toolAllowlist, + suppressNameConflicts: params.suppressNameConflicts, + }); } diff --git a/src/utils/message-channel.test.ts b/src/utils/message-channel.test.ts index 98bd4fffce2..2513fc298a6 100644 --- a/src/utils/message-channel.test.ts +++ b/src/utils/message-channel.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { ChannelPlugin } from "../channels/plugins/types.js"; +import { addExtensionHostChannelRegistration } from "../extension-host/contributions/runtime-registry.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createMSTeamsTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; import { resolveGatewayMessageChannel } from "./message-channel.js"; @@ -31,4 +32,22 @@ describe("message-channel", () => { ); expect(resolveGatewayMessageChannel("teams")).toBe("msteams"); }); + + it("falls back to host-owned channel state when the legacy channel array is replaced", () => { + const registry = createTestRegistry([ + { pluginId: "msteams", plugin: msteamsPlugin, source: "test" }, + ]); + setActivePluginRegistry(registry, "message-channel-registry"); + expect(resolveGatewayMessageChannel("teams")).toBe("msteams"); + + registry.channels = [] as typeof registry.channels; + addExtensionHostChannelRegistration(registry, { + pluginId: "msteams", + plugin: msteamsPlugin, + source: "test", + }); + setActivePluginRegistry(registry, "message-channel-registry"); + + expect(resolveGatewayMessageChannel("teams")).toBe("msteams"); + }); }); diff --git a/src/utils/message-channel.ts b/src/utils/message-channel.ts index ed580960ad4..de4d9c3a71f 100644 --- a/src/utils/message-channel.ts +++ b/src/utils/message-channel.ts @@ -4,6 +4,8 @@ import { listChatChannelAliases, normalizeChatChannelId, } from "../channels/registry.js"; +import { listExtensionHostChannelRegistrations } from "../extension-host/contributions/runtime-registry.js"; +import { getActiveExtensionHostRegistry } from "../extension-host/static/active-registry.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES, @@ -12,7 +14,6 @@ import { normalizeGatewayClientMode, normalizeGatewayClientName, } from "../gateway/protocol/client-info.js"; -import { getActivePluginRegistry } from "../plugins/runtime.js"; export const INTERNAL_MESSAGE_CHANNEL = "webchat" as const; export type InternalMessageChannel = typeof INTERNAL_MESSAGE_CHANNEL; @@ -64,32 +65,36 @@ export function normalizeMessageChannel(raw?: string | null): string | undefined if (builtIn) { return builtIn; } - const registry = getActivePluginRegistry(); - const pluginMatch = registry?.channels.find((entry) => { - if (entry.plugin.id.toLowerCase() === normalized) { - return true; - } - return (entry.plugin.meta.aliases ?? []).some( - (alias) => alias.trim().toLowerCase() === normalized, - ); - }); + const registry = getActiveExtensionHostRegistry(); + const pluginMatch = registry + ? listExtensionHostChannelRegistrations(registry).find((entry) => { + if (entry.plugin.id.toLowerCase() === normalized) { + return true; + } + return (entry.plugin.meta.aliases ?? []).some( + (alias) => alias.trim().toLowerCase() === normalized, + ); + }) + : undefined; return pluginMatch?.plugin.id ?? normalized; } const listPluginChannelIds = (): string[] => { - const registry = getActivePluginRegistry(); + const registry = getActiveExtensionHostRegistry(); if (!registry) { return []; } - return registry.channels.map((entry) => entry.plugin.id); + return listExtensionHostChannelRegistrations(registry).map((entry) => entry.plugin.id); }; const listPluginChannelAliases = (): string[] => { - const registry = getActivePluginRegistry(); + const registry = getActiveExtensionHostRegistry(); if (!registry) { return []; } - return registry.channels.flatMap((entry) => entry.plugin.meta.aliases ?? []); + return listExtensionHostChannelRegistrations(registry).flatMap( + (entry) => entry.plugin.meta.aliases ?? [], + ); }; export const listDeliverableMessageChannels = (): ChannelId[] =>