Compare commits
103 Commits
main
...
codex/exte
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
499b8ad972 | ||
|
|
ef7ae4c6db | ||
|
|
1ea8fc0db6 | ||
|
|
67ca7cc1d8 | ||
|
|
955be3c50f | ||
|
|
7c172ad97c | ||
|
|
7fa8e9748d | ||
|
|
3aaa809d63 | ||
|
|
2e80ccce38 | ||
|
|
d608a6abaf | ||
|
|
653c84bd35 | ||
|
|
daad214a3a | ||
|
|
542d17de04 | ||
|
|
a1c5cbabff | ||
|
|
4cb3600549 | ||
|
|
d9fb2cbaf8 | ||
|
|
89e02b6a89 | ||
|
|
7ed6880d0e | ||
|
|
6eb1d803dc | ||
|
|
e2635aaf00 | ||
|
|
e7ad81179d | ||
|
|
1548728a28 | ||
|
|
88cf5ef972 | ||
|
|
7802655c9b | ||
|
|
bb8349fde1 | ||
|
|
bb4681fca6 | ||
|
|
e7e59a862d | ||
|
|
354ac4185e | ||
|
|
856c885057 | ||
|
|
c8d30dc144 | ||
|
|
aa47414c95 | ||
|
|
7b779f7b3f | ||
|
|
c03d3b33e3 | ||
|
|
62b0245655 | ||
|
|
3fd9f20f5b | ||
|
|
b33e0c566b | ||
|
|
161c167e14 | ||
|
|
975c9ec32b | ||
|
|
f996804369 | ||
|
|
2ad45cf8d2 | ||
|
|
9ded1afa13 | ||
|
|
492293addc | ||
|
|
04996c60aa | ||
|
|
0064a89415 | ||
|
|
8d0c487a45 | ||
|
|
adbf2a3ddc | ||
|
|
4c22681ddb | ||
|
|
d7f201fcd7 | ||
|
|
50f2293018 | ||
|
|
639178bd16 | ||
|
|
cacd8898b2 | ||
|
|
b195ce5275 | ||
|
|
8bfe6bb03f | ||
|
|
8f22067992 | ||
|
|
1c247ebaa7 | ||
|
|
abb6dd493e | ||
|
|
02b4cb2787 | ||
|
|
ce4c0fa44e | ||
|
|
3545e89fcc | ||
|
|
af5809283a | ||
|
|
e36d2136ea | ||
|
|
a8b3b1c008 | ||
|
|
d6d28037db | ||
|
|
652a95ad41 | ||
|
|
1af9ca694f | ||
|
|
977610fbde | ||
|
|
6fb5324f73 | ||
|
|
938bbe0343 | ||
|
|
7be38f29a4 | ||
|
|
49ae3b65a5 | ||
|
|
1817c6fcf6 | ||
|
|
376acd54ed | ||
|
|
b57c45ba9c | ||
|
|
7c1d0785c4 | ||
|
|
9cbdb075f3 | ||
|
|
ae6210d789 | ||
|
|
67b11c18c4 | ||
|
|
d9981847e1 | ||
|
|
5fc9f4d33c | ||
|
|
2777895047 | ||
|
|
90ef0afe14 | ||
|
|
5b26ce7252 | ||
|
|
5de733fa80 | ||
|
|
4ca027bf2b | ||
|
|
a54eda5051 | ||
|
|
d4c20c0841 | ||
|
|
b319d651f1 | ||
|
|
03656c8f26 | ||
|
|
25fc09885e | ||
|
|
f67ed5329a | ||
|
|
b9e4fb47a4 | ||
|
|
b721840568 | ||
|
|
1c88ecda3f | ||
|
|
ed46940ad6 | ||
|
|
8775207d81 | ||
|
|
b3e2c6d516 | ||
|
|
d17c4cca02 | ||
|
|
2358a2ac9c | ||
|
|
dc4fc0f8f8 | ||
|
|
bce8b67777 | ||
|
|
032b5dee20 | ||
|
|
bcb74de2ff | ||
|
|
fb9a0383d1 |
@ -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.
|
||||
File diff suppressed because it is too large
Load Diff
@ -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`
|
||||
@ -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.<id>.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.
|
||||
@ -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.
|
||||
File diff suppressed because it is too large
Load Diff
BIN
src/agents/.DS_Store
vendored
Normal file
BIN
src/agents/.DS_Store
vendored
Normal file
Binary file not shown.
11
src/agents/google-model-id.test.ts
Normal file
11
src/agents/google-model-id.test.ts
Normal file
@ -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");
|
||||
});
|
||||
});
|
||||
21
src/agents/google-model-id.ts
Normal file
21
src/agents/google-model-id.ts
Normal file
@ -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;
|
||||
}
|
||||
38
src/agents/model-ref.test.ts
Normal file
38
src/agents/model-ref.test.ts
Normal file
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
94
src/agents/model-ref.ts
Normal file
94
src/agents/model-ref.ts
Normal file
@ -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);
|
||||
}
|
||||
@ -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<T>(
|
||||
entries: Record<string, T> | 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/<name>" 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 };
|
||||
|
||||
BIN
src/agents/pi-embedded-runner/.DS_Store
vendored
Normal file
BIN
src/agents/pi-embedded-runner/.DS_Store
vendored
Normal file
Binary file not shown.
24
src/agents/provider-id.test.ts
Normal file
24
src/agents/provider-id.test.ts
Normal file
@ -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");
|
||||
});
|
||||
});
|
||||
38
src/agents/provider-id.ts
Normal file
38
src/agents/provider-id.ts
Normal file
@ -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;
|
||||
}
|
||||
@ -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<string, unknown>;
|
||||
skills?: string[];
|
||||
};
|
||||
staticMetadata: {
|
||||
configSchema: Record<string, unknown>;
|
||||
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,
|
||||
|
||||
@ -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<string>();
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
BIN
src/auto-reply/.DS_Store
vendored
Normal file
BIN
src/auto-reply/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
src/auto-reply/reply/.DS_Store
vendored
Normal file
BIN
src/auto-reply/reply/.DS_Store
vendored
Normal file
Binary file not shown.
@ -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,
|
||||
|
||||
@ -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<typeof listPluginCommands>,
|
||||
pluginCommands: ReturnType<typeof listExtensionHostPluginCommands>,
|
||||
): 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) {
|
||||
|
||||
BIN
src/channels/.DS_Store
vendored
Normal file
BIN
src/channels/.DS_Store
vendored
Normal file
Binary file not shown.
@ -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<string>();
|
||||
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;
|
||||
}
|
||||
|
||||
BIN
src/channels/plugins/.DS_Store
vendored
Normal file
BIN
src/channels/plugins/.DS_Store
vendored
Normal file
Binary file not shown.
@ -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<PluginOrigin, number> = {
|
||||
bundled: 3,
|
||||
};
|
||||
|
||||
type ExternalCatalogEntry = {
|
||||
name?: string;
|
||||
version?: string;
|
||||
description?: string;
|
||||
} & Partial<Record<ManifestKey, OpenClawPackageManifest>>;
|
||||
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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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"]);
|
||||
|
||||
@ -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<TValue> = (
|
||||
@ -13,7 +14,7 @@ export function createChannelRegistryLoader<TValue>(
|
||||
let lastRegistry: PluginRegistry | null = null;
|
||||
|
||||
return async (id: ChannelId): Promise<TValue | undefined> => {
|
||||
const registry = getActivePluginRegistry();
|
||||
const registry = getActiveExtensionHostRegistry();
|
||||
if (registry !== lastRegistry) {
|
||||
cache.clear();
|
||||
lastRegistry = registry;
|
||||
@ -22,7 +23,9 @@ export function createChannelRegistryLoader<TValue>(
|
||||
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;
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
BIN
src/cli/.DS_Store
vendored
Normal file
BIN
src/cli/.DS_Store
vendored
Normal file
Binary file not shown.
67
src/cli/plugin-registry.test.ts
Normal file
67
src/cli/plugin-registry.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
|
||||
BIN
src/commands/.DS_Store
vendored
Normal file
BIN
src/commands/.DS_Store
vendored
Normal file
Binary file not shown.
@ -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",
|
||||
|
||||
@ -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<typeof runExtensionHostProviderAuthMethod>[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<ApplyAuthChoiceResult | null> {
|
||||
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<ApplyAuthChoiceResult | null> {
|
||||
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);
|
||||
}
|
||||
|
||||
BIN
src/commands/onboard-non-interactive/.DS_Store
vendored
Normal file
BIN
src/commands/onboard-non-interactive/.DS_Store
vendored
Normal file
Binary file not shown.
@ -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<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
return pickExtensionHostAuthMethod(provider, rawMethod);
|
||||
}
|
||||
|
||||
export function mergeConfigPatch<T>(base: T, patch: unknown): T {
|
||||
if (!isPlainRecord(base) || !isPlainRecord(patch)) {
|
||||
return patch as T;
|
||||
}
|
||||
|
||||
const next: Record<string, unknown> = { ...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);
|
||||
}
|
||||
|
||||
BIN
src/config/.DS_Store
vendored
Normal file
BIN
src/config/.DS_Store
vendored
Normal file
Binary file not shown.
@ -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<ConfigSchemaResponse>
|
||||
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<ConfigSchemaResponse> {
|
||||
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,
|
||||
|
||||
@ -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<string, string> {
|
||||
function buildChannelToPluginIdMap(registry: ResolvedExtensionRegistry): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
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: [] };
|
||||
|
||||
47
src/config/resolved-extension-validation.test.ts
Normal file
47
src/config/resolved-extension-validation.test.ts
Normal file
@ -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",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
56
src/config/resolved-extension-validation.ts
Normal file
56
src/config/resolved-extension-validation.ts
Normal file
@ -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<string, unknown>;
|
||||
manifestPath: string;
|
||||
schemaCacheKey?: string;
|
||||
};
|
||||
|
||||
export type ResolvedExtensionValidationIndex = {
|
||||
knownIds: Set<string>;
|
||||
channelIds: Set<string>;
|
||||
lowercaseChannelIds: Set<string>;
|
||||
entries: ResolvedExtensionValidationEntry[];
|
||||
};
|
||||
|
||||
export function buildResolvedExtensionValidationIndex(
|
||||
registry: ResolvedExtensionRegistry,
|
||||
): ResolvedExtensionValidationIndex {
|
||||
const knownIds = new Set<string>();
|
||||
const channelIds = new Set<string>();
|
||||
const lowercaseChannelIds = new Set<string>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
@ -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<typeof loadPluginManifestRegistry>;
|
||||
registry: ReturnType<typeof loadResolvedExtensionRegistry>;
|
||||
validationIndex?: ReturnType<typeof buildResolvedExtensionValidationIndex>;
|
||||
knownIds?: Set<string>;
|
||||
normalizedPlugins?: ReturnType<typeof normalizePluginsConfig>;
|
||||
};
|
||||
@ -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<string> => {
|
||||
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<typeof buildResolvedExtensionValidationIndex> => {
|
||||
const info = ensureRegistry();
|
||||
if (!info.validationIndex) {
|
||||
info.validationIndex = buildResolvedExtensionValidationIndex(info.registry);
|
||||
}
|
||||
return info.validationIndex;
|
||||
};
|
||||
|
||||
const ensureNormalizedPlugins = (): ReturnType<typeof normalizePluginsConfig> => {
|
||||
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<string>();
|
||||
for (const record of registry.plugins) {
|
||||
for (const record of validationIndex.entries) {
|
||||
const pluginId = record.id;
|
||||
if (seenPlugins.has(pluginId)) {
|
||||
continue;
|
||||
|
||||
43
src/extension-host/activation.test.ts
Normal file
43
src/extension-host/activation.test.ts
Normal file
@ -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();
|
||||
});
|
||||
});
|
||||
8
src/extension-host/activation.ts
Normal file
8
src/extension-host/activation.ts
Normal file
@ -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);
|
||||
}
|
||||
77
src/extension-host/activation/loader-bootstrap.test.ts
Normal file
77
src/extension-host/activation/loader-bootstrap.test.ts
Normal file
@ -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<string>(),
|
||||
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");
|
||||
});
|
||||
});
|
||||
107
src/extension-host/activation/loader-bootstrap.ts
Normal file
107
src/extension-host/activation/loader-bootstrap.ts
Normal file
@ -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<string>;
|
||||
logger: PluginLogger;
|
||||
registry: PluginRegistry;
|
||||
discoverPlugins?: typeof discoverOpenClawPlugins;
|
||||
loadManifestRegistry?: typeof loadPluginManifestRegistry;
|
||||
pushDiagnostics?: typeof pushExtensionHostDiagnostics;
|
||||
resolveDiscoveryPolicy?: typeof resolveExtensionHostDiscoveryPolicy;
|
||||
buildProvenanceIndex?: typeof buildExtensionHostProvenanceIndex;
|
||||
compareDuplicateCandidateOrder?: typeof compareExtensionHostDuplicateCandidateOrder;
|
||||
}): {
|
||||
manifestByRoot: Map<string, PluginManifestRecord>;
|
||||
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,
|
||||
};
|
||||
}
|
||||
119
src/extension-host/activation/loader-cache.test.ts
Normal file
119
src/extension-host/activation/loader-cache.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
72
src/extension-host/activation/loader-cache.ts
Normal file
72
src/extension-host/activation/loader-cache.ts
Normal file
@ -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<string, PluginRegistry>();
|
||||
|
||||
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<string, PluginInstallRecord>;
|
||||
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,
|
||||
})}`;
|
||||
}
|
||||
49
src/extension-host/activation/loader-execution.test.ts
Normal file
49
src/extension-host/activation/loader-execution.test.ts
Normal file
@ -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<string>(),
|
||||
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");
|
||||
});
|
||||
});
|
||||
92
src/extension-host/activation/loader-execution.ts
Normal file
92
src/extension-host/activation/loader-execution.ts
Normal file
@ -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<string, unknown>;
|
||||
runtimeOptions?: CreatePluginRuntimeOptions;
|
||||
warningCache: Set<string>;
|
||||
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,
|
||||
};
|
||||
}
|
||||
108
src/extension-host/activation/loader-finalize.test.ts
Normal file
108
src/extension-host/activation/loader-finalize.test.ts
Normal file
@ -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"]);
|
||||
});
|
||||
});
|
||||
37
src/extension-host/activation/loader-finalize.ts
Normal file
37
src/extension-host/activation/loader-finalize.ts
Normal file
@ -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;
|
||||
}
|
||||
256
src/extension-host/activation/loader-flow.test.ts
Normal file
256
src/extension-host/activation/loader-flow.test.ts
Normal file
@ -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> = {},
|
||||
): PluginCandidate {
|
||||
return {
|
||||
source: entryPath,
|
||||
rootDir,
|
||||
packageDir: rootDir,
|
||||
origin: "workspace",
|
||||
workspaceDir: "/workspace",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createManifestRecord(
|
||||
rootDir: string,
|
||||
entryPath: string,
|
||||
overrides: Partial<PluginManifestRecord> = {},
|
||||
): 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");
|
||||
});
|
||||
});
|
||||
242
src/extension-host/activation/loader-flow.ts
Normal file
242
src/extension-host/activation/loader-flow.ts
Normal file
@ -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<string, PluginRecord["origin"]>;
|
||||
selectedMemoryPluginId: string | null;
|
||||
createApi: (
|
||||
record: PluginRecord,
|
||||
options: {
|
||||
config: OpenClawConfig;
|
||||
pluginConfig?: Record<string, unknown>;
|
||||
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,
|
||||
};
|
||||
}
|
||||
16
src/extension-host/activation/loader-host-state.test.ts
Normal file
16
src/extension-host/activation/loader-host-state.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
12
src/extension-host/activation/loader-host-state.ts
Normal file
12
src/extension-host/activation/loader-host-state.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { clearExtensionHostRegistryCache } from "./loader-cache.js";
|
||||
|
||||
const extensionHostDiscoveryWarningCache = new Set<string>();
|
||||
|
||||
export function getExtensionHostDiscoveryWarningCache(): Set<string> {
|
||||
return extensionHostDiscoveryWarningCache;
|
||||
}
|
||||
|
||||
export function clearExtensionHostLoaderHostState(): void {
|
||||
clearExtensionHostRegistryCache();
|
||||
extensionHostDiscoveryWarningCache.clear();
|
||||
}
|
||||
88
src/extension-host/activation/loader-import.test.ts
Normal file
88
src/extension-host/activation/loader-import.test.ts
Normal file
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
60
src/extension-host/activation/loader-import.ts
Normal file
60
src/extension-host/activation/loader-import.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
48
src/extension-host/activation/loader-module-loader.test.ts
Normal file
48
src/extension-host/activation/loader-module-loader.test.ts
Normal file
@ -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" });
|
||||
});
|
||||
});
|
||||
44
src/extension-host/activation/loader-module-loader.ts
Normal file
44
src/extension-host/activation/loader-module-loader.ts
Normal file
@ -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<JitiLoaderFactory>;
|
||||
|
||||
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;
|
||||
};
|
||||
}
|
||||
58
src/extension-host/activation/loader-orchestrator.ts
Normal file
58
src/extension-host/activation/loader-orchestrator.ts
Normal file
@ -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,
|
||||
});
|
||||
}
|
||||
46
src/extension-host/activation/loader-pipeline.test.ts
Normal file
46
src/extension-host/activation/loader-pipeline.test.ts
Normal file
@ -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<string>(),
|
||||
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);
|
||||
});
|
||||
});
|
||||
51
src/extension-host/activation/loader-pipeline.ts
Normal file
51
src/extension-host/activation/loader-pipeline.ts
Normal file
@ -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<string, GatewayRequestHandler>;
|
||||
runtimeOptions?: CreatePluginRuntimeOptions;
|
||||
warningCache: Set<string>;
|
||||
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<string, GatewayRequestHandler>,
|
||||
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,
|
||||
});
|
||||
}
|
||||
72
src/extension-host/activation/loader-preflight.test.ts
Normal file
72
src/extension-host/activation/loader-preflight.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
96
src/extension-host/activation/loader-preflight.ts
Normal file
96
src/extension-host/activation/loader-preflight.ts
Normal file
@ -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<typeof getCachedExtensionHostRegistry> extends infer T
|
||||
? Exclude<T, undefined>
|
||||
: never;
|
||||
};
|
||||
|
||||
export type ExtensionHostLoaderPreflightReady = {
|
||||
cacheHit: false;
|
||||
env: NodeJS.ProcessEnv;
|
||||
config: OpenClawConfig;
|
||||
logger: PluginLogger;
|
||||
validateOnly: boolean;
|
||||
normalizedConfig: ReturnType<typeof normalizePluginsConfig>;
|
||||
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,
|
||||
};
|
||||
}
|
||||
161
src/extension-host/activation/loader-records.test.ts
Normal file
161
src/extension-host/activation/loader-records.test.ts
Normal file
@ -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> = {}): PluginCandidate {
|
||||
return {
|
||||
source: "/plugins/demo/index.ts",
|
||||
rootDir: "/plugins/demo",
|
||||
packageDir: "/plugins/demo",
|
||||
origin: "workspace",
|
||||
workspaceDir: "/workspace",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createManifestRecord(overrides: Partial<PluginManifestRecord> = {}): 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<string, "workspace" | "global" | "bundled" | "config">([
|
||||
["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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
93
src/extension-host/activation/loader-records.ts
Normal file
93
src/extension-host/activation/loader-records.ts
Normal file
@ -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<string, PluginRecord["origin"]>;
|
||||
}): 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;
|
||||
}
|
||||
143
src/extension-host/activation/loader-register.test.ts
Normal file
143
src/extension-host/activation/loader-register.test.ts
Normal file
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
186
src/extension-host/activation/loader-register.ts
Normal file
186
src/extension-host/activation/loader-register.ts
Normal file
@ -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<OpenClawPluginDefinition["register"]>;
|
||||
pluginConfig?: Record<string, unknown>;
|
||||
memorySlotMatched: boolean;
|
||||
selectedMemoryPluginId: string | null;
|
||||
}
|
||||
| {
|
||||
kind: "error";
|
||||
message: string;
|
||||
memorySlotMatched: boolean;
|
||||
selectedMemoryPluginId: string | null;
|
||||
};
|
||||
|
||||
export function planExtensionHostLoadedPlugin(params: {
|
||||
record: PluginRecord;
|
||||
manifestRecord: Pick<PluginManifestRecord, "configSchema" | "schemaCacheKey">;
|
||||
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<OpenClawPluginDefinition["register"]>;
|
||||
createApi: (
|
||||
record: PluginRecord,
|
||||
options: {
|
||||
config: OpenClawConfig;
|
||||
pluginConfig?: Record<string, unknown>;
|
||||
hookPolicy?: { allowPromptInjection?: boolean };
|
||||
},
|
||||
) => OpenClawPluginApi;
|
||||
record: PluginRecord;
|
||||
config: OpenClawConfig;
|
||||
pluginConfig?: Record<string, unknown>;
|
||||
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 };
|
||||
}
|
||||
}
|
||||
36
src/extension-host/activation/loader-run.test.ts
Normal file
36
src/extension-host/activation/loader-run.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
48
src/extension-host/activation/loader-run.ts
Normal file
48
src/extension-host/activation/loader-run.ts
Normal file
@ -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<string, { rootDir: string }>;
|
||||
normalizedConfig: NormalizedPluginsConfig;
|
||||
rootConfig: OpenClawConfig;
|
||||
validateOnly: boolean;
|
||||
createApi: (
|
||||
record: PluginRecord,
|
||||
options: {
|
||||
config: OpenClawConfig;
|
||||
pluginConfig?: Record<string, unknown>;
|
||||
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);
|
||||
}
|
||||
33
src/extension-host/activation/loader-runtime-proxy.test.ts
Normal file
33
src/extension-host/activation/loader-runtime-proxy.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
39
src/extension-host/activation/loader-runtime-proxy.ts
Normal file
39
src/extension-host/activation/loader-runtime-proxy.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import type { PluginRuntime } from "../../plugins/runtime/types.js";
|
||||
|
||||
export function createExtensionHostLazyRuntime<TOptions>(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);
|
||||
},
|
||||
});
|
||||
}
|
||||
128
src/extension-host/activation/loader-runtime.test.ts
Normal file
128
src/extension-host/activation/loader-runtime.test.ts
Normal file
@ -0,0 +1,128 @@
|
||||
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("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,
|
||||
});
|
||||
});
|
||||
});
|
||||
125
src/extension-host/activation/loader-runtime.ts
Normal file
125
src/extension-host/activation/loader-runtime.ts
Normal file
@ -0,0 +1,125 @@
|
||||
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<string, unknown>;
|
||||
cacheKey?: string;
|
||||
value?: unknown;
|
||||
}): { ok: boolean; value?: Record<string, unknown>; errors?: string[] } {
|
||||
const schema = params.schema;
|
||||
if (!schema) {
|
||||
return { ok: true, value: params.value as Record<string, unknown> | 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<string, unknown> | undefined };
|
||||
}
|
||||
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<string, unknown>)
|
||||
? (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 }),
|
||||
};
|
||||
}
|
||||
164
src/extension-host/activation/loader-session.test.ts
Normal file
164
src/extension-host/activation/loader-session.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
102
src/extension-host/activation/loader-session.ts
Normal file
102
src/extension-host/activation/loader-session.ts
Normal file
@ -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<string, PluginRecord["origin"]>;
|
||||
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<string, unknown>;
|
||||
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,
|
||||
});
|
||||
}
|
||||
145
src/extension-host/activation/loader-state.test.ts
Normal file
145
src/extension-host/activation/loader-state.test.ts
Normal file
@ -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<string, "workspace" | "global" | "bundled" | "config">();
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
109
src/extension-host/activation/loader-state.ts
Normal file
109
src/extension-host/activation/loader-state.ts
Normal file
@ -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<PluginRecordLifecycleState>
|
||||
> = {
|
||||
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: <none> -> ${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<string, PluginRecord["origin"]>;
|
||||
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);
|
||||
}
|
||||
}
|
||||
66
src/extension-host/compat/hook-compat.test.ts
Normal file
66
src/extension-host/compat/hook-compat.test.ts
Normal file
@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
91
src/extension-host/compat/hook-compat.ts
Normal file
91
src/extension-host/compat/hook-compat.ts
Normal file
@ -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<K extends PluginHookName>(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,
|
||||
};
|
||||
}
|
||||
114
src/extension-host/compat/loader-compat.ts
Normal file
114
src/extension-host/compat/loader-compat.ts
Normal file
@ -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<string, string[]>();
|
||||
|
||||
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<string, unknown>;
|
||||
};
|
||||
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<string, string> {
|
||||
const aliasMap: Record<string, string> = {};
|
||||
for (const subpath of listPluginSdkExportedSubpaths()) {
|
||||
const resolved = resolvePluginSdkAliasFile({
|
||||
srcFile: `${subpath}.ts`,
|
||||
distFile: `${subpath}.js`,
|
||||
});
|
||||
if (resolved) {
|
||||
aliasMap[`openclaw/plugin-sdk/${subpath}`] = resolved;
|
||||
}
|
||||
}
|
||||
return aliasMap;
|
||||
}
|
||||
105
src/extension-host/compat/plugin-api.test.ts
Normal file
105
src/extension-host/compat/plugin-api.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
91
src/extension-host/compat/plugin-api.ts
Normal file
91
src/extension-host/compat/plugin-api.ts
Normal file
@ -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<string, unknown>;
|
||||
registerTool: (
|
||||
tool: OpenClawPluginToolFactory | AnyAgentTool,
|
||||
opts?: { name?: string; names?: string[]; optional?: boolean },
|
||||
) => void;
|
||||
registerHook: (
|
||||
events: string | string[],
|
||||
handler: Parameters<OpenClawPluginApi["registerHook"]>[1],
|
||||
opts?: Parameters<OpenClawPluginApi["registerHook"]>[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<OpenClawPluginApi["registerContextEngine"]>[1],
|
||||
) => void;
|
||||
on: <K extends PluginHookName>(
|
||||
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),
|
||||
};
|
||||
}
|
||||
92
src/extension-host/compat/plugin-registry-compat.test.ts
Normal file
92
src/extension-host/compat/plugin-registry-compat.test.ts
Normal file
@ -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();
|
||||
});
|
||||
});
|
||||
117
src/extension-host/compat/plugin-registry-compat.ts
Normal file
117
src/extension-host/compat/plugin-registry-compat.ts
Normal file
@ -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;
|
||||
}
|
||||
@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } 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"]),
|
||||
pushDiagnostic: (diag) => {
|
||||
registry.diagnostics.push(diag);
|
||||
},
|
||||
});
|
||||
|
||||
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(),
|
||||
pushDiagnostic: (diag) => {
|
||||
registry.diagnostics.push(diag);
|
||||
},
|
||||
});
|
||||
|
||||
actions.registerContextEngine(createRecord(), " ", (() => ({})) as never);
|
||||
|
||||
expect(registry.diagnostics).toContainEqual(
|
||||
expect.objectContaining({
|
||||
level: "error",
|
||||
pluginId: "demo",
|
||||
message: "context engine registration missing id",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
343
src/extension-host/compat/plugin-registry-registrations.ts
Normal file
343
src/extension-host/compat/plugin-registry-registrations.ts
Normal file
@ -0,0 +1,343 @@
|
||||
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,
|
||||
getExtensionHostGatewayHandlers,
|
||||
listExtensionHostHttpRoutes,
|
||||
} 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<string>;
|
||||
}) {
|
||||
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<typeof registerInternalHook>[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;
|
||||
}
|
||||
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,
|
||||
});
|
||||
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;
|
||||
}
|
||||
addExtensionServiceRegistration({
|
||||
registry,
|
||||
record,
|
||||
serviceId: result.serviceId,
|
||||
entry: result.entry,
|
||||
});
|
||||
};
|
||||
|
||||
const registerTypedHook = <K extends PluginHookName>(
|
||||
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<typeof registerLegacyContextEngine>[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,
|
||||
};
|
||||
}
|
||||
95
src/extension-host/compat/plugin-registry.test.ts
Normal file
95
src/extension-host/compat/plugin-registry.test.ts
Normal file
@ -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();
|
||||
});
|
||||
});
|
||||
127
src/extension-host/compat/plugin-registry.ts
Normal file
127
src/extension-host/compat/plugin-registry.ts
Normal file
@ -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<string, unknown>;
|
||||
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,
|
||||
};
|
||||
}
|
||||
97
src/extension-host/contributions/cli-lifecycle.test.ts
Normal file
97
src/extension-host/contributions/cli-lifecycle.test.ts
Normal file
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
47
src/extension-host/contributions/cli-lifecycle.ts
Normal file
47
src/extension-host/contributions/cli-lifecycle.ts
Normal file
@ -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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
93
src/extension-host/contributions/command-runtime.test.ts
Normal file
93
src/extension-host/contributions/command-runtime.test.ts
Normal file
@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
275
src/extension-host/contributions/command-runtime.ts
Normal file
275
src/extension-host/contributions/command-runtime.ts
Normal file
@ -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<string, RegisteredExtensionHostPluginCommand>();
|
||||
|
||||
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<PluginCommandResult> {
|
||||
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 || "<unknown>"}`,
|
||||
);
|
||||
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,
|
||||
}));
|
||||
}
|
||||
70
src/extension-host/contributions/gateway-methods.test.ts
Normal file
70
src/extension-host/contributions/gateway-methods.test.ts
Normal file
@ -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)");
|
||||
});
|
||||
});
|
||||
48
src/extension-host/contributions/gateway-methods.ts
Normal file
48
src/extension-host/contributions/gateway-methods.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
233
src/extension-host/contributions/provider-auth-flow.ts
Normal file
233
src/extension-host/contributions/provider-auth-flow.ts
Normal file
@ -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<ApplyAuthChoiceResult | null> {
|
||||
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<ApplyAuthChoiceResult | null> {
|
||||
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 };
|
||||
}
|
||||
106
src/extension-host/contributions/provider-auth.test.ts
Normal file
106
src/extension-host/contributions/provider-auth.test.ts
Normal file
@ -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<ProviderPlugin> & Pick<ProviderPlugin, "id" | "label">) {
|
||||
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"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
82
src/extension-host/contributions/provider-auth.ts
Normal file
82
src/extension-host/contributions/provider-auth.ts
Normal file
@ -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<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
export function mergeExtensionHostConfigPatch<T>(base: T, patch: unknown): T {
|
||||
if (!isPlainRecord(base) || !isPlainRecord(patch)) {
|
||||
return patch as T;
|
||||
}
|
||||
|
||||
const next: Record<string, unknown> = { ...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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user