feat: add Control UI plugin management (#103176)
* feat(ui): add plugin catalog management * feat(gateway): add plugins.uninstall and richer plugin catalog metadata Adds a plugins.uninstall gateway method (operator.admin, control-plane write) backed by a lock-guarded uninstallManagedPlugin that mirrors the CLI flow: config cleanup, install-record removal, managed file deletion, and registry refresh. Bundled plugins stay disable-only. Catalog entries now carry a manifest-derived category and a removable flag; ClawHub search results expose download counts and verification tiers. * feat(ui): redesign plugins page with inventory, store shelves, and cover art Rebuilds /settings/plugins around three tabs: Installed (category-grouped inventory with overview stats, state filters, uninstall for external plugins, and inline MCP server management through the shared config seam), Discover (featured/official shelves plus one-click MCP connectors and curated ClawHub searches), and ClawHub (search with download counts and verification badges). Every catalog entry renders bundled cover art or a deterministic gradient monogram tile - no more empty boxes. Artwork generated with Codex CLI, shipped as 512px WebP under ui/public/plugin-art. * chore(ui): regenerate locale bundles for plugins manager strings * docs: describe plugins manager tabs, uninstall, and MCP connectors * fix(plugins): human catalog labels and un-pinned hosted fallback ids listManagedPlugins now prefers manifest names over registry package-name backfill, falls back to channel catalog labels and blurbs, and stops pinning expectedPluginId when a hosted feed entry only exposes its package name (which rejected every legitimate install of that package). Found via live gateway testing against ClawHub. * fix(ui): send minimal RFC 7396 merge patches for MCP server edits config.patch merges rather than replaces, so key removal needs an explicit null; sending the full config back made MCP server removal a no-op. Found via live gateway testing. * fix(ui): write explicit MCP transports for URL servers The MCP runtime defaults URL-only servers to SSE, so streamable HTTP endpoints saved by the add form or connector templates would fail at connect time. Connector templates now declare their transport and the add form infers streamable-http unless the URL follows the /sse convention. Flagged by autoreview against the transport resolver. * test(ui): wait for deferred plugin requests before resolving in e2e * feat(ui): plugins detail view, action menus, and unified ClawHub search Reworks the plugins page from PR #103176 feedback: merges the ClawHub tab into Discover (typing searches ClawHub inline and appends a quiet From ClawHub section, with Browse ClawHub demoted to a header text link), makes every row and store card open a plugin detail overlay (hero art, primary enable/install action, metadata table), and replaces enable/disable switches with a state chip plus an overflow menu (Enable/Disable, Remove for external plugins, View details) matching the ChatGPT-store install+menu pattern. MCP rows use the same menu; refresh is now icon-only. * chore(ui): regenerate locale bundles for plugins UI iteration * feat(ui): vetted, grouped connector catalog for the plugins store Expands Connect your world to 28 connectors organized into use-case shelves (Work & productivity, Coding & infrastructure, Home & media, Everyday life). Every entry passed a three-stage subagent review: official-docs verification plus live endpoint probes for MCP servers, ClawHub result-quality and malware/typosquat screening for curated searches, and an adversarial pass that dynamically registered OAuth clients to prove one-click viability. That review removed Figma (registration allowlisted, 403) and Atlassian (OAuth issuer-mismatch bug upstream), downgraded GitHub to PAT-based setup (no dynamic client registration upstream), fixed Linear (/sse retired) and Home Assistant (/api/mcp, streamable HTTP) endpoints, retargeted poisoned or dead searches (youtube, finance, hue dropped; calendar -> google calendar; stocks replaces finance), and added Todoist, Airtable, Canva, Stripe, Context7, DeepWiki, Hugging Face one-click MCP servers plus Jira, PDF, transcription, Kubernetes, Reddit, maps, translation, and notes searches. Keyless servers get a ready-to-use success message; new cover art included. * chore(ui): regenerate locale bundles for connector groups * fix(plugins): suppress hosted catalog rows once their package is installed Hosted feed entries without a declared runtime id fall back to their package name as catalog id, which never matches the installed runtime id, so the Discover shelf kept offering an already-installed package. Installed package names now also suppress official rows. Flagged by autoreview. * fix(plugins): pin declared runtime ids and surface connector errors in place The runtime-id pin now keys off explicitly declared catalog ids (plugin, channel, or provider) instead of string-comparing against the package name, so declared ids that equal their package name stay enforced while entry-id fallbacks stay unpinned. Connector add failures on Discover now render on the triggering card instead of the Installed tab's MCP section. Both flagged by autoreview; regression tests included. * feat(ui): full inventory artwork, pulse header, and two-column plugin list Every bundled plugin now ships distinctive cover art (113 new Codex CLI illustrations; 172 total, ~2.1MB WebP), so inventory rows and detail views never fall back to monogram tiles. The four stat cards give way to a compact inventory pulse: a segmented enabled/disabled/issues meter whose legend and counts live inside the filter chips. Inventory, MCP, and search rows flow into two columns when the panel is wide enough. * chore(ui): regenerate locale bundles for pulse header * fix(ui): omit stdio args from the MCP server row target Stdio MCP args routinely carry tokens, and the inventory is visible to read-only operators; mirror the config page and show only the command. Flagged by autoreview; regression test included. * fix(merge): point crestodian setup at relocated plugin commit/refresh modules * fix(merge): add bootstrapToken to plugins page test gateway harness * fix(plugins): name catalog install-action branches so Swift emits the union * fix(ui): satisfy strict lint on plugins page form parsing and mocks * chore(build): regen docs map, raise plugin-sdk declaration budget for new protocol surface * fix(ui): type the plugins page patch mock with its real call signature
@@ -8343,6 +8343,116 @@ public struct PluginApprovalResolveParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct PluginCatalogClawHubInstall: Codable, Sendable {
|
||||
public let source: String
|
||||
public let packagename: String
|
||||
|
||||
public init(
|
||||
source: String,
|
||||
packagename: String)
|
||||
{
|
||||
self.source = source
|
||||
self.packagename = packagename
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case source
|
||||
case packagename = "packageName"
|
||||
}
|
||||
}
|
||||
|
||||
public struct PluginCatalogEntry: Codable, Sendable {
|
||||
public let id: String
|
||||
public let name: String
|
||||
public let packagename: String?
|
||||
public let description: String?
|
||||
public let version: String?
|
||||
public let kind: [String]?
|
||||
public let origin: String?
|
||||
public let installed: Bool
|
||||
public let enabled: Bool
|
||||
public let state: AnyCodable
|
||||
public let featured: Bool?
|
||||
public let order: Double?
|
||||
public let install: PluginCatalogInstallAction?
|
||||
public let error: String?
|
||||
public let category: String?
|
||||
public let removable: Bool?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
name: String,
|
||||
packagename: String? = nil,
|
||||
description: String? = nil,
|
||||
version: String? = nil,
|
||||
kind: [String]? = nil,
|
||||
origin: String? = nil,
|
||||
installed: Bool,
|
||||
enabled: Bool,
|
||||
state: AnyCodable,
|
||||
featured: Bool? = nil,
|
||||
order: Double? = nil,
|
||||
install: PluginCatalogInstallAction? = nil,
|
||||
error: String? = nil,
|
||||
category: String? = nil,
|
||||
removable: Bool? = nil)
|
||||
{
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.packagename = packagename
|
||||
self.description = description
|
||||
self.version = version
|
||||
self.kind = kind
|
||||
self.origin = origin
|
||||
self.installed = installed
|
||||
self.enabled = enabled
|
||||
self.state = state
|
||||
self.featured = featured
|
||||
self.order = order
|
||||
self.install = install
|
||||
self.error = error
|
||||
self.category = category
|
||||
self.removable = removable
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
case packagename = "packageName"
|
||||
case description
|
||||
case version
|
||||
case kind
|
||||
case origin
|
||||
case installed
|
||||
case enabled
|
||||
case state
|
||||
case featured
|
||||
case order
|
||||
case install
|
||||
case error
|
||||
case category
|
||||
case removable
|
||||
}
|
||||
}
|
||||
|
||||
public struct PluginCatalogOfficialInstall: Codable, Sendable {
|
||||
public let source: String
|
||||
public let pluginid: String
|
||||
|
||||
public init(
|
||||
source: String,
|
||||
pluginid: String)
|
||||
{
|
||||
self.source = source
|
||||
self.pluginid = pluginid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case source
|
||||
case pluginid = "pluginId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct PluginControlUiDescriptor: Codable, Sendable {
|
||||
public let id: String
|
||||
public let pluginid: String
|
||||
@@ -8389,6 +8499,156 @@ public struct PluginControlUiDescriptor: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct PluginSearchPackage: Codable, Sendable {
|
||||
public let name: String
|
||||
public let displayname: String
|
||||
public let family: AnyCodable
|
||||
public let channel: AnyCodable
|
||||
public let isofficial: Bool
|
||||
public let summary: String?
|
||||
public let latestversion: String?
|
||||
public let runtimeid: String?
|
||||
public let downloads: Double?
|
||||
public let verificationtier: String?
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
displayname: String,
|
||||
family: AnyCodable,
|
||||
channel: AnyCodable,
|
||||
isofficial: Bool,
|
||||
summary: String? = nil,
|
||||
latestversion: String? = nil,
|
||||
runtimeid: String? = nil,
|
||||
downloads: Double? = nil,
|
||||
verificationtier: String? = nil)
|
||||
{
|
||||
self.name = name
|
||||
self.displayname = displayname
|
||||
self.family = family
|
||||
self.channel = channel
|
||||
self.isofficial = isofficial
|
||||
self.summary = summary
|
||||
self.latestversion = latestversion
|
||||
self.runtimeid = runtimeid
|
||||
self.downloads = downloads
|
||||
self.verificationtier = verificationtier
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case displayname = "displayName"
|
||||
case family
|
||||
case channel
|
||||
case isofficial = "isOfficial"
|
||||
case summary
|
||||
case latestversion = "latestVersion"
|
||||
case runtimeid = "runtimeId"
|
||||
case downloads
|
||||
case verificationtier = "verificationTier"
|
||||
}
|
||||
}
|
||||
|
||||
public struct PluginSearchResultEntry: Codable, Sendable {
|
||||
public let score: Double
|
||||
public let package: PluginSearchPackage
|
||||
|
||||
public init(
|
||||
score: Double,
|
||||
package: PluginSearchPackage)
|
||||
{
|
||||
self.score = score
|
||||
self.package = package
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case score
|
||||
case package
|
||||
}
|
||||
}
|
||||
|
||||
public struct PluginsInstallResult: Codable, Sendable {
|
||||
public let ok: Bool
|
||||
public let plugin: PluginCatalogEntry
|
||||
public let restartrequired: Bool
|
||||
public let warnings: [String]?
|
||||
|
||||
public init(
|
||||
ok: Bool,
|
||||
plugin: PluginCatalogEntry,
|
||||
restartrequired: Bool,
|
||||
warnings: [String]? = nil)
|
||||
{
|
||||
self.ok = ok
|
||||
self.plugin = plugin
|
||||
self.restartrequired = restartrequired
|
||||
self.warnings = warnings
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ok
|
||||
case plugin
|
||||
case restartrequired = "restartRequired"
|
||||
case warnings
|
||||
}
|
||||
}
|
||||
|
||||
public struct PluginsListParams: Codable, Sendable {}
|
||||
|
||||
public struct PluginsListResult: Codable, Sendable {
|
||||
public let plugins: [PluginCatalogEntry]
|
||||
public let diagnostics: [AnyCodable]
|
||||
public let mutationallowed: Bool
|
||||
|
||||
public init(
|
||||
plugins: [PluginCatalogEntry],
|
||||
diagnostics: [AnyCodable],
|
||||
mutationallowed: Bool)
|
||||
{
|
||||
self.plugins = plugins
|
||||
self.diagnostics = diagnostics
|
||||
self.mutationallowed = mutationallowed
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case plugins
|
||||
case diagnostics
|
||||
case mutationallowed = "mutationAllowed"
|
||||
}
|
||||
}
|
||||
|
||||
public struct PluginsSearchParams: Codable, Sendable {
|
||||
public let query: String
|
||||
public let limit: Int?
|
||||
|
||||
public init(
|
||||
query: String,
|
||||
limit: Int? = nil)
|
||||
{
|
||||
self.query = query
|
||||
self.limit = limit
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case query
|
||||
case limit
|
||||
}
|
||||
}
|
||||
|
||||
public struct PluginsSearchResult: Codable, Sendable {
|
||||
public let results: [PluginSearchResultEntry]
|
||||
|
||||
public init(
|
||||
results: [PluginSearchResultEntry])
|
||||
{
|
||||
self.results = results
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case results
|
||||
}
|
||||
}
|
||||
|
||||
public struct PluginsSessionActionFailureResult: Codable, Sendable {
|
||||
public let ok: Bool
|
||||
public let error: String
|
||||
@@ -8539,6 +8799,50 @@ public struct PluginsSessionActionSuccessResult: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct PluginsSetEnabledParams: Codable, Sendable {
|
||||
public let pluginid: String
|
||||
public let enabled: Bool
|
||||
|
||||
public init(
|
||||
pluginid: String,
|
||||
enabled: Bool)
|
||||
{
|
||||
self.pluginid = pluginid
|
||||
self.enabled = enabled
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case pluginid = "pluginId"
|
||||
case enabled
|
||||
}
|
||||
}
|
||||
|
||||
public struct PluginsSetEnabledResult: Codable, Sendable {
|
||||
public let ok: Bool
|
||||
public let plugin: PluginCatalogEntry
|
||||
public let restartrequired: Bool
|
||||
public let warnings: [String]?
|
||||
|
||||
public init(
|
||||
ok: Bool,
|
||||
plugin: PluginCatalogEntry,
|
||||
restartrequired: Bool,
|
||||
warnings: [String]? = nil)
|
||||
{
|
||||
self.ok = ok
|
||||
self.plugin = plugin
|
||||
self.restartrequired = restartrequired
|
||||
self.warnings = warnings
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ok
|
||||
case plugin
|
||||
case restartrequired = "restartRequired"
|
||||
case warnings
|
||||
}
|
||||
}
|
||||
|
||||
public struct PluginsUiDescriptorsParams: Codable, Sendable {}
|
||||
|
||||
public struct PluginsUiDescriptorsResult: Codable, Sendable {
|
||||
@@ -8559,6 +8863,50 @@ public struct PluginsUiDescriptorsResult: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct PluginsUninstallParams: Codable, Sendable {
|
||||
public let pluginid: String
|
||||
|
||||
public init(
|
||||
pluginid: String)
|
||||
{
|
||||
self.pluginid = pluginid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case pluginid = "pluginId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct PluginsUninstallResult: Codable, Sendable {
|
||||
public let ok: Bool
|
||||
public let pluginid: String
|
||||
public let restartrequired: Bool
|
||||
public let removed: [String]
|
||||
public let warnings: [String]?
|
||||
|
||||
public init(
|
||||
ok: Bool,
|
||||
pluginid: String,
|
||||
restartrequired: Bool,
|
||||
removed: [String],
|
||||
warnings: [String]? = nil)
|
||||
{
|
||||
self.ok = ok
|
||||
self.pluginid = pluginid
|
||||
self.restartrequired = restartrequired
|
||||
self.removed = removed
|
||||
self.warnings = warnings
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ok
|
||||
case pluginid = "pluginId"
|
||||
case restartrequired = "restartRequired"
|
||||
case removed
|
||||
case warnings
|
||||
}
|
||||
}
|
||||
|
||||
public struct DevicePairListParams: Codable, Sendable {}
|
||||
|
||||
public struct DevicePairApproveParams: Codable, Sendable {
|
||||
@@ -9354,6 +9702,37 @@ public struct ShutdownEvent: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public enum PluginCatalogInstallAction: Codable, Sendable {
|
||||
case clawhub(PluginCatalogClawHubInstall)
|
||||
case official(PluginCatalogOfficialInstall)
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case discriminator = "source"
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let discriminator = try container.decode(String.self, forKey: .discriminator)
|
||||
switch discriminator {
|
||||
case "clawhub": self = try .clawhub(PluginCatalogClawHubInstall(from: decoder))
|
||||
case "official": self = try .official(PluginCatalogOfficialInstall(from: decoder))
|
||||
default:
|
||||
throw DecodingError.dataCorruptedError(
|
||||
forKey: .discriminator,
|
||||
in: container,
|
||||
debugDescription: "Unknown PluginCatalogInstallAction discriminator value"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
switch self {
|
||||
case .clawhub(let value): try value.encode(to: encoder)
|
||||
case .official(let value): try value.encode(to: encoder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum PluginsSessionActionResult: Codable, Sendable {
|
||||
case success(PluginsSessionActionSuccessResult)
|
||||
case failure(PluginsSessionActionFailureResult)
|
||||
|
||||
@@ -5585,6 +5585,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
|
||||
|
||||
- Route: /plugins/manage-plugins
|
||||
- Headings:
|
||||
- H2: Use the Control UI
|
||||
- H2: List and search plugins
|
||||
- H2: Enable and disable plugins
|
||||
- H2: Install plugins
|
||||
@@ -5603,6 +5604,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
|
||||
- H2: Minimal example
|
||||
- H2: Rich example
|
||||
- H2: Top-level field reference
|
||||
- H2: catalog reference
|
||||
- H2: Generation provider metadata reference
|
||||
- H2: Tool metadata reference
|
||||
- H2: providerAuthChoices reference
|
||||
@@ -9924,6 +9926,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
|
||||
- H2: Gateway host status
|
||||
- H2: Language support
|
||||
- H2: Appearance themes
|
||||
- H2: Manage plugins
|
||||
- H2: Sidebar navigation
|
||||
- H2: What it can do (today)
|
||||
- H2: MCP page
|
||||
|
||||
@@ -376,6 +376,15 @@ methods. Treat this as feature discovery, not a full enumeration of
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Plugin management">
|
||||
- `plugins.list` (`operator.read`) returns the installed plugin inventory plus locally curated official picks, diagnostics, and whether the current install mode allows mutations.
|
||||
- `plugins.search` (`operator.read`) searches installable ClawHub code-plugin and bundle-plugin families. Pass non-empty `query` and optional `limit` from 1 to 100.
|
||||
- `plugins.install` (`operator.admin`) installs either an official catalog entry with `{ source: "official", pluginId }` or a ClawHub package with `{ source: "clawhub", packageName, version?, acknowledgeClawHubRisk? }`. ClawHub installs preserve Gateway trust, integrity, and install-policy checks. Successful installs require a Gateway restart.
|
||||
- `plugins.setEnabled` (`operator.admin`) changes one installed plugin's enabled policy with `{ pluginId, enabled }`. The response includes the updated catalog entry, restart metadata, and any slot-selection warnings.
|
||||
- `plugins.uninstall` (`operator.admin`) removes one externally installed plugin with `{ pluginId }`: config references, the install record, and managed files. Bundled plugins cannot be uninstalled, only disabled. The response lists the removal actions and always requires a Gateway restart.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Messaging and logs">
|
||||
- `send` is the direct outbound-delivery RPC for channel/account/thread-targeted sends outside the chat runner.
|
||||
- `logs.tail` returns the configured gateway file-log tail with cursor/limit and max-byte controls.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
summary: "Quick examples for listing, installing, updating, inspecting, and uninstalling OpenClaw plugins"
|
||||
summary: "Manage OpenClaw plugins from the Control UI or CLI"
|
||||
read_when:
|
||||
- You want to browse, install, enable, or disable plugins in the Control UI
|
||||
- You want quick plugin list, install, update, inspect, or uninstall examples
|
||||
- You want to choose a plugin install source
|
||||
- You want the right reference for publishing plugin packages
|
||||
@@ -9,12 +10,53 @@ sidebarTitle: "Manage plugins"
|
||||
doc-schema-version: 1
|
||||
---
|
||||
|
||||
Common plugin management commands. For the full command contract, flags,
|
||||
source-selection rules, and edge cases, see [`openclaw plugins`](/cli/plugins).
|
||||
The Control UI covers the common discovery, install, enable, and disable
|
||||
workflow. The CLI adds update, uninstall, advanced configuration, and explicit
|
||||
install-source controls. For its full command contract, flags, source-selection
|
||||
rules, and edge cases, see [`openclaw plugins`](/cli/plugins).
|
||||
|
||||
Typical workflow: find a package, install it from ClawHub, npm, git, or a
|
||||
local path, let the managed Gateway auto-restart (or restart it manually),
|
||||
then verify the plugin's runtime registrations.
|
||||
Typical CLI workflow: find a package, install it from ClawHub, npm, git, or a
|
||||
local path, let the managed Gateway auto-restart (or restart it manually), then
|
||||
verify the plugin's runtime registrations.
|
||||
|
||||
## Use the Control UI
|
||||
|
||||
Open **Plugins** in the Control UI, or use `/settings/plugins` relative to the
|
||||
configured Control UI base path. For example, a base path of `/openclaw` uses
|
||||
`/openclaw/settings/plugins`. The page has two tabs:
|
||||
|
||||
- **Installed** shows the full local inventory grouped by category (channels,
|
||||
model providers, memory, tools). Each row opens a detail view; its overflow
|
||||
(`…`) menu enables or disables the plugin and, for externally installed
|
||||
plugins, offers **Remove**. The tab also lists the configured
|
||||
[MCP servers](/cli/mcp) with the same menu-driven enable, disable, and remove
|
||||
actions, editing `mcp.servers` in the Gateway configuration.
|
||||
- **Discover** is the store: featured plugins included with OpenClaw, official
|
||||
external plugins, and a curated connector shelf. Connector cards either add a
|
||||
hosted MCP server in one click (GitHub, Notion, Linear, Sentry,
|
||||
Home Assistant) or jump into a prefilled ClawHub search. Typing in the search
|
||||
box queries [ClawHub](https://clawhub.ai/plugins) inline and appends a **From
|
||||
ClawHub** section with download counts and source-verification badges.
|
||||
|
||||
Included plugins do not need a package install. Their menu action is **Enable**
|
||||
or **Disable**. Workboard, for example, is included with OpenClaw and disabled
|
||||
by default, so choose **Enable** to turn it on. Bundled plugins cannot be
|
||||
removed, only disabled.
|
||||
|
||||
Catalog and search access require `operator.read`. Install, enable, disable,
|
||||
remove, and MCP server changes require `operator.admin`. A ClawHub install is
|
||||
performed by the Gateway and preserves its trust, integrity, and plugin-install
|
||||
policy checks.
|
||||
|
||||
Installing or removing plugin code requires a Gateway restart. Enablement
|
||||
changes can be applied without a restart when the installed plugin and current
|
||||
Gateway runtime support it; otherwise the UI tells you a restart is required.
|
||||
OAuth-backed MCP connectors still need a one-time `openclaw mcp login <name>`
|
||||
from the CLI after they are added.
|
||||
|
||||
The Control UI does not install from arbitrary npm, git, or local-path sources,
|
||||
update plugins, or expose rich plugin configuration. Use the CLI workflows
|
||||
below for those operations.
|
||||
|
||||
## List and search plugins
|
||||
|
||||
|
||||
@@ -176,10 +176,29 @@ See [Plugins](/tools/plugin) for the full plugin system guide, and [Capability m
|
||||
| `skills` | No | `string[]` | Skill directories to load, relative to the plugin root. |
|
||||
| `name` | No | `string` | Human-readable plugin name. |
|
||||
| `description` | No | `string` | Short summary shown in plugin surfaces. |
|
||||
| `catalog` | No | `object` | Optional presentation hints for plugin catalog surfaces. This metadata does not install, enable, or grant trust to a plugin. |
|
||||
| `icon` | No | `string` | HTTPS image URL for marketplace/catalog cards. ClawHub accepts any valid `https://` URL and falls back to the default plugin icon when this is omitted or invalid. |
|
||||
| `version` | No | `string` | Informational plugin version. |
|
||||
| `uiHints` | No | `Record<string, object>` | UI labels, placeholders, and sensitivity hints for config fields. |
|
||||
|
||||
## catalog reference
|
||||
|
||||
`catalog` provides optional display hints to plugin browsers. Hosts may ignore these hints. They never install or enable the plugin, and they do not change its runtime behavior or trust level.
|
||||
|
||||
```json
|
||||
{
|
||||
"catalog": {
|
||||
"featured": true,
|
||||
"order": 10
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | What it means |
|
||||
| ---------- | --------- | -------------------------------------------------------------------------- |
|
||||
| `featured` | `boolean` | Whether catalog surfaces should feature this plugin. |
|
||||
| `order` | `number` | Ascending display hint among curated plugins; lower values appear earlier. |
|
||||
|
||||
## Generation provider metadata reference
|
||||
|
||||
The generation provider metadata fields describe static auth signals for providers declared in the matching `contracts.*GenerationProviders` list. OpenClaw reads these fields before provider runtime loads so core tools can decide whether a generation provider is available without importing every provider plugin.
|
||||
|
||||
@@ -19,18 +19,27 @@ other team project management systems.
|
||||
|
||||
Workboard is bundled but disabled by default:
|
||||
|
||||
1. Open **Plugins** in the Control UI, or use `/settings/plugins` relative to
|
||||
the configured Control UI base path. For example, a base path of `/openclaw`
|
||||
uses `/openclaw/settings/plugins`.
|
||||
2. Find **Workboard** and choose **Enable**. Because Workboard is included with
|
||||
OpenClaw, it does not need an **Install** action.
|
||||
3. If the UI reports that a restart is required, restart the Gateway.
|
||||
|
||||
The Workboard tab appears in the dashboard nav after the plugin runtime loads.
|
||||
While it is disabled, the tab stays hidden from navigation. Opening the
|
||||
`/workboard` route directly while the plugin is disabled or blocked by
|
||||
`plugins.allow`/`plugins.deny` shows a plugin-unavailable state instead of card
|
||||
data.
|
||||
|
||||
The equivalent CLI workflow is:
|
||||
|
||||
```bash
|
||||
openclaw plugins enable workboard
|
||||
openclaw gateway restart
|
||||
openclaw dashboard
|
||||
```
|
||||
|
||||
The Workboard tab appears in the dashboard nav once the plugin is enabled;
|
||||
while it is disabled the tab stays hidden from navigation. Opening the
|
||||
`/workboard` route directly while the plugin is disabled or blocked by
|
||||
`plugins.allow`/`plugins.deny` shows a plugin-unavailable state instead of
|
||||
card data.
|
||||
|
||||
## Configuration
|
||||
|
||||
Workboard has no plugin-specific config. Enable/disable it with the standard
|
||||
|
||||
@@ -115,6 +115,45 @@ Imported themes are stored only in the current browser profile; they are not wri
|
||||
|
||||
Appearance also has a browser-local Text size setting, stored with the rest of Control UI preferences. It applies to chat text, composer text, tool cards, and chat sidebars, and keeps text inputs at least 16px so mobile Safari does not auto-zoom on focus.
|
||||
|
||||
## Manage plugins
|
||||
|
||||
Open **Plugins** in the sidebar, or use `/settings/plugins` relative to the
|
||||
configured Control UI base path, to browse and manage plugins without leaving
|
||||
the Control UI. For example, a base path of `/openclaw` uses
|
||||
`/openclaw/settings/plugins`. The page is always available, even when every
|
||||
optional plugin is disabled.
|
||||
|
||||
The **Installed** tab shows the full local inventory grouped by category, with
|
||||
overview counts. Each row opens a detail view; its overflow (`…`) menu enables
|
||||
or disables the plugin and offers **Remove** for externally installed plugins.
|
||||
It also lists configured [MCP servers](/cli/mcp) and supports adding, disabling,
|
||||
and removing them inline. The **Discover** tab is the store: featured plugins
|
||||
included with OpenClaw, official external plugins, and one-click MCP connectors
|
||||
for popular services. Typing in the search box queries
|
||||
[ClawHub](https://clawhub.ai/plugins) inline and appends a **From ClawHub**
|
||||
section with download counts and source-verification badges.
|
||||
|
||||
Included plugins are already present on the Gateway and show **Enable** or
|
||||
**Disable** instead of **Install**. For example, Workboard is included with
|
||||
OpenClaw but disabled by default, so its action is **Enable**. Bundled plugins
|
||||
cannot be removed, only disabled.
|
||||
|
||||
Reading the catalog and searching ClawHub require `operator.read`. Installing,
|
||||
enabling, disabling, or removing a plugin and changing MCP servers require
|
||||
`operator.admin`; those actions stay disabled for read-only operators.
|
||||
|
||||
ClawHub installs run through the Gateway and keep the same trust, integrity,
|
||||
and plugin-install policy checks as other Gateway-mediated installs. Installing
|
||||
or removing plugin code requires a Gateway restart. Enabling or disabling an
|
||||
installed plugin can apply without a restart when the plugin and current
|
||||
Gateway runtime support it; otherwise the UI reports that a restart is
|
||||
required. OAuth-backed MCP connectors need a one-time
|
||||
`openclaw mcp login <name>` from the CLI after they are added.
|
||||
|
||||
The page intentionally focuses on inventory, discovery, install, enablement,
|
||||
and removal. Use [`openclaw plugins`](/cli/plugins) for arbitrary npm, git, or
|
||||
local-path sources, updates, and advanced plugin configuration.
|
||||
|
||||
## Sidebar navigation
|
||||
|
||||
The sidebar pins navigation above a scrollable session list split into **Pinned**, one section per custom group (the session `category`), and **Ungrouped** for the rest. Every active session loaded for the selected agent stays visible inline; opening a session moves the selection highlight without reordering the rows. Sessions with new activity since they were last read show an unread dot, and opening one marks it read. Each session row has a context menu (kebab button or right-click) with Pin/Unpin, Mark as unread/read, Rename, Fork, Move to group (including New group and Remove from group), Archive, and Delete; touch layouts keep the direct pin and menu controls visible. Drag a session onto a custom group or **Ungrouped** to move it. Group headers can be collapsed, expanded, or dragged to reorder them; the collapsed state and custom order are stored in the current browser profile. Group headers also have a menu (kebab button or right-click) with Rename group, New group, and Delete group; renaming or deleting a group updates every member session, including archived ones, and deleting a group keeps its sessions and moves them back to Ungrouped. Groups created from the header start empty and stay visible as move targets. The sort control in the session list header also has a Group by toggle: Custom groups (default) or None for one flat list (Pinned stays separate); the choice is stored in the current browser profile. Multi-agent setups show a compact scope control in the session-list header. **Overview** is the only destination pinned by default; expand **More** to reach every other destination. Select **Edit pinned items** under More, or right-click the navigation area, to pin or unpin destinations and restore the defaults. The pinned set and More expansion state are stored in the current browser profile and survive reloads.
|
||||
@@ -145,9 +184,10 @@ A **Search** field at the top of the sidebar opens the command palette (⌘K). T
|
||||
- Dreams: dreaming status, enable/disable toggle, and Dream Diary reader (`doctor.memory.status`, `doctor.memory.dreamDiary`, `config.patch`).
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="Cron, tasks, skills, nodes, exec approvals">
|
||||
<Accordion title="Cron, tasks, plugins, skills, nodes, exec approvals">
|
||||
- Cron jobs: list/add/edit/run/enable/disable plus run history (`cron.*`).
|
||||
- Tasks: live active and recent background task ledger with linked sessions and cancellation (`tasks.*`).
|
||||
- Plugins: browse the installed inventory and curated store, search ClawHub, install and remove plugin code, and enable or disable installed plugins (`plugins.*`); MCP server rows edit `mcp.servers` through the config methods.
|
||||
- Skills: status, enable/disable, install, API key updates (`skills.*`).
|
||||
- Nodes: one **Nodes & devices** inventory that joins paired device records with the node catalog (`node.list`, `device.pair.list`) — one entry per machine with roles, live link status, tokens, and capabilities. Duplicate pairings of the same client collapse into an expandable group, and **Clean up N stale** bulk-removes superseded pairings that are offline and were auto-approved (silent local or trusted-CIDR), so affected clients re-pair without user action. Entries can be removed (`node.pair.remove`, `device.pair.remove`), device pairing and node re-approvals handled inline (`device.pair.*`, `node.pair.approve`/`reject`), and mobile setup codes created from the same card.
|
||||
- Exec approvals: edit gateway or node allowlists and ask policy for `exec host=gateway/node` (`exec.approvals.*`).
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
},
|
||||
"name": "Diffs",
|
||||
"description": "OpenClaw read-only diff viewer plugin and file renderer for agents.",
|
||||
"catalog": { "featured": true, "order": 40 },
|
||||
"contracts": {
|
||||
"tools": ["diffs"]
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
},
|
||||
"name": "Lobster",
|
||||
"description": "Lobster workflow tool plugin for typed pipelines and resumable approvals.",
|
||||
"catalog": { "featured": true, "order": 50 },
|
||||
"contracts": {
|
||||
"tools": ["lobster"]
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "memory-lancedb",
|
||||
"name": "Memory LanceDB",
|
||||
"description": "OpenClaw LanceDB-backed long-term memory plugin with auto-recall, auto-capture, and vector search.",
|
||||
"catalog": { "featured": true, "order": 70 },
|
||||
"commandAliases": [{ "name": "ltm" }],
|
||||
"activation": {
|
||||
"onStartup": false,
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
},
|
||||
"name": "Memory Wiki",
|
||||
"description": "Persistent wiki compiler and Obsidian-friendly knowledge vault for OpenClaw.",
|
||||
"catalog": { "featured": true, "order": 30 },
|
||||
"contracts": {
|
||||
"tools": ["wiki_apply", "wiki_get", "wiki_lint", "wiki_search", "wiki_status"]
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
},
|
||||
"name": "OpenProse",
|
||||
"description": "OpenProse VM skill pack with a /prose slash command.",
|
||||
"catalog": { "featured": true, "order": 20 },
|
||||
"skills": ["./skills"],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
},
|
||||
"name": "tokenjuice",
|
||||
"description": "Compacts exec and bash tool results with tokenjuice reducers.",
|
||||
"catalog": { "featured": true, "order": 60 },
|
||||
"contracts": {
|
||||
"agentToolResultMiddleware": ["openclaw", "codex"]
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
},
|
||||
"name": "Workboard",
|
||||
"description": "Dashboard workboard for agent-owned issues and sessions.",
|
||||
"catalog": { "featured": true, "order": 10 },
|
||||
"contracts": {
|
||||
"tools": [
|
||||
"workboard_list",
|
||||
|
||||
@@ -246,14 +246,39 @@ import {
|
||||
PluginApprovalRequestParamsSchema,
|
||||
type PluginApprovalResolveParams,
|
||||
PluginApprovalResolveParamsSchema,
|
||||
type PluginCatalogEntry,
|
||||
PluginCatalogEntrySchema,
|
||||
PluginCatalogInstallActionSchema,
|
||||
PluginSearchPackageSchema,
|
||||
PluginSearchResultEntrySchema,
|
||||
type PluginsInstallParams,
|
||||
type PluginsInstallResult,
|
||||
PluginsInstallParamsSchema,
|
||||
PluginsInstallResultSchema,
|
||||
type PluginsListParams,
|
||||
type PluginsListResult,
|
||||
PluginsListParamsSchema,
|
||||
PluginsListResultSchema,
|
||||
type PluginsSearchParams,
|
||||
type PluginsSearchResult,
|
||||
PluginsSearchParamsSchema,
|
||||
PluginsSearchResultSchema,
|
||||
type PluginsSessionActionParams,
|
||||
type PluginsSessionActionResult,
|
||||
PluginsSessionActionParamsSchema,
|
||||
PluginsSessionActionResultSchema,
|
||||
type PluginsSetEnabledParams,
|
||||
type PluginsSetEnabledResult,
|
||||
PluginsSetEnabledParamsSchema,
|
||||
PluginsSetEnabledResultSchema,
|
||||
type PluginsUiDescriptorsParams,
|
||||
type PluginsUiDescriptorsResult,
|
||||
PluginsUiDescriptorsParamsSchema,
|
||||
PluginsUiDescriptorsResultSchema,
|
||||
type PluginsUninstallParams,
|
||||
type PluginsUninstallResult,
|
||||
PluginsUninstallParamsSchema,
|
||||
PluginsUninstallResultSchema,
|
||||
ErrorCodes,
|
||||
type EnvironmentSummary,
|
||||
EnvironmentSummarySchema,
|
||||
@@ -1066,6 +1091,30 @@ export const validatePluginApprovalRequestParams = lazyCompile<PluginApprovalReq
|
||||
export const validatePluginApprovalResolveParams = lazyCompile<PluginApprovalResolveParams>(
|
||||
PluginApprovalResolveParamsSchema,
|
||||
);
|
||||
export const validatePluginsListParams = lazyCompile<PluginsListParams>(PluginsListParamsSchema);
|
||||
export const validatePluginsListResult = lazyCompile<PluginsListResult>(PluginsListResultSchema);
|
||||
export const validatePluginsSearchParams =
|
||||
lazyCompile<PluginsSearchParams>(PluginsSearchParamsSchema);
|
||||
export const validatePluginsSearchResult =
|
||||
lazyCompile<PluginsSearchResult>(PluginsSearchResultSchema);
|
||||
export const validatePluginsInstallParams = lazyCompile<PluginsInstallParams>(
|
||||
PluginsInstallParamsSchema,
|
||||
);
|
||||
export const validatePluginsInstallResult = lazyCompile<PluginsInstallResult>(
|
||||
PluginsInstallResultSchema,
|
||||
);
|
||||
export const validatePluginsSetEnabledParams = lazyCompile<PluginsSetEnabledParams>(
|
||||
PluginsSetEnabledParamsSchema,
|
||||
);
|
||||
export const validatePluginsSetEnabledResult = lazyCompile<PluginsSetEnabledResult>(
|
||||
PluginsSetEnabledResultSchema,
|
||||
);
|
||||
export const validatePluginsUninstallParams = lazyCompile<PluginsUninstallParams>(
|
||||
PluginsUninstallParamsSchema,
|
||||
);
|
||||
export const validatePluginsUninstallResult = lazyCompile<PluginsUninstallResult>(
|
||||
PluginsUninstallResultSchema,
|
||||
);
|
||||
export const validatePluginsUiDescriptorsParams = lazyCompile<PluginsUiDescriptorsParams>(
|
||||
PluginsUiDescriptorsParamsSchema,
|
||||
);
|
||||
@@ -1369,10 +1418,24 @@ export {
|
||||
AgentsListResultSchema,
|
||||
CommandsListParamsSchema,
|
||||
CommandsListResultSchema,
|
||||
PluginCatalogEntrySchema,
|
||||
PluginCatalogInstallActionSchema,
|
||||
PluginSearchPackageSchema,
|
||||
PluginSearchResultEntrySchema,
|
||||
PluginsInstallParamsSchema,
|
||||
PluginsInstallResultSchema,
|
||||
PluginsListParamsSchema,
|
||||
PluginsListResultSchema,
|
||||
PluginsSearchParamsSchema,
|
||||
PluginsSearchResultSchema,
|
||||
PluginsSessionActionParamsSchema,
|
||||
PluginsSessionActionResultSchema,
|
||||
PluginsSetEnabledParamsSchema,
|
||||
PluginsSetEnabledResultSchema,
|
||||
PluginsUiDescriptorsParamsSchema,
|
||||
PluginsUiDescriptorsResultSchema,
|
||||
PluginsUninstallParamsSchema,
|
||||
PluginsUninstallResultSchema,
|
||||
ModelsListParamsSchema,
|
||||
SkillsStatusParamsSchema,
|
||||
ToolsCatalogParamsSchema,
|
||||
@@ -1590,8 +1653,19 @@ export type {
|
||||
CommandsListParams,
|
||||
CommandsListResult,
|
||||
CommandEntry,
|
||||
PluginCatalogEntry,
|
||||
PluginsInstallParams,
|
||||
PluginsInstallResult,
|
||||
PluginsListParams,
|
||||
PluginsListResult,
|
||||
PluginsSearchParams,
|
||||
PluginsSearchResult,
|
||||
PluginsSessionActionParams,
|
||||
PluginsSessionActionResult,
|
||||
PluginsSetEnabledParams,
|
||||
PluginsSetEnabledResult,
|
||||
PluginsUninstallParams,
|
||||
PluginsUninstallResult,
|
||||
SkillsStatusParams,
|
||||
ToolsCatalogParams,
|
||||
ToolsCatalogResult,
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
validatePluginsInstallParams,
|
||||
validatePluginsInstallResult,
|
||||
validatePluginsListParams,
|
||||
validatePluginsListResult,
|
||||
validatePluginsSearchParams,
|
||||
validatePluginsSearchResult,
|
||||
validatePluginsSetEnabledParams,
|
||||
validatePluginsSetEnabledResult,
|
||||
validatePluginsUninstallParams,
|
||||
validatePluginsUninstallResult,
|
||||
} from "./index.js";
|
||||
|
||||
const installedPlugin = {
|
||||
id: "workboard",
|
||||
name: "Workboard",
|
||||
packageName: "@openclaw/workboard",
|
||||
description: "Coordinate work across agents",
|
||||
version: "1.0.0",
|
||||
kind: ["tool"],
|
||||
origin: "bundled",
|
||||
installed: true,
|
||||
enabled: false,
|
||||
state: "disabled",
|
||||
featured: true,
|
||||
order: 10,
|
||||
install: { source: "official", pluginId: "workboard" },
|
||||
category: "tool",
|
||||
removable: false,
|
||||
} as const;
|
||||
|
||||
describe("plugin lifecycle protocol validators", () => {
|
||||
it("accepts cold catalog payloads and rejects runtime-only states", () => {
|
||||
expect(validatePluginsListParams({})).toBe(true);
|
||||
expect(validatePluginsListParams({ unexpected: true })).toBe(false);
|
||||
expect(
|
||||
validatePluginsListResult({
|
||||
plugins: [installedPlugin],
|
||||
diagnostics: [],
|
||||
mutationAllowed: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
validatePluginsListResult({
|
||||
plugins: [{ ...installedPlugin, state: "loaded" }],
|
||||
diagnostics: [],
|
||||
mutationAllowed: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("validates bounded plugin search requests and projected results", () => {
|
||||
expect(validatePluginsSearchParams({ query: "memory", limit: 20 })).toBe(true);
|
||||
expect(validatePluginsSearchParams({ query: "memory", limit: 101 })).toBe(false);
|
||||
expect(
|
||||
validatePluginsSearchResult({
|
||||
results: [
|
||||
{
|
||||
score: 0.95,
|
||||
package: {
|
||||
name: "memory-plus",
|
||||
displayName: "Memory Plus",
|
||||
family: "code-plugin",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
summary: "Long-term memory tools",
|
||||
latestVersion: "2.1.0",
|
||||
runtimeId: "memory-plus",
|
||||
downloads: 1420,
|
||||
verificationTier: "source-linked",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps official and ClawHub install requests distinct", () => {
|
||||
expect(
|
||||
validatePluginsInstallParams({
|
||||
source: "clawhub",
|
||||
packageName: "memory-plus",
|
||||
version: "2.1.0",
|
||||
acknowledgeClawHubRisk: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(validatePluginsInstallParams({ source: "official", pluginId: "workboard" })).toBe(true);
|
||||
expect(
|
||||
validatePluginsInstallParams({
|
||||
source: "official",
|
||||
pluginId: "workboard",
|
||||
packageName: "memory-plus",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
validatePluginsInstallResult({
|
||||
ok: true,
|
||||
plugin: { ...installedPlugin, enabled: true, state: "enabled" },
|
||||
restartRequired: true,
|
||||
warnings: ["Restart the gateway to load this plugin."],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("validates uninstall requests and removal summaries", () => {
|
||||
expect(validatePluginsUninstallParams({ pluginId: "memory-plus" })).toBe(true);
|
||||
expect(validatePluginsUninstallParams({ pluginId: "" })).toBe(false);
|
||||
expect(validatePluginsUninstallParams({})).toBe(false);
|
||||
expect(
|
||||
validatePluginsUninstallResult({
|
||||
ok: true,
|
||||
pluginId: "memory-plus",
|
||||
restartRequired: true,
|
||||
removed: ["config entry", "install record", "directory"],
|
||||
warnings: ["npm prune skipped"],
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
validatePluginsUninstallResult({
|
||||
ok: true,
|
||||
pluginId: "memory-plus",
|
||||
restartRequired: false,
|
||||
removed: [],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("validates enablement mutations and dynamic restart metadata", () => {
|
||||
expect(validatePluginsSetEnabledParams({ pluginId: "workboard", enabled: true })).toBe(true);
|
||||
expect(validatePluginsSetEnabledParams({ pluginId: "workboard", enabled: "yes" })).toBe(false);
|
||||
expect(
|
||||
validatePluginsSetEnabledResult({
|
||||
ok: true,
|
||||
plugin: { ...installedPlugin, enabled: true, state: "enabled" },
|
||||
restartRequired: false,
|
||||
warnings: ['Exclusive slot "memory" switched to "memory-plus".'],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
// Gateway Protocol schema module defines protocol validation shapes.
|
||||
import { Type } from "typebox";
|
||||
import { Type, type Static } from "typebox";
|
||||
import { NonEmptyString } from "./primitives.js";
|
||||
|
||||
/**
|
||||
@@ -82,3 +82,199 @@ export const PluginsSessionActionResultSchema = Type.Union([
|
||||
PluginsSessionActionSuccessResultSchema,
|
||||
PluginsSessionActionFailureResultSchema,
|
||||
]);
|
||||
|
||||
/** ClawHub-backed install action for one catalog entry. */
|
||||
export const PluginCatalogClawHubInstallSchema = Type.Object(
|
||||
{
|
||||
source: Type.Literal("clawhub"),
|
||||
packageName: NonEmptyString,
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
/** Official-catalog install action for one catalog entry. */
|
||||
export const PluginCatalogOfficialInstallSchema = Type.Object(
|
||||
{
|
||||
source: Type.Literal("official"),
|
||||
pluginId: NonEmptyString,
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
// Branches stay named schemas: the Swift generator only emits discriminated
|
||||
// unions whose branches resolve to registered types (see PluginsSessionActionResult).
|
||||
export const PluginCatalogInstallActionSchema = Type.Union([
|
||||
PluginCatalogClawHubInstallSchema,
|
||||
PluginCatalogOfficialInstallSchema,
|
||||
]);
|
||||
|
||||
/** Cold control-plane representation of an installed or available plugin. */
|
||||
export const PluginCatalogEntrySchema = Type.Object(
|
||||
{
|
||||
id: NonEmptyString,
|
||||
name: NonEmptyString,
|
||||
packageName: Type.Optional(NonEmptyString),
|
||||
description: Type.Optional(Type.String()),
|
||||
version: Type.Optional(NonEmptyString),
|
||||
kind: Type.Optional(Type.Array(NonEmptyString)),
|
||||
origin: Type.Optional(NonEmptyString),
|
||||
installed: Type.Boolean(),
|
||||
enabled: Type.Boolean(),
|
||||
state: Type.Union([
|
||||
Type.Literal("enabled"),
|
||||
Type.Literal("disabled"),
|
||||
Type.Literal("not-installed"),
|
||||
Type.Literal("error"),
|
||||
]),
|
||||
featured: Type.Optional(Type.Boolean()),
|
||||
order: Type.Optional(Type.Number()),
|
||||
install: Type.Optional(PluginCatalogInstallActionSchema),
|
||||
error: Type.Optional(Type.String()),
|
||||
/** Coarse manifest-derived grouping (channel, provider, memory, ...) for catalog UIs. */
|
||||
category: Type.Optional(NonEmptyString),
|
||||
/** True when the plugin has an install record and can be removed via plugins.uninstall. */
|
||||
removable: Type.Optional(Type.Boolean()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
/** Empty request payload for the cold plugin catalog. */
|
||||
export const PluginsListParamsSchema = Type.Object({}, { additionalProperties: false });
|
||||
|
||||
/** Installed and curated plugin catalog visible to the current gateway client. */
|
||||
export const PluginsListResultSchema = Type.Object(
|
||||
{
|
||||
plugins: Type.Array(PluginCatalogEntrySchema),
|
||||
diagnostics: Type.Array(Type.Unknown()),
|
||||
mutationAllowed: Type.Boolean(),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
/** Request payload for searching installable ClawHub plugin families. */
|
||||
export const PluginsSearchParamsSchema = Type.Object(
|
||||
{
|
||||
query: NonEmptyString,
|
||||
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
/** ClawHub package fields exposed by plugin search. */
|
||||
export const PluginSearchPackageSchema = Type.Object(
|
||||
{
|
||||
name: NonEmptyString,
|
||||
displayName: NonEmptyString,
|
||||
family: Type.Union([Type.Literal("code-plugin"), Type.Literal("bundle-plugin")]),
|
||||
channel: Type.Union([
|
||||
Type.Literal("official"),
|
||||
Type.Literal("community"),
|
||||
Type.Literal("private"),
|
||||
]),
|
||||
isOfficial: Type.Boolean(),
|
||||
summary: Type.Optional(Type.String()),
|
||||
latestVersion: Type.Optional(NonEmptyString),
|
||||
runtimeId: Type.Optional(NonEmptyString),
|
||||
downloads: Type.Optional(Type.Number({ minimum: 0 })),
|
||||
verificationTier: Type.Optional(NonEmptyString),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
/** Ranked ClawHub plugin search hit. */
|
||||
export const PluginSearchResultEntrySchema = Type.Object(
|
||||
{
|
||||
score: Type.Number(),
|
||||
package: PluginSearchPackageSchema,
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
/** Ranked installable plugin packages matching the query. */
|
||||
export const PluginsSearchResultSchema = Type.Object(
|
||||
{ results: Type.Array(PluginSearchResultEntrySchema) },
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
/** Trusted official-catalog or acknowledged ClawHub install request. */
|
||||
export const PluginsInstallParamsSchema = Type.Union([
|
||||
Type.Object(
|
||||
{
|
||||
source: Type.Literal("clawhub"),
|
||||
packageName: NonEmptyString,
|
||||
version: Type.Optional(NonEmptyString),
|
||||
acknowledgeClawHubRisk: Type.Optional(Type.Boolean()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
Type.Object(
|
||||
{
|
||||
source: Type.Literal("official"),
|
||||
pluginId: NonEmptyString,
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
]);
|
||||
|
||||
/** Successful plugin installation result. */
|
||||
export const PluginsInstallResultSchema = Type.Object(
|
||||
{
|
||||
ok: Type.Literal(true),
|
||||
plugin: PluginCatalogEntrySchema,
|
||||
restartRequired: Type.Literal(true),
|
||||
warnings: Type.Optional(Type.Array(Type.String())),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
/** Request payload for removing one installed plugin and its managed files. */
|
||||
export const PluginsUninstallParamsSchema = Type.Object(
|
||||
{
|
||||
pluginId: NonEmptyString,
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
/** Successful plugin removal result listing the cleanup actions that ran. */
|
||||
export const PluginsUninstallResultSchema = Type.Object(
|
||||
{
|
||||
ok: Type.Literal(true),
|
||||
pluginId: NonEmptyString,
|
||||
restartRequired: Type.Literal(true),
|
||||
removed: Type.Array(Type.String()),
|
||||
warnings: Type.Optional(Type.Array(Type.String())),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
/** Request payload for changing one installed plugin's policy state. */
|
||||
export const PluginsSetEnabledParamsSchema = Type.Object(
|
||||
{
|
||||
pluginId: NonEmptyString,
|
||||
enabled: Type.Boolean(),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
/** Successful plugin enablement policy update. */
|
||||
export const PluginsSetEnabledResultSchema = Type.Object(
|
||||
{
|
||||
ok: Type.Literal(true),
|
||||
plugin: PluginCatalogEntrySchema,
|
||||
restartRequired: Type.Boolean(),
|
||||
warnings: Type.Optional(Type.Array(Type.String())),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export type PluginCatalogEntry = Static<typeof PluginCatalogEntrySchema>;
|
||||
export type PluginsListParams = Static<typeof PluginsListParamsSchema>;
|
||||
export type PluginsListResult = Static<typeof PluginsListResultSchema>;
|
||||
export type PluginsSearchParams = Static<typeof PluginsSearchParamsSchema>;
|
||||
export type PluginsSearchResult = Static<typeof PluginsSearchResultSchema>;
|
||||
export type PluginsInstallParams = Static<typeof PluginsInstallParamsSchema>;
|
||||
export type PluginsInstallResult = Static<typeof PluginsInstallResultSchema>;
|
||||
export type PluginsUninstallParams = Static<typeof PluginsUninstallParamsSchema>;
|
||||
export type PluginsUninstallResult = Static<typeof PluginsUninstallResultSchema>;
|
||||
export type PluginsSetEnabledParams = Static<typeof PluginsSetEnabledParamsSchema>;
|
||||
export type PluginsSetEnabledResult = Static<typeof PluginsSetEnabledResultSchema>;
|
||||
|
||||
@@ -263,13 +263,29 @@ import {
|
||||
PluginApprovalResolveParamsSchema,
|
||||
} from "./plugin-approvals.js";
|
||||
import {
|
||||
PluginCatalogClawHubInstallSchema,
|
||||
PluginCatalogEntrySchema,
|
||||
PluginCatalogInstallActionSchema,
|
||||
PluginCatalogOfficialInstallSchema,
|
||||
PluginControlUiDescriptorSchema,
|
||||
PluginSearchPackageSchema,
|
||||
PluginSearchResultEntrySchema,
|
||||
PluginsInstallParamsSchema,
|
||||
PluginsInstallResultSchema,
|
||||
PluginsListParamsSchema,
|
||||
PluginsListResultSchema,
|
||||
PluginsSearchParamsSchema,
|
||||
PluginsSearchResultSchema,
|
||||
PluginsSessionActionFailureResultSchema,
|
||||
PluginsSessionActionParamsSchema,
|
||||
PluginsSessionActionResultSchema,
|
||||
PluginsSessionActionSuccessResultSchema,
|
||||
PluginsSetEnabledParamsSchema,
|
||||
PluginsSetEnabledResultSchema,
|
||||
PluginsUiDescriptorsParamsSchema,
|
||||
PluginsUiDescriptorsResultSchema,
|
||||
PluginsUninstallParamsSchema,
|
||||
PluginsUninstallResultSchema,
|
||||
} from "./plugins.js";
|
||||
import { PushTestParamsSchema, PushTestResultSchema } from "./push.js";
|
||||
import {
|
||||
@@ -698,13 +714,29 @@ export const ProtocolSchemas = {
|
||||
ExecApprovalResolveParams: ExecApprovalResolveParamsSchema,
|
||||
PluginApprovalRequestParams: PluginApprovalRequestParamsSchema,
|
||||
PluginApprovalResolveParams: PluginApprovalResolveParamsSchema,
|
||||
PluginCatalogClawHubInstall: PluginCatalogClawHubInstallSchema,
|
||||
PluginCatalogEntry: PluginCatalogEntrySchema,
|
||||
PluginCatalogInstallAction: PluginCatalogInstallActionSchema,
|
||||
PluginCatalogOfficialInstall: PluginCatalogOfficialInstallSchema,
|
||||
PluginControlUiDescriptor: PluginControlUiDescriptorSchema,
|
||||
PluginSearchPackage: PluginSearchPackageSchema,
|
||||
PluginSearchResultEntry: PluginSearchResultEntrySchema,
|
||||
PluginsInstallParams: PluginsInstallParamsSchema,
|
||||
PluginsInstallResult: PluginsInstallResultSchema,
|
||||
PluginsListParams: PluginsListParamsSchema,
|
||||
PluginsListResult: PluginsListResultSchema,
|
||||
PluginsSearchParams: PluginsSearchParamsSchema,
|
||||
PluginsSearchResult: PluginsSearchResultSchema,
|
||||
PluginsSessionActionFailureResult: PluginsSessionActionFailureResultSchema,
|
||||
PluginsSessionActionParams: PluginsSessionActionParamsSchema,
|
||||
PluginsSessionActionResult: PluginsSessionActionResultSchema,
|
||||
PluginsSessionActionSuccessResult: PluginsSessionActionSuccessResultSchema,
|
||||
PluginsSetEnabledParams: PluginsSetEnabledParamsSchema,
|
||||
PluginsSetEnabledResult: PluginsSetEnabledResultSchema,
|
||||
PluginsUiDescriptorsParams: PluginsUiDescriptorsParamsSchema,
|
||||
PluginsUiDescriptorsResult: PluginsUiDescriptorsResultSchema,
|
||||
PluginsUninstallParams: PluginsUninstallParamsSchema,
|
||||
PluginsUninstallResult: PluginsUninstallResultSchema,
|
||||
DevicePairListParams: DevicePairListParamsSchema,
|
||||
DevicePairApproveParams: DevicePairApproveParamsSchema,
|
||||
DevicePairRejectParams: DevicePairRejectParamsSchema,
|
||||
|
||||
@@ -119,7 +119,12 @@
|
||||
"id": "diffs",
|
||||
"label": "Diffs"
|
||||
},
|
||||
"catalog": {
|
||||
"featured": true,
|
||||
"order": 40
|
||||
},
|
||||
"install": {
|
||||
"clawhubSpec": "clawhub:@openclaw/diffs",
|
||||
"npmSpec": "@openclaw/diffs",
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.4.30"
|
||||
@@ -308,7 +313,12 @@
|
||||
"id": "lobster",
|
||||
"label": "Lobster"
|
||||
},
|
||||
"catalog": {
|
||||
"featured": true,
|
||||
"order": 50
|
||||
},
|
||||
"install": {
|
||||
"clawhubSpec": "clawhub:@openclaw/lobster",
|
||||
"npmSpec": "@openclaw/lobster",
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.4.25"
|
||||
@@ -325,7 +335,12 @@
|
||||
"id": "memory-lancedb",
|
||||
"label": "Memory LanceDB"
|
||||
},
|
||||
"catalog": {
|
||||
"featured": true,
|
||||
"order": 70
|
||||
},
|
||||
"install": {
|
||||
"clawhubSpec": "clawhub:@openclaw/memory-lancedb",
|
||||
"npmSpec": "@openclaw/memory-lancedb",
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.5.31"
|
||||
@@ -588,6 +603,10 @@
|
||||
"id": "tokenjuice",
|
||||
"label": "Tokenjuice"
|
||||
},
|
||||
"catalog": {
|
||||
"featured": true,
|
||||
"order": 60
|
||||
},
|
||||
"install": {
|
||||
"clawhubSpec": "clawhub:@openclaw/tokenjuice",
|
||||
"npmSpec": "@openclaw/tokenjuice",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
export const MAX_PUBLIC_PLUGIN_SDK_DECLARATION_BYTES = 5_000_000;
|
||||
// Raised from 5_000_000 for the plugins.uninstall + catalog install-action protocol surface;
|
||||
// the cap exists to force a conscious decision on published declaration growth.
|
||||
export const MAX_PUBLIC_PLUGIN_SDK_DECLARATION_BYTES = 5_050_000;
|
||||
// Private-only entrypoints reshape chunks reachable from public roots but are never published.
|
||||
// Bound that topology overhead without counting local-only declarations as package surface.
|
||||
export const MAX_PRIVATE_QA_PUBLIC_PLUGIN_SDK_DECLARATION_BYTES = 5_025_000;
|
||||
export const MAX_PRIVATE_QA_PUBLIC_PLUGIN_SDK_DECLARATION_BYTES = 5_075_000;
|
||||
|
||||
export function isPrivateQaPluginSdkBuild(env) {
|
||||
return env.OPENCLAW_BUILD_PRIVATE_QA === "1";
|
||||
|
||||
@@ -54,8 +54,8 @@ vi.mock("../../plugins/git-install.js", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../cli/plugins-install-persist.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../cli/plugins-install-persist.js")>()),
|
||||
vi.mock("../../plugins/install-persistence.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../plugins/install-persistence.js")>()),
|
||||
persistPluginInstall: persistPluginInstallMock,
|
||||
}));
|
||||
|
||||
|
||||
@@ -23,11 +23,11 @@ vi.mock("../../cli/plugins-command-helpers.js", () => ({
|
||||
resolveFileNpmSpecToLocalPath: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock("../../cli/plugins-install-persist.js", () => ({
|
||||
vi.mock("../../plugins/install-persistence.js", () => ({
|
||||
persistPluginInstall: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../../cli/plugins-registry-refresh.js", () => ({
|
||||
vi.mock("../../plugins/registry-refresh.js", () => ({
|
||||
refreshPluginRegistryAfterConfigMutation: refreshPluginRegistryAfterConfigMutationMock,
|
||||
}));
|
||||
|
||||
|
||||
@@ -8,13 +8,6 @@ import {
|
||||
createPluginInstallLogger,
|
||||
resolveFileNpmSpecToLocalPath,
|
||||
} from "../../cli/plugins-command-helpers.js";
|
||||
import {
|
||||
persistPluginInstall,
|
||||
resolveInstallConfigMutationPreflights,
|
||||
selectInstallMutationWriteOptions,
|
||||
} from "../../cli/plugins-install-persist.js";
|
||||
import type { ConfigSnapshotForInstallPersist } from "../../cli/plugins-install-persist.js";
|
||||
import { refreshPluginRegistryAfterConfigMutation } from "../../cli/plugins-registry-refresh.js";
|
||||
import { readConfigFileSnapshot, readConfigFileSnapshotForWrite } from "../../config/config.js";
|
||||
import { assertConfigWriteAllowedInCurrentMode } from "../../config/nix-mode-write-guard.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
@@ -25,6 +18,12 @@ import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import { buildClawHubPluginInstallRecordFields } from "../../plugins/clawhub-install-records.js";
|
||||
import { CLAWHUB_INSTALL_ERROR_CODE, installPluginFromClawHub } from "../../plugins/clawhub.js";
|
||||
import { installPluginFromGitSpec, parseGitPluginSpec } from "../../plugins/git-install.js";
|
||||
import {
|
||||
persistPluginInstall,
|
||||
resolveInstallConfigMutationPreflights,
|
||||
selectInstallMutationWriteOptions,
|
||||
type ConfigSnapshotForInstallPersist,
|
||||
} from "../../plugins/install-persistence.js";
|
||||
import { installPluginFromNpmSpec, installPluginFromPath } from "../../plugins/install.js";
|
||||
import { loadInstalledPluginIndexInstallRecords } from "../../plugins/installed-plugin-index-records.js";
|
||||
import {
|
||||
@@ -32,6 +31,7 @@ import {
|
||||
resolveOfficialExternalPluginId,
|
||||
resolveOfficialExternalPluginInstall,
|
||||
} from "../../plugins/official-external-plugin-catalog.js";
|
||||
import { refreshPluginRegistryAfterConfigMutation } from "../../plugins/registry-refresh.js";
|
||||
import type { PluginRecord } from "../../plugins/registry.js";
|
||||
import {
|
||||
buildAllPluginInspectReports,
|
||||
|
||||
@@ -66,7 +66,7 @@ vi.mock("../gateway/call.js", () => ({
|
||||
callGateway: mocks.callGateway,
|
||||
}));
|
||||
|
||||
vi.mock("./plugins-install-record-commit.js", () => ({
|
||||
vi.mock("../plugins/install-record-commit.js", () => ({
|
||||
commitConfigWithPendingPluginInstalls: mocks.commitConfigWithPendingPluginInstalls,
|
||||
}));
|
||||
|
||||
|
||||
@@ -14,11 +14,11 @@ import { callGateway } from "../gateway/call.js";
|
||||
import { setVerbose } from "../globals.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { isBlockedObjectKey } from "../infra/prototype-keys.js";
|
||||
import { commitConfigWithPendingPluginInstalls } from "../plugins/install-record-commit.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||
import { formatCliCommand } from "./command-format.js";
|
||||
import { formatUnsupportedChannelActionMessage } from "./error-format.js";
|
||||
import { commitConfigWithPendingPluginInstalls } from "./plugins-install-record-commit.js";
|
||||
|
||||
type ChannelAuthOptions = {
|
||||
channel?: string;
|
||||
|
||||
@@ -15,9 +15,9 @@ import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
|
||||
import { danger } from "../globals.js";
|
||||
import { resolveMessageChannelSelection } from "../infra/outbound/channel-selection.js";
|
||||
import { parseStrictPositiveInteger } from "../infra/parse-finite-number.js";
|
||||
import { commitConfigWithPendingPluginInstalls } from "../plugins/install-record-commit.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { formatHelpExamples } from "./help-format.js";
|
||||
import { commitConfigWithPendingPluginInstalls } from "./plugins-install-record-commit.js";
|
||||
|
||||
function parseLimit(value: unknown): number | null {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// CLI persistence for hook-pack installs.
|
||||
import { replaceConfigFile } from "../config/config.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { type HookInstallUpdate, recordHookInstall } from "../hooks/installs.js";
|
||||
import type { ConfigSnapshotForInstallPersist } from "../plugins/install-persistence.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
import { enableInternalHookEntries, logHookPackRestartHint } from "./plugins-command-helpers.js";
|
||||
|
||||
export async function persistHookPackInstall(params: {
|
||||
snapshot: ConfigSnapshotForInstallPersist;
|
||||
hookPackId: string;
|
||||
hooks: string[];
|
||||
install: Omit<HookInstallUpdate, "hookId" | "hooks">;
|
||||
successMessage?: string;
|
||||
runtime?: RuntimeEnv;
|
||||
}): Promise<OpenClawConfig> {
|
||||
const runtime = params.runtime ?? defaultRuntime;
|
||||
let next = enableInternalHookEntries(params.snapshot.config, params.hooks);
|
||||
next = recordHookInstall(next, {
|
||||
hookId: params.hookPackId,
|
||||
hooks: params.hooks,
|
||||
...params.install,
|
||||
});
|
||||
await replaceConfigFile({
|
||||
nextConfig: next,
|
||||
baseHash: params.snapshot.baseHash,
|
||||
writeOptions: params.snapshot.writeOptions,
|
||||
});
|
||||
runtime.log(params.successMessage ?? `Installed hook pack: ${params.hookPackId}`);
|
||||
logHookPackRestartHint(runtime);
|
||||
return next;
|
||||
}
|
||||
@@ -41,9 +41,10 @@ function createModuleLoader<T>(load: () => Promise<T>): () => Promise<T> {
|
||||
|
||||
const loadPluginsConfigState = createModuleLoader(() => import("../plugins/config-state.js"));
|
||||
const loadPluginsStatus = createModuleLoader(() => import("../plugins/status.js"));
|
||||
const loadPluginSlotSelection = createModuleLoader(() => import("../plugins/slot-selection.js"));
|
||||
const loadPluginsCommandHelpers = createModuleLoader(() => import("./plugins-command-helpers.js"));
|
||||
const loadPluginsRegistryRefresh = createModuleLoader(
|
||||
() => import("./plugins-registry-refresh.js"),
|
||||
() => import("../plugins/registry-refresh.js"),
|
||||
);
|
||||
|
||||
function countEnabledPlugins(plugins: readonly { enabled: boolean }[]): number {
|
||||
@@ -193,7 +194,8 @@ export async function runPluginsEnableCommand(idInput: string): Promise<void> {
|
||||
const { enableExplicitlySelectedPluginInConfig } = await import("../plugins/enable.js");
|
||||
const { normalizePluginId } = await loadPluginsConfigState();
|
||||
const { buildPluginRegistrySnapshotReport } = await loadPluginsStatus();
|
||||
const { applySlotSelectionForPlugin, logSlotWarnings } = await loadPluginsCommandHelpers();
|
||||
const { applySlotSelectionForPlugin } = await loadPluginSlotSelection();
|
||||
const { logSlotWarnings } = await loadPluginsCommandHelpers();
|
||||
const { refreshPluginRegistryAfterConfigMutation } = await loadPluginsRegistryRefresh();
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
const cfg = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig;
|
||||
@@ -833,6 +835,9 @@ export async function runPluginMarketplaceRefreshCommand(
|
||||
...(expectedSha256 ? { expectedSha256 } : {}),
|
||||
requireSnapshotWrite: true,
|
||||
});
|
||||
const { clearManagedPluginOfficialCatalogCache } =
|
||||
await import("../plugins/management-service.js");
|
||||
clearManagedPluginOfficialCatalogCache();
|
||||
const payload = sanitizeMarketplaceRefreshPayload(buildMarketplaceRefreshPayload(result), {
|
||||
feedUrl: opts.feedUrl,
|
||||
});
|
||||
|
||||
@@ -1,69 +1,13 @@
|
||||
// Shared plugin CLI helpers for install logging, file specs, hooks, and slot selection.
|
||||
// Shared plugin CLI helpers for install logging, file specs, and hooks.
|
||||
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
|
||||
import { theme } from "../../packages/terminal-core/src/theme.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { HOOK_INSTALL_ERROR_CODE } from "../hooks/install.js";
|
||||
import type { PluginKind } from "../plugins/plugin-kind.types.js";
|
||||
import { loadPluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
|
||||
import { applyExclusiveSlotSelection } from "../plugins/slots.js";
|
||||
import { buildPluginDiagnosticsReport } from "../plugins/status.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
export { quietPluginJsonLogger } from "./plugins-json-logger.js";
|
||||
|
||||
type HookInternalEntryLike = Record<string, unknown> & { enabled?: boolean };
|
||||
|
||||
type SlotSelectionPlugin = {
|
||||
id: string;
|
||||
kind?: PluginKind | PluginKind[];
|
||||
};
|
||||
|
||||
type SlotSelectionRegistry = {
|
||||
plugins: SlotSelectionPlugin[];
|
||||
};
|
||||
|
||||
function mergeRuntimeKinds(
|
||||
report: SlotSelectionRegistry,
|
||||
runtimeReport: SlotSelectionRegistry,
|
||||
): SlotSelectionRegistry {
|
||||
const runtimeKinds = new Map(
|
||||
runtimeReport.plugins
|
||||
.filter((plugin) => plugin.kind)
|
||||
.map((plugin) => [plugin.id, plugin.kind] as const),
|
||||
);
|
||||
return {
|
||||
plugins: report.plugins.map((plugin) => {
|
||||
if (plugin.kind) {
|
||||
return plugin;
|
||||
}
|
||||
const runtimeKind = runtimeKinds.get(plugin.id);
|
||||
return runtimeKind ? { ...plugin, kind: runtimeKind } : plugin;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function loadRuntimeKindReportForPlugins(config: OpenClawConfig, pluginIds: readonly string[]) {
|
||||
return buildPluginDiagnosticsReport({
|
||||
config,
|
||||
onlyPluginIds: [...pluginIds],
|
||||
});
|
||||
}
|
||||
|
||||
function buildSlotSelectionRegistry(
|
||||
config: OpenClawConfig,
|
||||
pluginId: string,
|
||||
): SlotSelectionRegistry {
|
||||
const plugins = loadPluginMetadataSnapshot({
|
||||
config,
|
||||
env: process.env,
|
||||
}).plugins.filter((plugin) => plugin.id === pluginId);
|
||||
return {
|
||||
plugins: plugins.map((plugin) => ({
|
||||
id: plugin.id,
|
||||
kind: plugin.kind,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveFileNpmSpecToLocalPath(
|
||||
raw: string,
|
||||
): { ok: true; path: string } | { ok: false; error: string } | null {
|
||||
@@ -90,38 +34,6 @@ export function resolveFileNpmSpecToLocalPath(
|
||||
return { ok: true, path: rest };
|
||||
}
|
||||
|
||||
export function applySlotSelectionForPlugin(
|
||||
config: OpenClawConfig,
|
||||
pluginId: string,
|
||||
): { config: OpenClawConfig; warnings: string[] } {
|
||||
// Static metadata is preferred; runtime diagnostics fill in kind for older manifests.
|
||||
const report = buildSlotSelectionRegistry(config, pluginId);
|
||||
const plugin = report.plugins.find((entry) => entry.id === pluginId);
|
||||
if (!plugin) {
|
||||
return { config, warnings: [] };
|
||||
}
|
||||
if (!plugin.kind) {
|
||||
const runtimeReport = loadRuntimeKindReportForPlugins(config, [plugin.id]);
|
||||
const runtimePlugin = runtimeReport.plugins.find((entry) => entry.id === plugin.id);
|
||||
if (runtimePlugin?.kind) {
|
||||
const result = applyExclusiveSlotSelection({
|
||||
config,
|
||||
selectedId: runtimePlugin.id,
|
||||
selectedKind: runtimePlugin.kind,
|
||||
registry: mergeRuntimeKinds(report, runtimeReport),
|
||||
});
|
||||
return { config: result.config, warnings: result.warnings };
|
||||
}
|
||||
}
|
||||
const result = applyExclusiveSlotSelection({
|
||||
config,
|
||||
selectedId: plugin.id,
|
||||
selectedKind: plugin.kind,
|
||||
registry: report,
|
||||
});
|
||||
return { config: result.config, warnings: result.warnings };
|
||||
}
|
||||
|
||||
export function createPluginInstallLogger(runtime: RuntimeEnv = defaultRuntime): {
|
||||
info: (msg: string) => void;
|
||||
warn: (msg: string) => void;
|
||||
|
||||
@@ -21,6 +21,14 @@ import { buildClawHubPluginInstallRecordFields } from "../plugins/clawhub-instal
|
||||
import { CLAWHUB_INSTALL_ERROR_CODE, installPluginFromClawHub } from "../plugins/clawhub.js";
|
||||
import { installPluginFromGitSpec, parseGitPluginSpec } from "../plugins/git-install.js";
|
||||
import { resolveDefaultPluginExtensionsDir } from "../plugins/install-paths.js";
|
||||
import {
|
||||
persistPluginInstall,
|
||||
resolveInstallConfigMutationPreflights,
|
||||
selectInstallMutationWriteOptions,
|
||||
supportsInstallConfigSingleTopLevelIncludeShape,
|
||||
type ConfigMutationPreflight,
|
||||
type ConfigSnapshotForInstallPersist,
|
||||
} from "../plugins/install-persistence.js";
|
||||
import type { InstallSafetyOverrides } from "../plugins/install-security-scan.js";
|
||||
import {
|
||||
PLUGIN_INSTALL_ERROR_CODE,
|
||||
@@ -45,6 +53,7 @@ import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
import { resolveUserPath, shortenHomePath } from "../utils.js";
|
||||
import { resolveClawHubRiskAcknowledgementCliOptions } from "./clawhub-risk-acknowledgement.js";
|
||||
import { formatCliCommand } from "./command-format.js";
|
||||
import { persistHookPackInstall } from "./hook-install-persistence.js";
|
||||
import { looksLikeLocalInstallSpec } from "./install-spec.js";
|
||||
import { resolvePinnedNpmInstallRecordForCli } from "./npm-resolution.js";
|
||||
import {
|
||||
@@ -65,15 +74,6 @@ import {
|
||||
parseNpmPackPrefixPath,
|
||||
parseNpmPrefixSpec,
|
||||
} from "./plugins-command-helpers.js";
|
||||
import {
|
||||
persistHookPackInstall,
|
||||
persistPluginInstall,
|
||||
resolveInstallConfigMutationPreflights,
|
||||
selectInstallMutationWriteOptions,
|
||||
supportsInstallConfigSingleTopLevelIncludeShape,
|
||||
type ConfigMutationPreflight,
|
||||
type ConfigSnapshotForInstallPersist,
|
||||
} from "./plugins-install-persist.js";
|
||||
import { listPersistedBundledPluginRecoveryLocations } from "./plugins-location-bridges.js";
|
||||
|
||||
type ConfigSnapshotForInstallExecution = ConfigSnapshotForInstallPersist & {
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
// ClawHub-backed plugin search command; queries installable plugin families and merges scores.
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { theme } from "../../packages/terminal-core/src/theme.js";
|
||||
import {
|
||||
searchClawHubPackages,
|
||||
type ClawHubPackageFamily,
|
||||
type ClawHubPackageSearchResult,
|
||||
} from "../infra/clawhub.js";
|
||||
import type { ClawHubPackageSearchResult } from "../infra/clawhub.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { searchInstallablePluginPackages } from "../plugins/catalog-search.js";
|
||||
import { defaultRuntime, writeRuntimeJson, type RuntimeEnv } from "../runtime.js";
|
||||
|
||||
/** Options accepted by `openclaw plugins search`. */
|
||||
@@ -15,47 +12,6 @@ export type PluginsSearchOptions = {
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
const INSTALLABLE_PLUGIN_FAMILIES: ClawHubPackageFamily[] = ["code-plugin", "bundle-plugin"];
|
||||
|
||||
function clampSearchLimit(limit: number | undefined): number {
|
||||
if (!Number.isFinite(limit) || !limit || limit <= 0) {
|
||||
return 20;
|
||||
}
|
||||
return Math.min(Math.max(Math.trunc(limit), 1), 100);
|
||||
}
|
||||
|
||||
function mergePackageSearchResults(
|
||||
groups: readonly ClawHubPackageSearchResult[][],
|
||||
limit: number,
|
||||
): ClawHubPackageSearchResult[] {
|
||||
const byName = new Map<string, ClawHubPackageSearchResult>();
|
||||
for (const entry of groups.flat()) {
|
||||
const existing = byName.get(entry.package.name);
|
||||
if (!existing || entry.score > existing.score) {
|
||||
byName.set(entry.package.name, entry);
|
||||
}
|
||||
}
|
||||
const selected: ClawHubPackageSearchResult[] = [];
|
||||
for (const entry of byName.values()) {
|
||||
let insertAt = selected.length;
|
||||
for (let index = 0; index < selected.length; index += 1) {
|
||||
if (entry.score > selected[index].score) {
|
||||
insertAt = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (insertAt < limit) {
|
||||
selected.splice(insertAt, 0, entry);
|
||||
if (selected.length > limit) {
|
||||
selected.pop();
|
||||
}
|
||||
} else if (selected.length < limit) {
|
||||
selected.push(entry);
|
||||
}
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
function formatPackageSearchLine(entry: ClawHubPackageSearchResult): string {
|
||||
const pkg = entry.package;
|
||||
const flags = [
|
||||
@@ -82,18 +38,8 @@ export async function runPluginsSearchCommand(
|
||||
return runtime.exit(1);
|
||||
}
|
||||
|
||||
const limit = clampSearchLimit(opts.limit);
|
||||
try {
|
||||
const groups = await Promise.all(
|
||||
INSTALLABLE_PLUGIN_FAMILIES.map((family) =>
|
||||
searchClawHubPackages({
|
||||
query,
|
||||
family,
|
||||
limit,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const results = mergePackageSearchResults(groups, limit);
|
||||
const results = await searchInstallablePluginPackages({ query, limit: opts.limit });
|
||||
|
||||
if (opts.json) {
|
||||
writeRuntimeJson(runtime, { results });
|
||||
|
||||
@@ -52,9 +52,9 @@ export async function runPluginUninstallCommand(
|
||||
UNINSTALL_ACTION_LABELS,
|
||||
} = await import("../plugins/uninstall.js");
|
||||
const { commitPluginInstallRecordsWithConfig } =
|
||||
await import("./plugins-install-record-commit.js");
|
||||
await import("../plugins/install-record-commit.js");
|
||||
const { refreshPluginRegistryAfterConfigMutation } =
|
||||
await import("./plugins-registry-refresh.js");
|
||||
await import("../plugins/registry-refresh.js");
|
||||
const { resolvePluginUninstallId } = await import("./plugins-uninstall-selection.js");
|
||||
const { PromptInputClosedError, promptYesNo } = await import("./prompt.js");
|
||||
const snapshot = await tracePluginLifecyclePhaseAsync(
|
||||
|
||||
@@ -13,11 +13,19 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { PluginInstallRecord } from "../config/types.plugins.js";
|
||||
import { updateNpmInstalledHookPacks } from "../hooks/update.js";
|
||||
import { normalizeUpdateChannel } from "../infra/update-channels.js";
|
||||
import {
|
||||
containsConfigIncludeDirective,
|
||||
resolveCombinedPluginAndHookConfigMutationPreflight,
|
||||
resolveInstallConfigMutationPreflights,
|
||||
selectInstallMutationWriteOptions,
|
||||
} from "../plugins/install-persistence.js";
|
||||
import { commitPluginInstallRecordsWithConfig } from "../plugins/install-record-commit.js";
|
||||
import {
|
||||
loadInstalledPluginIndexInstallRecords,
|
||||
withoutPluginInstallRecords,
|
||||
withPluginInstallRecords,
|
||||
} from "../plugins/installed-plugin-index-records.js";
|
||||
import { refreshPluginRegistryAfterConfigMutation } from "../plugins/registry-refresh.js";
|
||||
import {
|
||||
isPluginInstallRecordUpdateSource,
|
||||
pluginInstallRecordMayMigrateConfigId,
|
||||
@@ -26,14 +34,6 @@ import {
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import { resolveClawHubRiskAcknowledgementCliOptions } from "./clawhub-risk-acknowledgement.js";
|
||||
import {
|
||||
containsConfigIncludeDirective,
|
||||
resolveCombinedPluginAndHookConfigMutationPreflight,
|
||||
resolveInstallConfigMutationPreflights,
|
||||
selectInstallMutationWriteOptions,
|
||||
} from "./plugins-install-persist.js";
|
||||
import { commitPluginInstallRecordsWithConfig } from "./plugins-install-record-commit.js";
|
||||
import { refreshPluginRegistryAfterConfigMutation } from "./plugins-registry-refresh.js";
|
||||
import { logPluginUpdateOutcomes } from "./plugins-update-outcomes.js";
|
||||
import {
|
||||
resolveHookPackUpdateSelection,
|
||||
|
||||
@@ -121,6 +121,7 @@ import {
|
||||
} from "../../infra/update-runner.js";
|
||||
import { getWindowsSystem32ExePath } from "../../infra/windows-install-roots.js";
|
||||
import { normalizePluginsConfig, resolveEffectiveEnableState } from "../../plugins/config-state.js";
|
||||
import { commitPluginInstallRecordsWithConfig } from "../../plugins/install-record-commit.js";
|
||||
import {
|
||||
loadInstalledPluginIndexInstallRecords,
|
||||
writePersistedInstalledPluginIndexInstallRecords,
|
||||
@@ -131,6 +132,7 @@ import {
|
||||
resolveTrustedSourceLinkedOfficialClawHubSpec,
|
||||
resolveTrustedSourceLinkedOfficialNpmSpec,
|
||||
} from "../../plugins/official-external-install-records.js";
|
||||
import { refreshPluginRegistryAfterConfigMutation } from "../../plugins/registry-refresh.js";
|
||||
import {
|
||||
isClawHubTrustSkippedOutcome,
|
||||
syncPluginsForUpdateChannel,
|
||||
@@ -153,9 +155,7 @@ import {
|
||||
waitForGatewayHealthyRestart,
|
||||
type GatewayRestartSnapshot,
|
||||
} from "../daemon-cli/restart-health.js";
|
||||
import { commitPluginInstallRecordsWithConfig } from "../plugins-install-record-commit.js";
|
||||
import { listPersistedBundledPluginLocationBridges } from "../plugins-location-bridges.js";
|
||||
import { refreshPluginRegistryAfterConfigMutation } from "../plugins-registry-refresh.js";
|
||||
import {
|
||||
registerSignalExitBarrier,
|
||||
registerSignalExitGate,
|
||||
|
||||
@@ -86,9 +86,9 @@ vi.mock("../config/config.js", async () => ({
|
||||
replaceConfigFile: replaceConfigFileMock,
|
||||
}));
|
||||
|
||||
vi.mock("../cli/plugins-install-record-commit.js", async () => ({
|
||||
...(await vi.importActual<typeof import("../cli/plugins-install-record-commit.js")>(
|
||||
"../cli/plugins-install-record-commit.js",
|
||||
vi.mock("../plugins/install-record-commit.js", async () => ({
|
||||
...(await vi.importActual<typeof import("../plugins/install-record-commit.js")>(
|
||||
"../plugins/install-record-commit.js",
|
||||
)),
|
||||
commitConfigWithPendingPluginInstalls: commitConfigWithPendingPluginInstallsMock,
|
||||
transformConfigWithPendingPluginInstalls: transformConfigWithPendingPluginInstallsMock,
|
||||
|
||||
@@ -18,11 +18,11 @@ import { resolveAuthStorePath } from "../agents/auth-profiles/paths.js";
|
||||
import { loadPersistedAuthProfileStore } from "../agents/auth-profiles/persisted.js";
|
||||
import { saveAuthProfileStore } from "../agents/auth-profiles/store.js";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { logConfigUpdated } from "../config/logging.js";
|
||||
import {
|
||||
commitConfigWithPendingPluginInstalls,
|
||||
transformConfigWithPendingPluginInstalls,
|
||||
} from "../cli/plugins-install-record-commit.js";
|
||||
import { logConfigUpdated } from "../config/logging.js";
|
||||
} from "../plugins/install-record-commit.js";
|
||||
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js";
|
||||
import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
|
||||
@@ -92,9 +92,9 @@ vi.mock("../channels/plugins/bundled.js", async () => {
|
||||
|
||||
vi.mock("./channel-setup/plugin-install.js", () => pluginInstallMocks);
|
||||
|
||||
vi.mock("../cli/plugins-registry-refresh.js", () => registryRefreshMocks);
|
||||
vi.mock("../plugins/registry-refresh.js", () => registryRefreshMocks);
|
||||
|
||||
vi.mock("../cli/plugins-install-record-commit.js", () => pluginInstallRecordCommitMocks);
|
||||
vi.mock("../plugins/install-record-commit.js", () => pluginInstallRecordCommitMocks);
|
||||
|
||||
vi.mock("../wizard/clack-prompter.js", () => ({
|
||||
createClackPrompter: () => channelWizardMocks.prompter,
|
||||
|
||||
@@ -59,7 +59,7 @@ vi.mock("./channel-setup/plugin-install.js", async () => {
|
||||
return createMockChannelSetupPluginInstallModule(actual);
|
||||
});
|
||||
|
||||
vi.mock("../cli/plugins-registry-refresh.js", () => registryRefreshMocks);
|
||||
vi.mock("../plugins/registry-refresh.js", () => registryRefreshMocks);
|
||||
|
||||
vi.mock("../gateway/call.js", () => ({
|
||||
callGateway: gatewayMocks.callGateway,
|
||||
|
||||
@@ -34,7 +34,7 @@ vi.mock("../config/config.js", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../cli/plugins-registry-refresh.js", () => ({
|
||||
vi.mock("../plugins/registry-refresh.js", () => ({
|
||||
refreshPluginRegistryAfterConfigMutation: mocks.refreshPluginRegistryAfterConfigMutation,
|
||||
}));
|
||||
|
||||
|
||||
@@ -13,10 +13,10 @@ import {
|
||||
formatUnknownChannelMessage,
|
||||
formatUnsupportedChannelActionMessage,
|
||||
} from "../../cli/error-format.js";
|
||||
import { commitConfigWithPendingPluginInstalls } from "../../cli/plugins-install-record-commit.js";
|
||||
import { refreshPluginRegistryAfterConfigMutation } from "../../cli/plugins-registry-refresh.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { parseStrictNonNegativeInteger } from "../../infra/parse-finite-number.js";
|
||||
import { commitConfigWithPendingPluginInstalls } from "../../plugins/install-record-commit.js";
|
||||
import { refreshPluginRegistryAfterConfigMutation } from "../../plugins/registry-refresh.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
|
||||
import { createLazyImportLoader } from "../../shared/lazy-promise.js";
|
||||
|
||||
@@ -48,7 +48,7 @@ vi.mock("../../config/config.js", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../cli/plugins-registry-refresh.js", () => ({
|
||||
vi.mock("../../plugins/registry-refresh.js", () => ({
|
||||
refreshPluginRegistryAfterConfigMutation: mocks.refreshPluginRegistryAfterConfigMutation,
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { commitConfigWithPendingPluginInstalls } from "../../cli/plugins-install-record-commit.js";
|
||||
import { refreshPluginRegistryAfterConfigMutation } from "../../cli/plugins-registry-refresh.js";
|
||||
import { replaceConfigFile } from "../../config/config.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { commitConfigWithPendingPluginInstalls } from "../../plugins/install-record-commit.js";
|
||||
import { refreshPluginRegistryAfterConfigMutation } from "../../plugins/registry-refresh.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
|
||||
export async function persistResolvedChannelPluginConfig(params: {
|
||||
|
||||
@@ -9,11 +9,11 @@ import {
|
||||
formatUnknownChannelMessage,
|
||||
formatUnsupportedChannelActionMessage,
|
||||
} from "../../cli/error-format.js";
|
||||
import { commitConfigWithPendingPluginInstalls } from "../../cli/plugins-install-record-commit.js";
|
||||
import { refreshPluginRegistryAfterConfigMutation } from "../../cli/plugins-registry-refresh.js";
|
||||
import { replaceConfigFile, type OpenClawConfig } from "../../config/config.js";
|
||||
import { callGateway } from "../../gateway/call.js";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import { commitConfigWithPendingPluginInstalls } from "../../plugins/install-record-commit.js";
|
||||
import { refreshPluginRegistryAfterConfigMutation } from "../../plugins/registry-refresh.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js";
|
||||
|
||||
@@ -6,7 +6,6 @@ import { note } from "../../packages/terminal-core/src/note.js";
|
||||
import { describeCodexNativeWebSearch } from "../agents/codex-native-web-search.shared.js";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { formatPortRangeHint } from "../cli/error-format.js";
|
||||
import { commitConfigWithPendingPluginInstalls } from "../cli/plugins-install-record-commit.js";
|
||||
import { parsePort } from "../cli/shared/parse-port.js";
|
||||
import {
|
||||
createConfigIO,
|
||||
@@ -18,6 +17,7 @@ import { ConfigMutationConflictError } from "../config/mutate.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
|
||||
import { formatWindowsGatewayFirewallGuidance } from "../infra/windows-gateway-firewall-diagnostics.js";
|
||||
import { commitConfigWithPendingPluginInstalls } from "../plugins/install-record-commit.js";
|
||||
import { resolvePluginContributionOwners } from "../plugins/plugin-registry.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { replaceConfigFile } from "../../config/config.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
/**
|
||||
* Config write commit helper for non-interactive onboarding.
|
||||
*
|
||||
@@ -9,9 +11,7 @@ import {
|
||||
hasPendingPluginInstallRecords,
|
||||
stripPendingPluginInstallRecords,
|
||||
unchangedPendingPluginInstallRecordIds,
|
||||
} from "../../cli/plugins-install-record-commit.js";
|
||||
import { replaceConfigFile } from "../../config/config.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
} from "../../plugins/install-record-commit.js";
|
||||
|
||||
/** Commits a non-interactive onboard config update with pending plugin records handled first. */
|
||||
export async function commitNonInteractiveOnboardConfig(params: {
|
||||
|
||||
@@ -26,7 +26,7 @@ vi.mock("../cli/plugin-install-plan.js", () => ({
|
||||
const invalidatePluginRuntimeDiscoveryAfterConfigMutation = vi.hoisted(() =>
|
||||
vi.fn(async () => undefined),
|
||||
);
|
||||
vi.mock("../cli/plugins-registry-refresh.js", () => ({
|
||||
vi.mock("../plugins/registry-refresh.js", () => ({
|
||||
invalidatePluginRuntimeDiscoveryAfterConfigMutation,
|
||||
}));
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import { uniqueStrings } from "@openclaw/normalization-core/string-normalization
|
||||
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js";
|
||||
import { resolveBundledInstallPlanForCatalogEntry } from "../cli/plugin-install-plan.js";
|
||||
import { invalidatePluginRuntimeDiscoveryAfterConfigMutation } from "../cli/plugins-registry-refresh.js";
|
||||
import { assertConfigWriteAllowedInCurrentMode } from "../config/nix-mode-write-guard.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { parseClawHubPluginSpec } from "../infra/clawhub-spec.js";
|
||||
@@ -50,6 +49,7 @@ import {
|
||||
} from "../plugins/installs.js";
|
||||
import type { PluginPackageInstall } from "../plugins/manifest.js";
|
||||
import { clearPluginMetadataLifecycleCaches } from "../plugins/plugin-metadata-lifecycle.js";
|
||||
import { invalidatePluginRuntimeDiscoveryAfterConfigMutation } from "../plugins/registry-refresh.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { withTimeout } from "../utils/with-timeout.js";
|
||||
import { VERSION } from "../version.js";
|
||||
|
||||
@@ -55,7 +55,7 @@ vi.mock("./audit.js", () => ({
|
||||
appendCrestodianAuditEntry: mocks.appendAudit,
|
||||
}));
|
||||
|
||||
vi.mock("../cli/plugins-install-record-commit.js", () => ({
|
||||
vi.mock("../plugins/install-record-commit.js", () => ({
|
||||
transformConfigWithPendingPluginInstalls: mocks.commitConfig,
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Crestodian model setup reuses the onboarding provider/auth step and config writer.
|
||||
import { transformConfigWithPendingPluginInstalls } from "../cli/plugins-install-record-commit.js";
|
||||
import { resolveAgentModelPrimaryValue } from "../config/model-input.js";
|
||||
// Crestodian model setup reuses the onboarding provider/auth step and config writer.
|
||||
import { transformConfigWithPendingPluginInstalls } from "../plugins/install-record-commit.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import type { WizardPrompter } from "../wizard/prompts.js";
|
||||
|
||||
@@ -124,9 +124,9 @@ export type ActivateSetupInferenceDeps = {
|
||||
runCliAgent?: typeof import("../agents/cli-runner.js").runCliAgent;
|
||||
applySetup?: typeof applyCrestodianSetup;
|
||||
ensureCodexRuntimePlugin?: typeof import("../commands/codex-runtime-plugin-install.js").ensureCodexRuntimePluginForModelSelection;
|
||||
transformConfigWithPendingPluginInstalls?: typeof import("../cli/plugins-install-record-commit.js").transformConfigWithPendingPluginInstalls;
|
||||
transformConfigWithPendingPluginInstalls?: typeof import("../plugins/install-record-commit.js").transformConfigWithPendingPluginInstalls;
|
||||
updateConfig?: typeof import("../commands/models/shared.js").updateConfig;
|
||||
refreshPluginRegistryAfterConfigMutation?: typeof import("../cli/plugins-registry-refresh.js").refreshPluginRegistryAfterConfigMutation;
|
||||
refreshPluginRegistryAfterConfigMutation?: typeof import("../plugins/registry-refresh.js").refreshPluginRegistryAfterConfigMutation;
|
||||
resolvePluginProviders?: typeof resolvePluginProviders;
|
||||
resolveManifestProviderAuthChoice?: typeof resolveManifestProviderAuthChoice;
|
||||
enablePluginInConfig?: typeof enablePluginInConfig;
|
||||
@@ -641,7 +641,7 @@ async function activateSetupInferenceUnredacted(
|
||||
let codexPluginPatch: unknown;
|
||||
if (params.kind === "codex-cli") {
|
||||
const { stripPendingPluginInstallRecords } =
|
||||
await import("../cli/plugins-install-record-commit.js");
|
||||
await import("../plugins/install-record-commit.js");
|
||||
// This explicit Codex CLI choice owns its runtime independently of the
|
||||
// user's existing OpenAI provider route (which may use a custom base URL).
|
||||
const codexInstallBase = stripPendingPluginInstallRecords(testPlan.config);
|
||||
@@ -683,7 +683,7 @@ async function activateSetupInferenceUnredacted(
|
||||
// failed or abandoned live probe cannot leave an untracked install behind.
|
||||
const transformConfig =
|
||||
deps.transformConfigWithPendingPluginInstalls ??
|
||||
(await import("../cli/plugins-install-record-commit.js"))
|
||||
(await import("../plugins/install-record-commit.js"))
|
||||
.transformConfigWithPendingPluginInstalls;
|
||||
await transformConfig({
|
||||
afterWrite: {
|
||||
@@ -744,10 +744,10 @@ async function activateSetupInferenceUnredacted(
|
||||
// Persist success-gated enablement and the model-scoped runtime pin. The managed
|
||||
// install record was committed before the live probe.
|
||||
const { stripPendingPluginInstallRecords } =
|
||||
await import("../cli/plugins-install-record-commit.js");
|
||||
await import("../plugins/install-record-commit.js");
|
||||
const transformConfig =
|
||||
deps.transformConfigWithPendingPluginInstalls ??
|
||||
(await import("../cli/plugins-install-record-commit.js"))
|
||||
(await import("../plugins/install-record-commit.js"))
|
||||
.transformConfigWithPendingPluginInstalls;
|
||||
const committed = await transformConfig({
|
||||
// Keep the setup RPC alive until the final model/setup write completes. The explicit
|
||||
@@ -762,8 +762,7 @@ async function activateSetupInferenceUnredacted(
|
||||
});
|
||||
const refreshPluginRegistry =
|
||||
deps.refreshPluginRegistryAfterConfigMutation ??
|
||||
(await import("../cli/plugins-registry-refresh.js"))
|
||||
.refreshPluginRegistryAfterConfigMutation;
|
||||
(await import("../plugins/registry-refresh.js")).refreshPluginRegistryAfterConfigMutation;
|
||||
await refreshPluginRegistry({
|
||||
config: committed.nextConfig,
|
||||
reason: "source-changed",
|
||||
|
||||
@@ -2,7 +2,24 @@
|
||||
* Control UI gateway routing tests.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { classifyControlUiRequest } from "./control-ui-routing.js";
|
||||
import { classifyControlUiRequest, isControlUiPluginManagerRequest } from "./control-ui-routing.js";
|
||||
|
||||
describe("isControlUiPluginManagerRequest", () => {
|
||||
it.each([
|
||||
{ basePath: "", pathname: "/settings/plugins", method: "GET", expected: true },
|
||||
{ basePath: "", pathname: "/settings/plugins/", method: "HEAD", expected: true },
|
||||
{
|
||||
basePath: "/openclaw",
|
||||
pathname: "/openclaw/settings/plugins",
|
||||
method: "GET",
|
||||
expected: true,
|
||||
},
|
||||
{ basePath: "", pathname: "/settings/plugins", method: "POST", expected: false },
|
||||
{ basePath: "", pathname: "/plugins", method: "GET", expected: false },
|
||||
])("classifies $method $pathname", ({ basePath, pathname, method, expected }) => {
|
||||
expect(isControlUiPluginManagerRequest({ basePath, pathname, method })).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyControlUiRequest", () => {
|
||||
describe("root-mounted control ui", () => {
|
||||
@@ -19,6 +36,12 @@ describe("classifyControlUiRequest", () => {
|
||||
method: "HEAD",
|
||||
expected: { kind: "serve" as const },
|
||||
},
|
||||
{
|
||||
name: "serves the plugin manager without claiming plugin HTTP routes",
|
||||
pathname: "/settings/plugins",
|
||||
method: "GET",
|
||||
expected: { kind: "serve" as const },
|
||||
},
|
||||
{
|
||||
name: "keeps health probes outside the SPA catch-all",
|
||||
pathname: "/healthz",
|
||||
@@ -37,6 +60,12 @@ describe("classifyControlUiRequest", () => {
|
||||
method: "GET",
|
||||
expected: { kind: "not-control-ui" as const },
|
||||
},
|
||||
{
|
||||
name: "keeps the plugin HTTP root outside the SPA catch-all",
|
||||
pathname: "/plugins",
|
||||
method: "GET",
|
||||
expected: { kind: "not-control-ui" as const },
|
||||
},
|
||||
{
|
||||
name: "keeps API routes outside the SPA catch-all",
|
||||
pathname: "/api/sessions",
|
||||
|
||||
@@ -8,6 +8,20 @@ type ControlUiRequestClassification =
|
||||
| { kind: "serve" };
|
||||
|
||||
const ROOT_MOUNTED_GATEWAY_PROBE_PATHS = new Set(["/health", "/healthz", "/ready", "/readyz"]);
|
||||
const CONTROL_UI_PLUGIN_MANAGER_PATH = "/settings/plugins";
|
||||
|
||||
/** Keep the plugin recovery surface ahead of plugin-owned HTTP routes. */
|
||||
export function isControlUiPluginManagerRequest(params: {
|
||||
basePath: string;
|
||||
pathname: string;
|
||||
method: string | undefined;
|
||||
}): boolean {
|
||||
if (!isReadHttpMethod(params.method)) {
|
||||
return false;
|
||||
}
|
||||
const path = `${params.basePath}${CONTROL_UI_PLUGIN_MANAGER_PATH}`;
|
||||
return params.pathname === path || params.pathname === `${path}/`;
|
||||
}
|
||||
|
||||
/** Classify an HTTP request as Control UI serving, redirect, 404, or non-Control-UI. */
|
||||
export function classifyControlUiRequest(params: {
|
||||
|
||||
@@ -273,6 +273,11 @@ export const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [
|
||||
{ name: "agents.workspace.list", scope: "operator.read" },
|
||||
{ name: "agents.workspace.get", scope: "operator.read" },
|
||||
{ name: "tts.speak", scope: "operator.write" },
|
||||
{ name: "plugins.list", scope: "operator.read" },
|
||||
{ name: "plugins.search", scope: "operator.read" },
|
||||
{ name: "plugins.install", scope: "operator.admin", controlPlaneWrite: true },
|
||||
{ name: "plugins.setEnabled", scope: "operator.admin", controlPlaneWrite: true },
|
||||
{ name: "plugins.uninstall", scope: "operator.admin", controlPlaneWrite: true },
|
||||
] as const;
|
||||
|
||||
const CORE_GATEWAY_METHOD_SPEC_BY_NAME: ReadonlyMap<string, CoreGatewayMethodSpec> = new Map(
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Plugin management descriptor tests keep read/admin scopes and write budgets explicit.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { GatewayRequestHandler } from "../server-methods/types.js";
|
||||
import { createCoreGatewayMethodDescriptors } from "./core-descriptors.js";
|
||||
|
||||
const handler: GatewayRequestHandler = ({ respond }) => respond(true, { ok: true });
|
||||
|
||||
describe("plugin management gateway descriptors", () => {
|
||||
it("keeps catalog reads separate from control-plane mutations", () => {
|
||||
const descriptors = createCoreGatewayMethodDescriptors({
|
||||
"plugins.list": handler,
|
||||
"plugins.search": handler,
|
||||
"plugins.install": handler,
|
||||
"plugins.setEnabled": handler,
|
||||
"plugins.uninstall": handler,
|
||||
});
|
||||
const byName = new Map(descriptors.map((descriptor) => [descriptor.name, descriptor]));
|
||||
|
||||
expect(byName.get("plugins.list")?.scope).toBe("operator.read");
|
||||
expect(byName.get("plugins.search")?.scope).toBe("operator.read");
|
||||
expect(byName.get("plugins.install")).toMatchObject({
|
||||
scope: "operator.admin",
|
||||
controlPlaneWrite: true,
|
||||
});
|
||||
expect(byName.get("plugins.setEnabled")).toMatchObject({
|
||||
scope: "operator.admin",
|
||||
controlPlaneWrite: true,
|
||||
});
|
||||
expect(byName.get("plugins.uninstall")).toMatchObject({
|
||||
scope: "operator.admin",
|
||||
controlPlaneWrite: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
type GatewayAuthResult,
|
||||
type ResolvedGatewayAuth,
|
||||
} from "./auth.js";
|
||||
import { isControlUiPluginManagerRequest } from "./control-ui-routing.js";
|
||||
import type { ControlUiRootState } from "./control-ui.js";
|
||||
import type { AuthorizedGatewayHttpRequest } from "./http-auth-utils.js";
|
||||
import { sendGatewayAuthFailure, setDefaultSecurityHeaders } from "./http-common.js";
|
||||
@@ -685,9 +686,36 @@ export function createGatewayHttpServer(opts: {
|
||||
},
|
||||
});
|
||||
}
|
||||
// Plugin routes run before the Control UI SPA catch-all so explicitly
|
||||
// registered plugin endpoints stay reachable. Core built-in gateway
|
||||
// routes above still keep precedence on overlapping paths.
|
||||
if (
|
||||
controlUiEnabled &&
|
||||
isControlUiPluginManagerRequest({
|
||||
basePath: controlUiBasePath,
|
||||
pathname: scopedRequestPath,
|
||||
method: req.method,
|
||||
})
|
||||
) {
|
||||
// This page must remain reachable when a plugin route is broken so the
|
||||
// operator can disable it. Other explicit plugin routes retain precedence.
|
||||
requestStages.push({
|
||||
name: "control-ui-plugin-manager",
|
||||
run: async () =>
|
||||
(await getControlUiModule()).handleControlUiHttpRequest(req, res, {
|
||||
basePath: controlUiBasePath,
|
||||
config: configSnapshot,
|
||||
terminalEnabled:
|
||||
opts.isTerminalEnabled?.() ?? configSnapshot.gateway?.terminal?.enabled === true,
|
||||
agentId: resolveAssistantIdentity({ cfg: configSnapshot }).agentId,
|
||||
root: controlUiRoot,
|
||||
auth: resolvedAuthValue,
|
||||
trustedProxies,
|
||||
allowRealIpFallback,
|
||||
rateLimiter,
|
||||
}),
|
||||
});
|
||||
}
|
||||
// Plugin routes run before the general Control UI SPA catch-all so
|
||||
// explicitly registered endpoints stay reachable. Core routes and the
|
||||
// plugin recovery surface staged above keep precedence.
|
||||
requestStages.push(
|
||||
...buildPluginRequestStages({
|
||||
req,
|
||||
|
||||
@@ -178,6 +178,10 @@ const loadPluginHostHookHandlers = lazyHandlerModule(
|
||||
() => import("./server-methods/plugin-host-hooks.js"),
|
||||
(module) => module.pluginHostHookHandlers,
|
||||
);
|
||||
const loadPluginsHandlers = lazyHandlerModule(
|
||||
() => import("./server-methods/plugins.js"),
|
||||
(module) => module.pluginsHandlers,
|
||||
);
|
||||
const loadPushHandlers = lazyHandlerModule(
|
||||
() => import("./server-methods/push.js"),
|
||||
(module) => module.pushHandlers,
|
||||
@@ -453,6 +457,16 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
|
||||
methods: ["plugins.uiDescriptors", "plugins.sessionAction"],
|
||||
loadHandlers: loadPluginHostHookHandlers,
|
||||
}),
|
||||
...createLazyCoreHandlers({
|
||||
methods: [
|
||||
"plugins.list",
|
||||
"plugins.search",
|
||||
"plugins.install",
|
||||
"plugins.setEnabled",
|
||||
"plugins.uninstall",
|
||||
],
|
||||
loadHandlers: loadPluginsHandlers,
|
||||
}),
|
||||
...createLazyCoreHandlers({
|
||||
methods: [
|
||||
"config.get",
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
// Plugin management Gateway handler tests cover DTO mapping, trust errors, and reload planning.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const managementMocks = vi.hoisted(() => {
|
||||
class ManagedPluginLifecycleError extends Error {
|
||||
readonly kind: "invalid-request" | "unavailable";
|
||||
readonly code?: string;
|
||||
readonly version?: string;
|
||||
readonly warning?: string;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
details?: {
|
||||
kind?: "invalid-request" | "unavailable";
|
||||
code?: string;
|
||||
version?: string;
|
||||
warning?: string;
|
||||
},
|
||||
) {
|
||||
super(message);
|
||||
this.kind = details?.kind ?? "invalid-request";
|
||||
this.code = details?.code;
|
||||
this.version = details?.version;
|
||||
this.warning = details?.warning;
|
||||
}
|
||||
}
|
||||
return {
|
||||
ManagedPluginLifecycleError,
|
||||
install: vi.fn(),
|
||||
list: vi.fn(),
|
||||
setEnabled: vi.fn(),
|
||||
uninstall: vi.fn(),
|
||||
};
|
||||
});
|
||||
const searchMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../plugins/management-service.js", () => ({
|
||||
ManagedPluginLifecycleError: managementMocks.ManagedPluginLifecycleError,
|
||||
formatManagedPluginLifecycleError: (error: unknown) =>
|
||||
error instanceof Error ? error.message : String(error),
|
||||
installManagedPlugin: (...args: unknown[]) => managementMocks.install(...args),
|
||||
listManagedPlugins: (...args: unknown[]) => managementMocks.list(...args),
|
||||
setManagedPluginEnabled: (...args: unknown[]) => managementMocks.setEnabled(...args),
|
||||
uninstallManagedPlugin: (...args: unknown[]) => managementMocks.uninstall(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/catalog-search.js", () => ({
|
||||
searchInstallablePluginPackages: (...args: unknown[]) => searchMock(...args),
|
||||
}));
|
||||
|
||||
const { pluginsHandlers } = await import("./plugins.js");
|
||||
|
||||
async function callHandler(
|
||||
method: string,
|
||||
params: Record<string, unknown>,
|
||||
runtimeConfig: Record<string, unknown> = {},
|
||||
) {
|
||||
let ok: boolean | null = null;
|
||||
let response: unknown;
|
||||
let error: unknown;
|
||||
await pluginsHandlers[method]({
|
||||
params,
|
||||
req: {} as never,
|
||||
client: null as never,
|
||||
isWebchatConnect: () => false,
|
||||
context: { getRuntimeConfig: () => runtimeConfig } as never,
|
||||
respond: (success, result, requestError) => {
|
||||
ok = success;
|
||||
response = result;
|
||||
error = requestError;
|
||||
},
|
||||
});
|
||||
return { ok, response, error };
|
||||
}
|
||||
|
||||
const workboard = {
|
||||
id: "workboard",
|
||||
name: "Workboard",
|
||||
installed: true,
|
||||
enabled: false,
|
||||
state: "disabled" as const,
|
||||
featured: true,
|
||||
order: 10,
|
||||
};
|
||||
|
||||
describe("plugin management Gateway handlers", () => {
|
||||
beforeEach(() => {
|
||||
managementMocks.install.mockReset();
|
||||
managementMocks.list.mockReset();
|
||||
managementMocks.setEnabled.mockReset();
|
||||
managementMocks.uninstall.mockReset();
|
||||
searchMock.mockReset();
|
||||
});
|
||||
|
||||
it("returns cold Workboard inventory without claiming runtime loaded state", async () => {
|
||||
managementMocks.list.mockResolvedValue({
|
||||
plugins: [workboard],
|
||||
diagnostics: [],
|
||||
mutationAllowed: true,
|
||||
});
|
||||
|
||||
const result = await callHandler("plugins.list", {});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
response: { plugins: [workboard], diagnostics: [], mutationAllowed: true },
|
||||
error: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps plugin-only ClawHub search results to the public DTO", async () => {
|
||||
searchMock.mockResolvedValue([
|
||||
{
|
||||
score: 0.91,
|
||||
package: {
|
||||
name: "@openclaw/diffs",
|
||||
displayName: "Diffs",
|
||||
family: "code-plugin",
|
||||
channel: "official",
|
||||
isOfficial: true,
|
||||
summary: "Readable diffs",
|
||||
latestVersion: "1.2.3",
|
||||
runtimeId: "diffs",
|
||||
ownerHandle: "openclaw",
|
||||
verificationTier: "source-linked",
|
||||
stats: { downloads: 149263, installs: 280, stars: 0, versions: 83 },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await callHandler("plugins.search", { query: "diff", limit: 12 });
|
||||
|
||||
expect(searchMock).toHaveBeenCalledWith({ query: "diff", limit: 12 });
|
||||
expect(result.response).toEqual({
|
||||
results: [
|
||||
{
|
||||
score: 0.91,
|
||||
package: {
|
||||
name: "@openclaw/diffs",
|
||||
displayName: "Diffs",
|
||||
family: "code-plugin",
|
||||
channel: "official",
|
||||
isOfficial: true,
|
||||
summary: "Readable diffs",
|
||||
latestVersion: "1.2.3",
|
||||
runtimeId: "diffs",
|
||||
downloads: 149263,
|
||||
verificationTier: "source-linked",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("omits malformed ClawHub download stats from the public DTO", async () => {
|
||||
searchMock.mockResolvedValue([
|
||||
{
|
||||
score: 0.5,
|
||||
package: {
|
||||
name: "community/demo",
|
||||
displayName: "Demo",
|
||||
family: "code-plugin",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
stats: { downloads: Number.NaN },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await callHandler("plugins.search", { query: "demo" });
|
||||
|
||||
expect(result.response).toEqual({
|
||||
results: [
|
||||
{
|
||||
score: 0.5,
|
||||
package: {
|
||||
name: "community/demo",
|
||||
displayName: "Demo",
|
||||
family: "code-plugin",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("derives Workboard restart state from its exact config path", async () => {
|
||||
managementMocks.setEnabled.mockResolvedValue({
|
||||
plugin: { ...workboard, enabled: true, state: "enabled" },
|
||||
changedPaths: ["plugins.entries.workboard.enabled"],
|
||||
warnings: ['Exclusive slot "memory" switched to "workboard".'],
|
||||
});
|
||||
|
||||
const result = await callHandler("plugins.setEnabled", {
|
||||
pluginId: "workboard",
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
expect(managementMocks.setEnabled).toHaveBeenCalledWith({
|
||||
pluginId: "workboard",
|
||||
enabled: true,
|
||||
});
|
||||
expect(result.response).toMatchObject({
|
||||
ok: true,
|
||||
restartRequired: false,
|
||||
warnings: ['Exclusive slot "memory" switched to "workboard".'],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ mode: "off", restartRequired: true },
|
||||
{ mode: "restart", restartRequired: true },
|
||||
{ mode: "hot", restartRequired: false },
|
||||
] as const)(
|
||||
"reports restartRequired=$restartRequired for $mode reload mode",
|
||||
async ({ mode, restartRequired }) => {
|
||||
managementMocks.setEnabled.mockResolvedValue({
|
||||
plugin: { ...workboard, enabled: true, state: "enabled" },
|
||||
changedPaths: ["plugins.entries.workboard.enabled"],
|
||||
});
|
||||
|
||||
const result = await callHandler(
|
||||
"plugins.setEnabled",
|
||||
{ pluginId: "workboard", enabled: true },
|
||||
{ gateway: { reload: { mode } } },
|
||||
);
|
||||
|
||||
expect(result.response).toMatchObject({ ok: true, restartRequired });
|
||||
},
|
||||
);
|
||||
|
||||
it("classifies known enablement policy failures as invalid requests", async () => {
|
||||
managementMocks.setEnabled.mockRejectedValue(
|
||||
new managementMocks.ManagedPluginLifecycleError("Plugin is blocked"),
|
||||
);
|
||||
|
||||
const result = await callHandler("plugins.setEnabled", {
|
||||
pluginId: "workboard",
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
expect(result.error).toMatchObject({
|
||||
code: "INVALID_REQUEST",
|
||||
message: "Plugin is blocked",
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies unexpected enablement persistence failures as unavailable", async () => {
|
||||
managementMocks.setEnabled.mockRejectedValue(new Error("rename EACCES"));
|
||||
|
||||
const result = await callHandler("plugins.setEnabled", {
|
||||
pluginId: "workboard",
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
expect(result.error).toMatchObject({
|
||||
code: "UNAVAILABLE",
|
||||
message: "rename EACCES",
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards explicit ClawHub risk acknowledgement", async () => {
|
||||
managementMocks.install.mockResolvedValue({
|
||||
plugin: { ...workboard, id: "diffs", name: "Diffs", enabled: true, state: "enabled" },
|
||||
});
|
||||
|
||||
await callHandler("plugins.install", {
|
||||
source: "clawhub",
|
||||
packageName: "@openclaw/diffs",
|
||||
version: "1.2.3",
|
||||
acknowledgeClawHubRisk: true,
|
||||
});
|
||||
|
||||
expect(managementMocks.install).toHaveBeenCalledWith({
|
||||
request: {
|
||||
source: "clawhub",
|
||||
packageName: "@openclaw/diffs",
|
||||
version: "1.2.3",
|
||||
acknowledgeClawHubRisk: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns structured ClawHub acknowledgement details", async () => {
|
||||
managementMocks.install.mockRejectedValue(
|
||||
new managementMocks.ManagedPluginLifecycleError("Review required", {
|
||||
kind: "invalid-request",
|
||||
code: "clawhub_risk_acknowledgement_required",
|
||||
version: "1.2.3",
|
||||
warning: "Suspicious release",
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await callHandler("plugins.install", {
|
||||
source: "clawhub",
|
||||
packageName: "community/plugin",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toMatchObject({
|
||||
code: "INVALID_REQUEST",
|
||||
message: "Review required",
|
||||
details: {
|
||||
clawhubTrustCode: "clawhub_risk_acknowledgement_required",
|
||||
version: "1.2.3",
|
||||
warning: "Suspicious release",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies ClawHub security outages as unavailable", async () => {
|
||||
managementMocks.install.mockRejectedValue(
|
||||
new managementMocks.ManagedPluginLifecycleError("Security service unavailable", {
|
||||
kind: "unavailable",
|
||||
code: "clawhub_security_unavailable",
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await callHandler("plugins.install", {
|
||||
source: "clawhub",
|
||||
packageName: "community/plugin",
|
||||
});
|
||||
|
||||
expect(result.error).toMatchObject({
|
||||
code: "UNAVAILABLE",
|
||||
details: { clawhubTrustCode: "clawhub_security_unavailable" },
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies unexpected install persistence failures as unavailable", async () => {
|
||||
managementMocks.install.mockRejectedValue(new Error("disk full"));
|
||||
|
||||
const result = await callHandler("plugins.install", {
|
||||
source: "clawhub",
|
||||
packageName: "community/plugin",
|
||||
});
|
||||
|
||||
expect(result.error).toMatchObject({
|
||||
code: "UNAVAILABLE",
|
||||
message: "disk full",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns removal actions and forces restart after uninstall", async () => {
|
||||
managementMocks.uninstall.mockResolvedValue({
|
||||
pluginId: "diffs",
|
||||
removed: ["config entry", "install record", "directory"],
|
||||
warnings: ["npm prune skipped"],
|
||||
});
|
||||
|
||||
const result = await callHandler("plugins.uninstall", { pluginId: "diffs" });
|
||||
|
||||
expect(managementMocks.uninstall).toHaveBeenCalledWith({ pluginId: "diffs" });
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
response: {
|
||||
ok: true,
|
||||
pluginId: "diffs",
|
||||
restartRequired: true,
|
||||
removed: ["config entry", "install record", "directory"],
|
||||
warnings: ["npm prune skipped"],
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies bundled uninstall refusals as invalid requests", async () => {
|
||||
managementMocks.uninstall.mockRejectedValue(
|
||||
new managementMocks.ManagedPluginLifecycleError(
|
||||
"bundled plugin cannot be uninstalled: workboard; disable it instead",
|
||||
),
|
||||
);
|
||||
|
||||
const result = await callHandler("plugins.uninstall", { pluginId: "workboard" });
|
||||
|
||||
expect(result.error).toMatchObject({
|
||||
code: "INVALID_REQUEST",
|
||||
message: "bundled plugin cannot be uninstalled: workboard; disable it instead",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
// Gateway control-plane handlers for cold plugin catalog and lifecycle operations.
|
||||
import {
|
||||
buildClawHubTrustErrorDetails,
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
isClawHubTrustErrorCode,
|
||||
validatePluginsInstallParams,
|
||||
validatePluginsListParams,
|
||||
validatePluginsSearchParams,
|
||||
validatePluginsSetEnabledParams,
|
||||
validatePluginsUninstallParams,
|
||||
} from "../../../packages/gateway-protocol/src/index.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { searchInstallablePluginPackages } from "../../plugins/catalog-search.js";
|
||||
import {
|
||||
formatManagedPluginLifecycleError,
|
||||
installManagedPlugin,
|
||||
listManagedPlugins,
|
||||
ManagedPluginLifecycleError,
|
||||
setManagedPluginEnabled,
|
||||
uninstallManagedPlugin,
|
||||
} from "../../plugins/management-service.js";
|
||||
import { buildGatewayReloadPlan } from "../config-reload-plan.js";
|
||||
import { resolveGatewayReloadSettings } from "../config-reload-settings.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
import { assertValidParams } from "./validation.js";
|
||||
|
||||
function pluginPolicyRestartRequired(params: {
|
||||
config: OpenClawConfig;
|
||||
changedPaths: readonly string[];
|
||||
}): boolean {
|
||||
const plan = buildGatewayReloadPlan([...params.changedPaths]);
|
||||
const mode = resolveGatewayReloadSettings(params.config).mode;
|
||||
return plan.restartGateway || mode === "off" || mode === "restart";
|
||||
}
|
||||
|
||||
/** Gateway handlers for plugin inventory, ClawHub search, install, and policy state. */
|
||||
export const pluginsHandlers: GatewayRequestHandlers = {
|
||||
"plugins.list": async ({ params, respond, context }) => {
|
||||
if (!assertValidParams(params, validatePluginsListParams, "plugins.list", respond)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
respond(true, await listManagedPlugins({ config: context.getRuntimeConfig() }), undefined);
|
||||
} catch (error) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.UNAVAILABLE, formatManagedPluginLifecycleError(error)),
|
||||
);
|
||||
}
|
||||
},
|
||||
"plugins.search": async ({ params, respond }) => {
|
||||
if (!assertValidParams(params, validatePluginsSearchParams, "plugins.search", respond)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const results = await searchInstallablePluginPackages({
|
||||
query: params.query,
|
||||
limit: params.limit,
|
||||
});
|
||||
respond(
|
||||
true,
|
||||
{
|
||||
results: results.flatMap((entry) => {
|
||||
if (
|
||||
entry.package.family !== "code-plugin" &&
|
||||
entry.package.family !== "bundle-plugin"
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
const downloads = entry.package.stats?.downloads;
|
||||
return [
|
||||
{
|
||||
score: entry.score,
|
||||
package: {
|
||||
name: entry.package.name,
|
||||
displayName: entry.package.displayName,
|
||||
family: entry.package.family,
|
||||
channel: entry.package.channel,
|
||||
isOfficial: entry.package.isOfficial,
|
||||
...(entry.package.summary ? { summary: entry.package.summary } : {}),
|
||||
...(entry.package.latestVersion
|
||||
? { latestVersion: entry.package.latestVersion }
|
||||
: {}),
|
||||
...(entry.package.runtimeId ? { runtimeId: entry.package.runtimeId } : {}),
|
||||
...(typeof downloads === "number" && Number.isFinite(downloads) && downloads >= 0
|
||||
? { downloads }
|
||||
: {}),
|
||||
...(entry.package.verificationTier
|
||||
? { verificationTier: entry.package.verificationTier }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
];
|
||||
}),
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
} catch (error) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.UNAVAILABLE, formatManagedPluginLifecycleError(error)),
|
||||
);
|
||||
}
|
||||
},
|
||||
"plugins.install": async ({ params, respond }) => {
|
||||
if (!assertValidParams(params, validatePluginsInstallParams, "plugins.install", respond)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await installManagedPlugin({ request: params });
|
||||
respond(
|
||||
true,
|
||||
{
|
||||
ok: true,
|
||||
plugin: result.plugin,
|
||||
restartRequired: true,
|
||||
...(result.warnings ? { warnings: result.warnings } : {}),
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
} catch (error) {
|
||||
const lifecycleError = error instanceof ManagedPluginLifecycleError ? error : undefined;
|
||||
const trustCode =
|
||||
lifecycleError?.code && isClawHubTrustErrorCode(lifecycleError.code)
|
||||
? lifecycleError.code
|
||||
: undefined;
|
||||
const details = lifecycleError
|
||||
? buildClawHubTrustErrorDetails({
|
||||
...(trustCode ? { code: trustCode } : {}),
|
||||
...(lifecycleError.version ? { version: lifecycleError.version } : {}),
|
||||
...(lifecycleError.warning ? { warning: lifecycleError.warning } : {}),
|
||||
})
|
||||
: undefined;
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
lifecycleError?.kind === "invalid-request"
|
||||
? ErrorCodes.INVALID_REQUEST
|
||||
: ErrorCodes.UNAVAILABLE,
|
||||
formatManagedPluginLifecycleError(error),
|
||||
details ? { details } : undefined,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
"plugins.uninstall": async ({ params, respond }) => {
|
||||
if (!assertValidParams(params, validatePluginsUninstallParams, "plugins.uninstall", respond)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await uninstallManagedPlugin({ pluginId: params.pluginId });
|
||||
respond(
|
||||
true,
|
||||
{
|
||||
ok: true,
|
||||
pluginId: result.pluginId,
|
||||
restartRequired: true,
|
||||
removed: result.removed,
|
||||
...(result.warnings ? { warnings: result.warnings } : {}),
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
} catch (error) {
|
||||
const lifecycleError = error instanceof ManagedPluginLifecycleError ? error : undefined;
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
lifecycleError?.kind === "invalid-request"
|
||||
? ErrorCodes.INVALID_REQUEST
|
||||
: ErrorCodes.UNAVAILABLE,
|
||||
formatManagedPluginLifecycleError(error),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
"plugins.setEnabled": async ({ params, respond, context }) => {
|
||||
if (
|
||||
!assertValidParams(params, validatePluginsSetEnabledParams, "plugins.setEnabled", respond)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await setManagedPluginEnabled({
|
||||
pluginId: params.pluginId,
|
||||
enabled: params.enabled,
|
||||
});
|
||||
respond(
|
||||
true,
|
||||
{
|
||||
ok: true,
|
||||
plugin: result.plugin,
|
||||
restartRequired: pluginPolicyRestartRequired({
|
||||
config: context.getRuntimeConfig(),
|
||||
changedPaths: result.changedPaths,
|
||||
}),
|
||||
...(result.warnings ? { warnings: result.warnings } : {}),
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
} catch (error) {
|
||||
const lifecycleError = error instanceof ManagedPluginLifecycleError ? error : undefined;
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
lifecycleError?.kind === "invalid-request"
|
||||
? ErrorCodes.INVALID_REQUEST
|
||||
: ErrorCodes.UNAVAILABLE,
|
||||
formatManagedPluginLifecycleError(error),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -609,6 +609,51 @@ describe("gateway plugin HTTP auth boundary", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ label: "root-mounted", basePath: "", path: "/settings/plugins" },
|
||||
{
|
||||
label: "base-path-mounted",
|
||||
basePath: "/openclaw",
|
||||
path: "/openclaw/settings/plugins",
|
||||
},
|
||||
])(
|
||||
"reserves the $label plugin manager GET while preserving writes",
|
||||
async ({ basePath, path }) => {
|
||||
const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => {
|
||||
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
|
||||
if (pathname !== path) {
|
||||
return false;
|
||||
}
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end("plugin-handled");
|
||||
return true;
|
||||
});
|
||||
|
||||
await withGatewayServer({
|
||||
prefix: "openclaw-plugin-http-plugin-manager-reserved-test-",
|
||||
resolvedAuth: AUTH_NONE,
|
||||
overrides: {
|
||||
controlUiEnabled: true,
|
||||
controlUiBasePath: basePath,
|
||||
controlUiRoot: { kind: "missing" },
|
||||
handlePluginRequest,
|
||||
},
|
||||
run: async (server) => {
|
||||
const read = await sendRequest(server, { path });
|
||||
expect(read.res.statusCode).toBe(503);
|
||||
expect(read.getBody()).toContain("Control UI assets not found");
|
||||
expect(handlePluginRequest).not.toHaveBeenCalled();
|
||||
|
||||
const write = await sendRequest(server, { path, method: "POST" });
|
||||
expect(write.res.statusCode).toBe(200);
|
||||
expect(write.getBody()).toBe("plugin-handled");
|
||||
expect(handlePluginRequest).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test("passes POST webhook routes through root-mounted control ui to plugins", async () => {
|
||||
const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => {
|
||||
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
|
||||
|
||||
@@ -177,6 +177,12 @@ export type ClawHubPackageListItem = {
|
||||
capabilityTags?: string[];
|
||||
executesCode?: boolean;
|
||||
verificationTier?: string | null;
|
||||
stats?: {
|
||||
downloads?: number;
|
||||
installs?: number;
|
||||
stars?: number;
|
||||
versions?: number;
|
||||
} | null;
|
||||
clawpackAvailable?: boolean;
|
||||
hostTargetKeys?: string[];
|
||||
environmentFlags?: string[];
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// Plugin catalog search tests cover family queries, score merging, and bounded results.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
searchClawHubPackages: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../infra/clawhub.js", () => ({
|
||||
searchClawHubPackages: mocks.searchClawHubPackages,
|
||||
}));
|
||||
|
||||
const { searchInstallablePluginPackages } = await import("./catalog-search.js");
|
||||
|
||||
function searchResult(name: string, family: "code-plugin" | "bundle-plugin", score: number) {
|
||||
return {
|
||||
score,
|
||||
package: {
|
||||
name,
|
||||
displayName: name,
|
||||
family,
|
||||
channel: "community" as const,
|
||||
isOfficial: false,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("plugin catalog search", () => {
|
||||
beforeEach(() => {
|
||||
mocks.searchClawHubPackages.mockReset();
|
||||
});
|
||||
|
||||
it("queries both installable families and merges duplicate packages by best score", async () => {
|
||||
mocks.searchClawHubPackages
|
||||
.mockResolvedValueOnce([
|
||||
searchResult("shared", "code-plugin", 4),
|
||||
searchResult("code-only", "code-plugin", 8),
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
searchResult("shared", "bundle-plugin", 9),
|
||||
searchResult("bundle-only", "bundle-plugin", 6),
|
||||
]);
|
||||
|
||||
const results = await searchInstallablePluginPackages({ query: "calendar", limit: 2 });
|
||||
|
||||
expect(mocks.searchClawHubPackages).toHaveBeenNthCalledWith(1, {
|
||||
query: "calendar",
|
||||
family: "code-plugin",
|
||||
limit: 2,
|
||||
});
|
||||
expect(mocks.searchClawHubPackages).toHaveBeenNthCalledWith(2, {
|
||||
query: "calendar",
|
||||
family: "bundle-plugin",
|
||||
limit: 2,
|
||||
});
|
||||
expect(results.map((entry) => [entry.package.name, entry.score])).toEqual([
|
||||
["shared", 9],
|
||||
["code-only", 8],
|
||||
]);
|
||||
expect(results[0]?.package.family).toBe("bundle-plugin");
|
||||
});
|
||||
|
||||
it("uses the default limit for invalid programmatic values", async () => {
|
||||
mocks.searchClawHubPackages.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
|
||||
|
||||
await searchInstallablePluginPackages({ query: "calendar", limit: Number.NaN });
|
||||
|
||||
expect(mocks.searchClawHubPackages).toHaveBeenCalledWith({
|
||||
query: "calendar",
|
||||
family: "code-plugin",
|
||||
limit: 20,
|
||||
});
|
||||
expect(mocks.searchClawHubPackages).toHaveBeenCalledWith({
|
||||
query: "calendar",
|
||||
family: "bundle-plugin",
|
||||
limit: 20,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
// ClawHub-backed discovery for installable plugin package families.
|
||||
import {
|
||||
searchClawHubPackages,
|
||||
type ClawHubPackageFamily,
|
||||
type ClawHubPackageSearchResult,
|
||||
} from "../infra/clawhub.js";
|
||||
|
||||
const INSTALLABLE_PLUGIN_FAMILIES: readonly ClawHubPackageFamily[] = [
|
||||
"code-plugin",
|
||||
"bundle-plugin",
|
||||
];
|
||||
const DEFAULT_PLUGIN_SEARCH_LIMIT = 20;
|
||||
const MAX_PLUGIN_SEARCH_LIMIT = 100;
|
||||
|
||||
export type PluginCatalogSearchParams = {
|
||||
query: string;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
function resolveSearchLimit(limit: number | undefined): number {
|
||||
if (!Number.isFinite(limit) || !limit || limit <= 0) {
|
||||
return DEFAULT_PLUGIN_SEARCH_LIMIT;
|
||||
}
|
||||
return Math.min(Math.max(Math.trunc(limit), 1), MAX_PLUGIN_SEARCH_LIMIT);
|
||||
}
|
||||
|
||||
function mergePackageSearchResults(
|
||||
groups: readonly ClawHubPackageSearchResult[][],
|
||||
limit: number,
|
||||
): ClawHubPackageSearchResult[] {
|
||||
const byName = new Map<string, ClawHubPackageSearchResult>();
|
||||
for (const entry of groups.flat()) {
|
||||
const existing = byName.get(entry.package.name);
|
||||
if (!existing || entry.score > existing.score) {
|
||||
byName.set(entry.package.name, entry);
|
||||
}
|
||||
}
|
||||
// Stable sorting preserves family query order when ClawHub scores tie.
|
||||
return [...byName.values()].toSorted((left, right) => right.score - left.score).slice(0, limit);
|
||||
}
|
||||
|
||||
/** Searches installable ClawHub plugin families and merges duplicate packages by best score. */
|
||||
export async function searchInstallablePluginPackages(
|
||||
params: PluginCatalogSearchParams,
|
||||
): Promise<ClawHubPackageSearchResult[]> {
|
||||
const limit = resolveSearchLimit(params.limit);
|
||||
const groups = await Promise.all(
|
||||
INSTALLABLE_PLUGIN_FAMILIES.map((family) =>
|
||||
searchClawHubPackages({
|
||||
query: params.query,
|
||||
family,
|
||||
limit,
|
||||
}),
|
||||
),
|
||||
);
|
||||
return mergePackageSearchResults(groups, limit);
|
||||
}
|
||||
@@ -41,6 +41,9 @@ vi.mock("../version.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./install.js", () => ({
|
||||
PLUGIN_INSTALL_ERROR_CODE: {
|
||||
PLUGIN_ID_MISMATCH: "plugin_id_mismatch",
|
||||
},
|
||||
installPluginFromArchive: (...args: unknown[]) => installPluginFromArchiveMock(...args),
|
||||
}));
|
||||
|
||||
@@ -129,6 +132,25 @@ function mockCommunityClawHubPackageDetail() {
|
||||
});
|
||||
}
|
||||
|
||||
function mockOfficialClawHubPackageDetail(overrides: Record<string, unknown>): void {
|
||||
fetchClawHubPackageDetailMock.mockResolvedValueOnce({
|
||||
package: {
|
||||
name: "demo",
|
||||
displayName: "Demo",
|
||||
family: "code-plugin",
|
||||
channel: "official",
|
||||
isOfficial: true,
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
compatibility: {
|
||||
pluginApiRange: ">=2026.3.22",
|
||||
minGatewayVersion: "2026.3.0",
|
||||
},
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function expectClawHubInstallFlow(params: {
|
||||
baseUrl: string;
|
||||
version: string;
|
||||
@@ -180,6 +202,7 @@ type PackageLookupCall = {
|
||||
type ArchiveInstallCall = {
|
||||
archivePath?: string;
|
||||
dangerouslyForceUnsafeInstall?: boolean;
|
||||
expectedPluginId?: string;
|
||||
installPolicyRequest?: {
|
||||
kind?: string;
|
||||
requestedSpecifier?: string;
|
||||
@@ -385,6 +408,85 @@ describe("installPluginFromClawHub", () => {
|
||||
expect(archiveCleanupMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["package runtimeId", { runtimeId: "demo-runtime" }],
|
||||
["capabilities.runtimeId", { capabilities: { runtimeId: "demo-runtime" } }],
|
||||
])("pins archive installation to the advertised %s", async (_label, overrides) => {
|
||||
mockOfficialClawHubPackageDetail(overrides);
|
||||
installPluginFromArchiveMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
pluginId: "demo-runtime",
|
||||
targetDir: "/tmp/openclaw/plugins/demo-runtime",
|
||||
version: "2026.3.22",
|
||||
});
|
||||
|
||||
const result = await installPluginFromClawHub({ spec: "clawhub:demo" });
|
||||
|
||||
expect(expectInstallSuccess(result).pluginId).toBe("demo-runtime");
|
||||
expect(archiveInstallCall().expectedPluginId).toBe("demo-runtime");
|
||||
});
|
||||
|
||||
it("rejects caller and advertised runtime id mismatches before download", async () => {
|
||||
mockOfficialClawHubPackageDetail({ runtimeId: "advertised-runtime" });
|
||||
|
||||
const result = await installPluginFromClawHub({
|
||||
spec: "clawhub:demo",
|
||||
expectedPluginId: "expected-runtime",
|
||||
});
|
||||
|
||||
const failure = expectInstallFailure(result);
|
||||
expect(failure.code).toBe("plugin_id_mismatch");
|
||||
expect(failure.error).toBe(
|
||||
'ClawHub package runtime id mismatch: expected "expected-runtime", got "advertised-runtime".',
|
||||
);
|
||||
expect(downloadClawHubPackageArchiveMock).not.toHaveBeenCalled();
|
||||
expect(installPluginFromArchiveMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects inconsistent advertised runtime ids before download", async () => {
|
||||
mockOfficialClawHubPackageDetail({
|
||||
runtimeId: "package-runtime",
|
||||
capabilities: { runtimeId: "capabilities-runtime" },
|
||||
});
|
||||
|
||||
const result = await installPluginFromClawHub({ spec: "clawhub:demo" });
|
||||
|
||||
const failure = expectInstallFailure(result);
|
||||
expect(failure.code).toBe("plugin_id_mismatch");
|
||||
expect(failure.error).toBe(
|
||||
'ClawHub package runtime id mismatch: package advertises "package-runtime" but capabilities advertise "capabilities-runtime".',
|
||||
);
|
||||
expect(downloadClawHubPackageArchiveMock).not.toHaveBeenCalled();
|
||||
expect(installPluginFromArchiveMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts a matching catalog archive integrity pin", async () => {
|
||||
const result = await installPluginFromClawHub({
|
||||
spec: "clawhub:demo",
|
||||
expectedIntegrity: `sha256:${DEMO_ARCHIVE_SHA256}`,
|
||||
});
|
||||
|
||||
expectSuccessfulClawHubInstall(result);
|
||||
expect(installPluginFromArchiveMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects a catalog archive integrity mismatch before extraction", async () => {
|
||||
const expectedIntegrity = `sha256-${Buffer.from("1".repeat(64), "hex").toString("base64")}`;
|
||||
|
||||
const result = await installPluginFromClawHub({
|
||||
spec: "clawhub:demo",
|
||||
expectedIntegrity,
|
||||
});
|
||||
|
||||
expectInstallFailureFields(
|
||||
result,
|
||||
CLAWHUB_INSTALL_ERROR_CODE.ARCHIVE_INTEGRITY_MISMATCH,
|
||||
`ClawHub archive integrity mismatch for "demo@2026.3.22": expected ${expectedIntegrity}, got ${DEMO_ARCHIVE_INTEGRITY}.`,
|
||||
);
|
||||
expect(installPluginFromArchiveMock).not.toHaveBeenCalled();
|
||||
expect(archiveCleanupMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("marks custom ClawHub registries as third-party install policy authority", async () => {
|
||||
const result = await installPluginFromClawHub({
|
||||
spec: "clawhub:demo",
|
||||
|
||||
@@ -50,7 +50,11 @@ import type { RuntimeVersionEnv } from "../version.js";
|
||||
import { CLAWHUB_INSTALL_ERROR_CODE, type ClawHubInstallErrorCode } from "./clawhub-error-codes.js";
|
||||
import type { ClawHubPluginInstallRecordFields } from "./clawhub-install-records.js";
|
||||
import type { InstallSafetyOverrides } from "./install-security-scan.js";
|
||||
import { installPluginFromArchive, type InstallPluginResult } from "./install.js";
|
||||
import {
|
||||
installPluginFromArchive,
|
||||
PLUGIN_INSTALL_ERROR_CODE,
|
||||
type InstallPluginResult,
|
||||
} from "./install.js";
|
||||
|
||||
export { CLAWHUB_INSTALL_ERROR_CODE };
|
||||
export type { ClawHubInstallErrorCode, ClawHubRiskAcknowledgementRequest };
|
||||
@@ -69,6 +73,10 @@ type ClawHubInstallFailure = {
|
||||
version?: string;
|
||||
};
|
||||
|
||||
type ClawHubRuntimeIdResolution =
|
||||
| { ok: true; expectedPluginId?: string }
|
||||
| Extract<InstallPluginResult, { ok: false }>;
|
||||
|
||||
type ClawHubFileEntryLike = {
|
||||
path?: unknown;
|
||||
sha256?: unknown;
|
||||
@@ -390,6 +398,38 @@ function formatClawHubReleaseLabel(packageName: string, version: string): string
|
||||
return `${sanitizeTerminalText(packageName)}@${sanitizeTerminalText(version)}`;
|
||||
}
|
||||
|
||||
function resolveClawHubExpectedRuntimeId(params: {
|
||||
detail: ClawHubPackageDetail;
|
||||
expectedPluginId?: string;
|
||||
}): ClawHubRuntimeIdResolution {
|
||||
const packageRuntimeId = normalizeOptionalString(params.detail.package?.runtimeId);
|
||||
const capabilitiesRuntimeId = normalizeOptionalString(
|
||||
params.detail.package?.capabilities?.runtimeId,
|
||||
);
|
||||
if (packageRuntimeId && capabilitiesRuntimeId && packageRuntimeId !== capabilitiesRuntimeId) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `ClawHub package runtime id mismatch: package advertises "${sanitizeTerminalText(packageRuntimeId)}" but capabilities advertise "${sanitizeTerminalText(capabilitiesRuntimeId)}".`,
|
||||
code: PLUGIN_INSTALL_ERROR_CODE.PLUGIN_ID_MISMATCH,
|
||||
};
|
||||
}
|
||||
|
||||
const advertisedRuntimeId = packageRuntimeId ?? capabilitiesRuntimeId;
|
||||
const expectedPluginId = normalizeOptionalString(params.expectedPluginId);
|
||||
if (expectedPluginId && advertisedRuntimeId && expectedPluginId !== advertisedRuntimeId) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `ClawHub package runtime id mismatch: expected "${sanitizeTerminalText(expectedPluginId)}", got "${sanitizeTerminalText(advertisedRuntimeId)}".`,
|
||||
code: PLUGIN_INSTALL_ERROR_CODE.PLUGIN_ID_MISMATCH,
|
||||
};
|
||||
}
|
||||
const resolvedExpectedPluginId = expectedPluginId ?? advertisedRuntimeId;
|
||||
return {
|
||||
ok: true,
|
||||
...(resolvedExpectedPluginId ? { expectedPluginId: resolvedExpectedPluginId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function isMissingArtifactResolverRoute(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof ClawHubRequestError &&
|
||||
@@ -1168,6 +1208,7 @@ export async function installPluginFromClawHub(
|
||||
timeoutMs?: number;
|
||||
dryRun?: boolean;
|
||||
expectedPluginId?: string;
|
||||
expectedIntegrity?: string;
|
||||
env?: RuntimeVersionEnv;
|
||||
acknowledgeClawHubRisk?: boolean;
|
||||
onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => boolean | Promise<boolean>;
|
||||
@@ -1189,6 +1230,16 @@ export async function installPluginFromClawHub(
|
||||
CLAWHUB_INSTALL_ERROR_CODE.INVALID_SPEC,
|
||||
);
|
||||
}
|
||||
const expectedIntegrity =
|
||||
params.expectedIntegrity === undefined
|
||||
? undefined
|
||||
: normalizeClawHubSha256Integrity(params.expectedIntegrity);
|
||||
if (params.expectedIntegrity !== undefined && !expectedIntegrity) {
|
||||
return buildClawHubInstallFailure(
|
||||
`invalid expected ClawHub archive integrity: ${sanitizeTerminalText(params.expectedIntegrity)}`,
|
||||
CLAWHUB_INSTALL_ERROR_CODE.MISSING_ARCHIVE_INTEGRITY,
|
||||
);
|
||||
}
|
||||
|
||||
params.logger?.info?.(`Resolving ${formatClawHubSpecifier(parsed)}…`);
|
||||
let detail: ClawHubPackageDetail;
|
||||
@@ -1224,6 +1275,13 @@ export async function installPluginFromClawHub(
|
||||
if (validationFailure) {
|
||||
return validationFailure;
|
||||
}
|
||||
const runtimeIdResolution = resolveClawHubExpectedRuntimeId({
|
||||
detail,
|
||||
expectedPluginId: params.expectedPluginId,
|
||||
});
|
||||
if (!runtimeIdResolution.ok) {
|
||||
return runtimeIdResolution;
|
||||
}
|
||||
const expectedClawPackSha256 = resolveClawHubClawPackArtifactSha256(versionState.clawpack);
|
||||
const canonicalPackageName = detail.package?.name ?? parsed.name;
|
||||
const officialClawHubPackage = detail.package
|
||||
@@ -1307,14 +1365,20 @@ export async function installPluginFromClawHub(
|
||||
);
|
||||
}
|
||||
try {
|
||||
if (expectedIntegrity && archive.integrity !== expectedIntegrity) {
|
||||
return buildClawHubInstallFailure(
|
||||
`ClawHub archive integrity mismatch for "${releaseLabel}": expected ${expectedIntegrity}, got ${archive.integrity}.`,
|
||||
CLAWHUB_INSTALL_ERROR_CODE.ARCHIVE_INTEGRITY_MISMATCH,
|
||||
);
|
||||
}
|
||||
if (expectedClawPackSha256) {
|
||||
const expectedIntegrity = normalizeClawHubSha256Integrity(expectedClawPackSha256);
|
||||
const expectedClawPackIntegrity = normalizeClawHubSha256Integrity(expectedClawPackSha256);
|
||||
const expectedNpmIntegrity = resolveClawHubNpmIntegrity(versionState.clawpack);
|
||||
if (
|
||||
archive.artifact !== "clawpack" ||
|
||||
archive.clawpackHeaderSha256 !== expectedClawPackSha256 ||
|
||||
archive.sha256Hex !== expectedClawPackSha256 ||
|
||||
archive.integrity !== expectedIntegrity
|
||||
archive.integrity !== expectedClawPackIntegrity
|
||||
) {
|
||||
return buildClawHubInstallFailure(
|
||||
`ClawHub ClawPack integrity mismatch for "${releaseLabel}": expected ${expectedClawPackSha256}, got ${archive.sha256Hex}.`,
|
||||
@@ -1379,7 +1443,7 @@ export async function installPluginFromClawHub(
|
||||
extensionsDir: params.extensionsDir,
|
||||
timeoutMs: params.timeoutMs,
|
||||
dryRun: params.dryRun,
|
||||
expectedPluginId: params.expectedPluginId,
|
||||
expectedPluginId: runtimeIdResolution.expectedPluginId,
|
||||
installPolicyRequest: {
|
||||
kind: "plugin-archive",
|
||||
requestedSpecifier: params.spec,
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
// Plugin install persist tests cover saving installed plugin records after install.
|
||||
// Plugin install persistence tests cover saving installed plugin records after install.
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { hasRetainedManagedNpmInstallMarker } from "../plugins/managed-npm-retention.js";
|
||||
import {
|
||||
applyExclusiveSlotSelection,
|
||||
buildPluginDiagnosticsReport,
|
||||
@@ -21,7 +19,9 @@ import {
|
||||
writeConfigFile,
|
||||
writePersistedInstalledPluginIndexInstallRecords,
|
||||
applyPluginUninstallDirectoryRemoval,
|
||||
} from "./plugins-cli-test-helpers.js";
|
||||
} from "../cli/plugins-cli-test-helpers.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { hasRetainedManagedNpmInstallMarker } from "./managed-npm-retention.js";
|
||||
|
||||
function requireMockCallArg(
|
||||
mockFn: { mock: { calls: unknown[][] } },
|
||||
@@ -51,7 +51,7 @@ describe("persistPluginInstall", () => {
|
||||
});
|
||||
|
||||
it("adds installed plugins to restrictive allowlists before enabling", async () => {
|
||||
const { persistPluginInstall } = await import("./plugins-install-persist.js");
|
||||
const { persistPluginInstall } = await import("./install-persistence.js");
|
||||
const baseConfig = {
|
||||
plugins: {
|
||||
allow: ["memory-core"],
|
||||
@@ -130,7 +130,7 @@ describe("persistPluginInstall", () => {
|
||||
});
|
||||
|
||||
it("persists installs even when runtime cache invalidation fails", async () => {
|
||||
const { persistPluginInstall } = await import("./plugins-install-persist.js");
|
||||
const { persistPluginInstall } = await import("./install-persistence.js");
|
||||
const baseConfig = {
|
||||
plugins: {
|
||||
entries: {},
|
||||
@@ -168,7 +168,7 @@ describe("persistPluginInstall", () => {
|
||||
});
|
||||
|
||||
it("removes a replaced managed install directory before refreshing the registry", async () => {
|
||||
const { persistPluginInstall } = await import("./plugins-install-persist.js");
|
||||
const { persistPluginInstall } = await import("./install-persistence.js");
|
||||
const baseConfig = {
|
||||
plugins: {
|
||||
entries: {},
|
||||
@@ -255,7 +255,7 @@ describe("persistPluginInstall", () => {
|
||||
});
|
||||
|
||||
it("preserves replaced install directories when the new install path overlaps", async () => {
|
||||
const { persistPluginInstall } = await import("./plugins-install-persist.js");
|
||||
const { persistPluginInstall } = await import("./install-persistence.js");
|
||||
const baseConfig = {
|
||||
plugins: {
|
||||
entries: {},
|
||||
@@ -296,7 +296,7 @@ describe("persistPluginInstall", () => {
|
||||
});
|
||||
|
||||
it("preserves replaced npm install directories across generation updates", async () => {
|
||||
const { persistPluginInstall } = await import("./plugins-install-persist.js");
|
||||
const { persistPluginInstall } = await import("./install-persistence.js");
|
||||
const baseConfig = {
|
||||
plugins: {
|
||||
entries: {},
|
||||
@@ -398,7 +398,7 @@ describe("persistPluginInstall", () => {
|
||||
});
|
||||
|
||||
it("warns when an installed npm plugin remains shadowed by a config-selected source", async () => {
|
||||
const { persistPluginInstall } = await import("./plugins-install-persist.js");
|
||||
const { persistPluginInstall } = await import("./install-persistence.js");
|
||||
const baseConfig = {
|
||||
plugins: {
|
||||
entries: {},
|
||||
@@ -457,7 +457,7 @@ describe("persistPluginInstall", () => {
|
||||
});
|
||||
|
||||
it("does not warn when the config-selected source is inside the npm install path", async () => {
|
||||
const { persistPluginInstall } = await import("./plugins-install-persist.js");
|
||||
const { persistPluginInstall } = await import("./install-persistence.js");
|
||||
const baseConfig = {
|
||||
plugins: {
|
||||
entries: {},
|
||||
@@ -501,7 +501,7 @@ describe("persistPluginInstall", () => {
|
||||
});
|
||||
|
||||
it("invalidates runtime cache even when registry refresh fails", async () => {
|
||||
const { persistPluginInstall } = await import("./plugins-install-persist.js");
|
||||
const { persistPluginInstall } = await import("./install-persistence.js");
|
||||
const baseConfig = {
|
||||
plugins: {
|
||||
entries: {},
|
||||
@@ -538,7 +538,7 @@ describe("persistPluginInstall", () => {
|
||||
});
|
||||
|
||||
it("skips runtime cache invalidation when the caller opts out", async () => {
|
||||
const { persistPluginInstall } = await import("./plugins-install-persist.js");
|
||||
const { persistPluginInstall } = await import("./install-persistence.js");
|
||||
const baseConfig = {
|
||||
plugins: {
|
||||
entries: {},
|
||||
@@ -574,7 +574,7 @@ describe("persistPluginInstall", () => {
|
||||
});
|
||||
|
||||
it("removes stale denylist entries before enabling installed plugins", async () => {
|
||||
const { persistPluginInstall } = await import("./plugins-install-persist.js");
|
||||
const { persistPluginInstall } = await import("./install-persistence.js");
|
||||
const baseConfig = {
|
||||
plugins: {
|
||||
deny: ["alpha", "other"],
|
||||
@@ -613,7 +613,7 @@ describe("persistPluginInstall", () => {
|
||||
});
|
||||
|
||||
it("scopes runtime kind lookup to the selected plugin when metadata omits kind", async () => {
|
||||
const { persistPluginInstall } = await import("./plugins-install-persist.js");
|
||||
const { persistPluginInstall } = await import("./install-persistence.js");
|
||||
const baseConfig = {
|
||||
plugins: {
|
||||
entries: {
|
||||
@@ -690,7 +690,7 @@ describe("persistPluginInstall", () => {
|
||||
});
|
||||
|
||||
it("uses cold metadata for manifest-kind slot selection without loading runtime siblings", async () => {
|
||||
const { persistPluginInstall } = await import("./plugins-install-persist.js");
|
||||
const { persistPluginInstall } = await import("./install-persistence.js");
|
||||
const baseConfig = {
|
||||
plugins: {
|
||||
entries: {
|
||||
@@ -759,7 +759,7 @@ describe("persistPluginInstall", () => {
|
||||
});
|
||||
|
||||
it("does not load every plugin runtime for non-slot installs without manifest kind", async () => {
|
||||
const { persistPluginInstall } = await import("./plugins-install-persist.js");
|
||||
const { persistPluginInstall } = await import("./install-persistence.js");
|
||||
const baseConfig = {
|
||||
plugins: {
|
||||
entries: {},
|
||||
@@ -813,7 +813,7 @@ describe("persistPluginInstall", () => {
|
||||
});
|
||||
|
||||
it("can persist an install record without enabling a plugin that needs config first", async () => {
|
||||
const { persistPluginInstall } = await import("./plugins-install-persist.js");
|
||||
const { persistPluginInstall } = await import("./install-persistence.js");
|
||||
const baseConfig = {
|
||||
plugins: {
|
||||
entries: {},
|
||||
@@ -854,7 +854,7 @@ describe("persistPluginInstall", () => {
|
||||
});
|
||||
|
||||
it("does not add disabled installs to restrictive allowlists", async () => {
|
||||
const { persistPluginInstall } = await import("./plugins-install-persist.js");
|
||||
const { persistPluginInstall } = await import("./install-persistence.js");
|
||||
const baseConfig = {
|
||||
plugins: {
|
||||
allow: ["memory-core"],
|
||||
@@ -1,9 +1,8 @@
|
||||
// Persistence helpers for plugin and hook-pack installs plus related config mutation.
|
||||
// Persistence helpers for plugin installs plus related config mutation.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { theme } from "../../packages/terminal-core/src/theme.js";
|
||||
import { replaceConfigFile } from "../config/config.js";
|
||||
import {
|
||||
hashConfigIncludeRaw,
|
||||
readConfigIncludeFileWithGuards,
|
||||
@@ -12,33 +11,27 @@ import {
|
||||
import type { ConfigWriteOptions } from "../config/io.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { PluginInstallRecord } from "../config/types.plugins.js";
|
||||
import { type HookInstallUpdate, recordHookInstall } from "../hooks/installs.js";
|
||||
import { isPathInside } from "../infra/path-guards.js";
|
||||
import { enablePluginInConfig } from "../plugins/enable.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
import { resolveUserPath, shortenHomePath } from "../utils.js";
|
||||
import { parseJsonWithJson5Fallback } from "../utils/parse-json-compat.js";
|
||||
import { enablePluginInConfig } from "./enable.js";
|
||||
import { commitPluginInstallRecordsWithConfig } from "./install-record-commit.js";
|
||||
import {
|
||||
loadInstalledPluginIndexInstallRecords,
|
||||
recordPluginInstallInRecords,
|
||||
withoutPluginInstallRecords,
|
||||
} from "../plugins/installed-plugin-index-records.js";
|
||||
import type { PluginInstallUpdate } from "../plugins/installs.js";
|
||||
import { tracePluginLifecyclePhaseAsync } from "../plugins/plugin-lifecycle-trace.js";
|
||||
import { buildPluginSnapshotReport } from "../plugins/status.js";
|
||||
} from "./installed-plugin-index-records.js";
|
||||
import type { PluginInstallUpdate } from "./installs.js";
|
||||
import { tracePluginLifecyclePhaseAsync } from "./plugin-lifecycle-trace.js";
|
||||
import { refreshPluginRegistryAfterConfigMutation } from "./registry-refresh.js";
|
||||
import { applySlotSelectionForPlugin } from "./slot-selection.js";
|
||||
import { buildPluginSnapshotReport } from "./status.js";
|
||||
import {
|
||||
applyPluginUninstallDirectoryRemoval,
|
||||
planPluginUninstall,
|
||||
type PluginUninstallDirectoryRemoval,
|
||||
} from "../plugins/uninstall.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
import { resolveUserPath, shortenHomePath } from "../utils.js";
|
||||
import { parseJsonWithJson5Fallback } from "../utils/parse-json-compat.js";
|
||||
import {
|
||||
applySlotSelectionForPlugin,
|
||||
enableInternalHookEntries,
|
||||
logHookPackRestartHint,
|
||||
logSlotWarnings,
|
||||
} from "./plugins-command-helpers.js";
|
||||
import { commitPluginInstallRecordsWithConfig } from "./plugins-install-record-commit.js";
|
||||
import { refreshPluginRegistryAfterConfigMutation } from "./plugins-registry-refresh.js";
|
||||
} from "./uninstall.js";
|
||||
|
||||
function addInstalledPluginToAllowlist(cfg: OpenClawConfig, pluginId: string): OpenClawConfig {
|
||||
const allow = cfg.plugins?.allow;
|
||||
@@ -358,6 +351,12 @@ function logShadowedNpmInstallWarning(params: {
|
||||
);
|
||||
}
|
||||
|
||||
function logSlotWarnings(warnings: string[], runtime: RuntimeEnv): void {
|
||||
for (const warning of warnings) {
|
||||
runtime.log(theme.warn(warning));
|
||||
}
|
||||
}
|
||||
|
||||
function resolveComparableInstallPath(
|
||||
install: Pick<PluginInstallRecord, "installPath" | "sourcePath">,
|
||||
) {
|
||||
@@ -530,28 +529,3 @@ export async function persistPluginInstall(params: {
|
||||
runtime.log("Restart the gateway to load plugins.");
|
||||
return next;
|
||||
}
|
||||
|
||||
export async function persistHookPackInstall(params: {
|
||||
snapshot: ConfigSnapshotForInstallPersist;
|
||||
hookPackId: string;
|
||||
hooks: string[];
|
||||
install: Omit<HookInstallUpdate, "hookId" | "hooks">;
|
||||
successMessage?: string;
|
||||
runtime?: RuntimeEnv;
|
||||
}): Promise<OpenClawConfig> {
|
||||
const runtime = params.runtime ?? defaultRuntime;
|
||||
let next = enableInternalHookEntries(params.snapshot.config, params.hooks);
|
||||
next = recordHookInstall(next, {
|
||||
hookId: params.hookPackId,
|
||||
hooks: params.hooks,
|
||||
...params.install,
|
||||
});
|
||||
await replaceConfigFile({
|
||||
nextConfig: next,
|
||||
baseHash: params.snapshot.baseHash,
|
||||
writeOptions: params.snapshot.writeOptions,
|
||||
});
|
||||
runtime.log(params.successMessage ?? `Installed hook pack: ${params.hookPackId}`);
|
||||
logHookPackRestartHint(runtime);
|
||||
return next;
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
// Plugin install record commit tests cover install record persistence after CLI installs.
|
||||
// Plugin install record commit tests cover install record persistence.
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { PluginInstallRecord } from "../config/types.plugins.js";
|
||||
import { withEnvAsync } from "../test-utils/env.js";
|
||||
import {
|
||||
hasRetainedManagedNpmInstallMarker,
|
||||
markRetainedManagedNpmInstall,
|
||||
} from "../plugins/managed-npm-retention.js";
|
||||
import { withEnvAsync } from "../test-utils/env.js";
|
||||
} from "./managed-npm-retention.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
loadInstalledPluginIndexInstallRecords: vi.fn(),
|
||||
@@ -24,9 +24,8 @@ vi.mock("../config/config.js", () => ({
|
||||
transformConfigFileWithRetry: mocks.transformConfigFileWithRetry,
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/installed-plugin-index-records.js", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("../plugins/installed-plugin-index-records.js")>();
|
||||
vi.mock("./installed-plugin-index-records.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./installed-plugin-index-records.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadInstalledPluginIndexInstallRecords: mocks.loadInstalledPluginIndexInstallRecords,
|
||||
@@ -42,7 +41,7 @@ import {
|
||||
stripPendingPluginInstallRecords,
|
||||
transformConfigWithPendingPluginInstalls,
|
||||
unchangedPendingPluginInstallRecordIds,
|
||||
} from "./plugins-install-record-commit.js";
|
||||
} from "./install-record-commit.js";
|
||||
|
||||
describe("commitConfigWithPendingPluginInstalls", () => {
|
||||
beforeEach(() => {
|
||||
@@ -21,14 +21,14 @@ import {
|
||||
PLUGIN_INSTALLS_CONFIG_PATH,
|
||||
withoutPluginInstallRecords,
|
||||
writePersistedInstalledPluginIndexInstallRecords,
|
||||
} from "../plugins/installed-plugin-index-records.js";
|
||||
} from "./installed-plugin-index-records.js";
|
||||
import {
|
||||
clearRetainedManagedNpmInstallMarker,
|
||||
markRetainedManagedNpmInstall,
|
||||
resolveRetainedManagedNpmInstallPackageInfo,
|
||||
resolveRetainedManagedNpmInstallMarkerPath,
|
||||
} from "../plugins/managed-npm-retention.js";
|
||||
import { planPluginUninstall } from "../plugins/uninstall.js";
|
||||
} from "./managed-npm-retention.js";
|
||||
import { planPluginUninstall } from "./uninstall.js";
|
||||
|
||||
function mergeUnsetPaths(
|
||||
left?: ConfigWriteOptions["unsetPaths"],
|
||||
@@ -0,0 +1,969 @@
|
||||
// Plugin management service tests cover cold state, catalog identity, and guarded mutations.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
applyUninstall: vi.fn(),
|
||||
clawhubInstall: vi.fn(),
|
||||
commitRecords: vi.fn(),
|
||||
installRecords: vi.fn(),
|
||||
metadata: vi.fn(),
|
||||
npmInstall: vi.fn(),
|
||||
officialCatalog: vi.fn(),
|
||||
persistInstall: vi.fn(),
|
||||
preflight: vi.fn(),
|
||||
readConfig: vi.fn(),
|
||||
refreshRegistry: vi.fn(),
|
||||
replaceConfig: vi.fn(),
|
||||
planUninstall: vi.fn(),
|
||||
selectWriteOptions: vi.fn((writeOptions: unknown) => writeOptions),
|
||||
slotSelection: vi.fn((config: unknown): { config: unknown; warnings: string[] } => ({
|
||||
config,
|
||||
warnings: [],
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
assertConfigWriteAllowedInCurrentMode: (params?: { env?: NodeJS.ProcessEnv }) => {
|
||||
if (params?.env?.OPENCLAW_NIX_MODE === "1") {
|
||||
throw new Error("Config is managed by Nix");
|
||||
}
|
||||
},
|
||||
readConfigFileSnapshotForWrite: () => mocks.readConfig(),
|
||||
replaceConfigFile: (params: unknown) => mocks.replaceConfig(params),
|
||||
}));
|
||||
|
||||
vi.mock("./install-persistence.js", () => ({
|
||||
persistPluginInstall: (...args: unknown[]) => mocks.persistInstall(...args),
|
||||
resolveInstallConfigMutationPreflights: (...args: unknown[]) => mocks.preflight(...args),
|
||||
selectInstallMutationWriteOptions: (writeOptions: unknown) =>
|
||||
mocks.selectWriteOptions(writeOptions),
|
||||
}));
|
||||
|
||||
vi.mock("./slot-selection.js", () => ({
|
||||
applySlotSelectionForPlugin: (config: unknown) => mocks.slotSelection(config),
|
||||
}));
|
||||
|
||||
vi.mock("./registry-refresh.js", () => ({
|
||||
refreshPluginRegistryAfterConfigMutation: (...args: unknown[]) => mocks.refreshRegistry(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./plugin-metadata-snapshot.js", () => ({
|
||||
loadPluginMetadataSnapshot: (...args: unknown[]) => mocks.metadata(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./clawhub.js", () => ({
|
||||
installPluginFromClawHub: (...args: unknown[]) => mocks.clawhubInstall(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./install.js", () => ({
|
||||
installPluginFromNpmSpec: (...args: unknown[]) => mocks.npmInstall(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./installed-plugin-index-records.js", async (importOriginal) => ({
|
||||
// Keep the pure config/record helpers real; only record IO is stubbed.
|
||||
...(await importOriginal<typeof import("./installed-plugin-index-records.js")>()),
|
||||
loadInstalledPluginIndexInstallRecords: (...args: unknown[]) => mocks.installRecords(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./uninstall.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./uninstall.js")>()),
|
||||
applyPluginUninstallDirectoryRemoval: (...args: unknown[]) => mocks.applyUninstall(...args),
|
||||
planPluginUninstall: (...args: unknown[]) => mocks.planUninstall(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./install-record-commit.js", () => ({
|
||||
commitPluginInstallRecordsWithConfig: (...args: unknown[]) => mocks.commitRecords(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./official-external-plugin-catalog.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./official-external-plugin-catalog.js")>()),
|
||||
loadConfiguredHostedOfficialExternalPluginCatalogEntries: (...args: unknown[]) =>
|
||||
mocks.officialCatalog(...args),
|
||||
}));
|
||||
|
||||
const {
|
||||
clearManagedPluginOfficialCatalogCache,
|
||||
installManagedPlugin,
|
||||
listManagedPlugins,
|
||||
overlayBundledOfficialPluginCatalogMetadata,
|
||||
setManagedPluginEnabled,
|
||||
uninstallManagedPlugin,
|
||||
} = await import("./management-service.js");
|
||||
|
||||
function configSnapshot(config: Record<string, unknown> = {}) {
|
||||
return {
|
||||
snapshot: {
|
||||
valid: true,
|
||||
parsed: {},
|
||||
path: "/tmp/openclaw.json",
|
||||
sourceConfig: config,
|
||||
hash: "base-hash",
|
||||
},
|
||||
writeOptions: {
|
||||
expectedConfigPath: "/tmp/openclaw.json",
|
||||
includeFileHashesForWrite: { "/tmp/plugins.json": "include-hash" },
|
||||
includeFileTargetsForWrite: { "/tmp/plugins.json": "/tmp/plugins.json" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function metadataSnapshot(params: {
|
||||
enabled: boolean;
|
||||
id?: string;
|
||||
name?: string;
|
||||
origin?: "bundled" | "global";
|
||||
installRecord?: Record<string, unknown>;
|
||||
}) {
|
||||
const id = params.id ?? "workboard";
|
||||
const manifest = {
|
||||
id,
|
||||
name: params.name ?? "Workboard",
|
||||
description: "Coordinate agent work in a shared board.",
|
||||
catalog: { featured: true, order: 10 },
|
||||
channels: [],
|
||||
providers: [],
|
||||
cliBackends: [],
|
||||
skills: [],
|
||||
hooks: [],
|
||||
origin: params.origin ?? "bundled",
|
||||
rootDir: `/tmp/${id}`,
|
||||
source: `/tmp/${id}/index.ts`,
|
||||
manifestPath: `/tmp/${id}/openclaw.plugin.json`,
|
||||
};
|
||||
return {
|
||||
index: {
|
||||
plugins: [
|
||||
{
|
||||
pluginId: id,
|
||||
packageName: `@openclaw/${id}`,
|
||||
origin: params.origin ?? "bundled",
|
||||
enabled: params.enabled,
|
||||
},
|
||||
],
|
||||
installRecords: params.installRecord ? { [id]: params.installRecord } : {},
|
||||
},
|
||||
byPluginId: new Map([[id, manifest]]),
|
||||
plugins: [manifest],
|
||||
diagnostics: [],
|
||||
normalizePluginId: (pluginId: string) => pluginId,
|
||||
};
|
||||
}
|
||||
|
||||
function emptyMetadataSnapshot() {
|
||||
return {
|
||||
index: { plugins: [], installRecords: {} },
|
||||
byPluginId: new Map(),
|
||||
plugins: [],
|
||||
diagnostics: [],
|
||||
normalizePluginId: (pluginId: string) => pluginId,
|
||||
};
|
||||
}
|
||||
|
||||
const hostedDiffsEntry = {
|
||||
name: "@openclaw/diffs",
|
||||
version: "2.0.0",
|
||||
description: "Hosted description",
|
||||
openclaw: {
|
||||
plugin: { id: "diffs", label: "Hosted Diffs" },
|
||||
install: { clawhubSpec: "clawhub:@openclaw/diffs", defaultChoice: "clawhub" },
|
||||
},
|
||||
};
|
||||
|
||||
const bundledDiffsEntry = {
|
||||
name: "@openclaw/diffs",
|
||||
version: "1.0.0",
|
||||
description: "Bundled description",
|
||||
openclaw: {
|
||||
plugin: { id: "diffs", label: "Bundled Diffs" },
|
||||
catalog: { featured: true, order: 40 },
|
||||
install: { clawhubSpec: "clawhub:@openclaw/diffs", defaultChoice: "npm" },
|
||||
},
|
||||
};
|
||||
|
||||
// Mirrors the current default ClawHub feed shape: package identity lives in a
|
||||
// source candidate while runtime/editorial metadata remains local.
|
||||
const hostedFeedDiffsEntry = {
|
||||
id: "@openclaw/diffs",
|
||||
title: "Diffs",
|
||||
state: "available",
|
||||
publisher: { id: "openclaw", trust: "official" },
|
||||
install: {
|
||||
candidates: [
|
||||
{
|
||||
sourceRef: "public-clawhub",
|
||||
package: "@openclaw/diffs",
|
||||
version: "2026.6.11",
|
||||
integrity: `sha256:${"a".repeat(64)}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
describe("plugin management service", () => {
|
||||
beforeEach(() => {
|
||||
clearManagedPluginOfficialCatalogCache();
|
||||
for (const mock of Object.values(mocks)) {
|
||||
if (typeof mock === "function" && "mockReset" in mock) {
|
||||
mock.mockReset();
|
||||
}
|
||||
}
|
||||
mocks.selectWriteOptions.mockImplementation((writeOptions) => writeOptions);
|
||||
mocks.preflight.mockReturnValue({
|
||||
hookMutation: { mode: "allowed" },
|
||||
pluginMutation: { mode: "allowed" },
|
||||
});
|
||||
mocks.slotSelection.mockImplementation((config) => ({ config, warnings: [] }));
|
||||
mocks.installRecords.mockResolvedValue({});
|
||||
mocks.applyUninstall.mockResolvedValue({ directoryRemoved: true, warnings: [] });
|
||||
mocks.officialCatalog.mockResolvedValue({
|
||||
source: "hosted",
|
||||
entries: [],
|
||||
feed: { schemaVersion: 1, id: "test", generatedAt: "now", sequence: 1, entries: [] },
|
||||
metadata: { url: "https://clawhub.ai/feed", status: 200, checksum: "hash" },
|
||||
});
|
||||
});
|
||||
|
||||
it("overlays bundled runtime identity and curation while keeping hosted install metadata", () => {
|
||||
const [merged] = overlayBundledOfficialPluginCatalogMetadata(
|
||||
[hostedDiffsEntry] as never,
|
||||
[bundledDiffsEntry] as never,
|
||||
);
|
||||
|
||||
expect(merged).toMatchObject({
|
||||
name: "@openclaw/diffs",
|
||||
version: "2.0.0",
|
||||
description: "Hosted description",
|
||||
openclaw: {
|
||||
plugin: { id: "diffs", label: "Bundled Diffs" },
|
||||
install: { clawhubSpec: "clawhub:@openclaw/diffs", defaultChoice: "clawhub" },
|
||||
catalog: { featured: true, order: 40 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes the current package-shaped hosted feed against exact local identity", async () => {
|
||||
const [merged] = overlayBundledOfficialPluginCatalogMetadata(
|
||||
[hostedFeedDiffsEntry] as never,
|
||||
[bundledDiffsEntry] as never,
|
||||
);
|
||||
mocks.metadata.mockReturnValue(emptyMetadataSnapshot());
|
||||
|
||||
const catalog = await listManagedPlugins({
|
||||
config: {},
|
||||
env: {},
|
||||
officialCatalog: { entries: merged ? [merged] : [] },
|
||||
});
|
||||
|
||||
expect(merged).toMatchObject({
|
||||
id: "@openclaw/diffs",
|
||||
title: "Diffs",
|
||||
name: "@openclaw/diffs",
|
||||
description: "Bundled description",
|
||||
install: hostedFeedDiffsEntry.install,
|
||||
openclaw: {
|
||||
plugin: { id: "diffs", label: "Bundled Diffs" },
|
||||
catalog: { featured: true, order: 40 },
|
||||
},
|
||||
});
|
||||
expect(catalog.plugins).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "diffs",
|
||||
name: "Bundled Diffs",
|
||||
installed: false,
|
||||
featured: true,
|
||||
order: 40,
|
||||
install: { source: "official", pluginId: "diffs" },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("deduplicates a package-shaped hosted row against its installed runtime id", async () => {
|
||||
const [merged] = overlayBundledOfficialPluginCatalogMetadata(
|
||||
[hostedFeedDiffsEntry] as never,
|
||||
[bundledDiffsEntry] as never,
|
||||
);
|
||||
mocks.metadata.mockReturnValue(
|
||||
metadataSnapshot({ enabled: true, id: "diffs", name: "Diffs", origin: "global" }),
|
||||
);
|
||||
|
||||
const catalog = await listManagedPlugins({
|
||||
config: {},
|
||||
env: {},
|
||||
officialCatalog: { entries: merged ? [merged] : [] },
|
||||
});
|
||||
|
||||
expect(catalog.plugins).toHaveLength(1);
|
||||
expect(catalog.plugins[0]).toMatchObject({ id: "diffs", installed: true, enabled: true });
|
||||
});
|
||||
|
||||
it("does not transfer endorsement to a hosted entry that only reuses the plugin id", () => {
|
||||
const impostor = {
|
||||
...hostedDiffsEntry,
|
||||
name: "community/impostor",
|
||||
openclaw: {
|
||||
...hostedDiffsEntry.openclaw,
|
||||
install: { clawhubSpec: "clawhub:community/impostor", defaultChoice: "clawhub" },
|
||||
},
|
||||
};
|
||||
|
||||
const [merged] = overlayBundledOfficialPluginCatalogMetadata(
|
||||
[impostor] as never,
|
||||
[bundledDiffsEntry] as never,
|
||||
);
|
||||
|
||||
expect(merged?.openclaw?.catalog).toBeUndefined();
|
||||
});
|
||||
|
||||
it("normalizes hosted catalog hints before building the public DTO", async () => {
|
||||
mocks.metadata.mockReturnValue(emptyMetadataSnapshot());
|
||||
|
||||
const catalog = await listManagedPlugins({
|
||||
config: {},
|
||||
env: {},
|
||||
officialCatalog: {
|
||||
entries: [
|
||||
{
|
||||
name: "community/partial",
|
||||
openclaw: {
|
||||
plugin: { id: "partial", label: "Partial" },
|
||||
catalog: { featured: "yes", order: 25 },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "community/invalid",
|
||||
openclaw: {
|
||||
plugin: { id: "invalid", label: "Invalid" },
|
||||
catalog: { featured: "yes", order: "first" },
|
||||
},
|
||||
},
|
||||
] as never,
|
||||
},
|
||||
});
|
||||
|
||||
expect(catalog.plugins).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "partial",
|
||||
order: 25,
|
||||
}),
|
||||
]);
|
||||
expect(catalog.plugins[0]).not.toHaveProperty("featured");
|
||||
});
|
||||
|
||||
it("lists bundled Workboard as installed, default-off, and cold-disabled", async () => {
|
||||
mocks.metadata.mockReturnValue(metadataSnapshot({ enabled: false }));
|
||||
|
||||
const catalog = await listManagedPlugins({
|
||||
config: {},
|
||||
env: {},
|
||||
officialCatalog: { entries: [] },
|
||||
});
|
||||
|
||||
expect(catalog.plugins).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "workboard",
|
||||
packageName: "@openclaw/workboard",
|
||||
installed: true,
|
||||
enabled: false,
|
||||
state: "disabled",
|
||||
featured: true,
|
||||
order: 10,
|
||||
}),
|
||||
]);
|
||||
expect(catalog.mutationAllowed).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses mutation in Nix mode before reading or writing config", async () => {
|
||||
await expect(
|
||||
setManagedPluginEnabled({
|
||||
pluginId: "workboard",
|
||||
enabled: true,
|
||||
env: { OPENCLAW_NIX_MODE: "1" },
|
||||
}),
|
||||
).rejects.toThrow("managed by Nix");
|
||||
expect(mocks.readConfig).not.toHaveBeenCalled();
|
||||
expect(mocks.replaceConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks unsupported plugin includes before config mutation", async () => {
|
||||
mocks.readConfig.mockResolvedValue(configSnapshot());
|
||||
mocks.preflight.mockReturnValue({
|
||||
hookMutation: { mode: "allowed" },
|
||||
pluginMutation: { mode: "blocked", reason: "nested plugins include" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
setManagedPluginEnabled({ pluginId: "workboard", enabled: true, env: {} }),
|
||||
).rejects.toThrow("nested plugins include");
|
||||
expect(mocks.replaceConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves config hash and include ownership when enabling Workboard", async () => {
|
||||
const prepared = configSnapshot();
|
||||
mocks.readConfig.mockResolvedValue(prepared);
|
||||
mocks.metadata
|
||||
.mockReturnValueOnce(metadataSnapshot({ enabled: false }))
|
||||
.mockReturnValueOnce(metadataSnapshot({ enabled: true }));
|
||||
mocks.replaceConfig.mockResolvedValue({});
|
||||
mocks.refreshRegistry.mockResolvedValue(undefined);
|
||||
|
||||
const result = await setManagedPluginEnabled({
|
||||
pluginId: "workboard",
|
||||
enabled: true,
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(mocks.replaceConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseHash: "base-hash",
|
||||
writeOptions: prepared.writeOptions,
|
||||
}),
|
||||
);
|
||||
expect(mocks.refreshRegistry).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
reason: "policy-changed",
|
||||
policyPluginIds: ["workboard"],
|
||||
}),
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
plugin: { id: "workboard", enabled: true, state: "enabled" },
|
||||
changedPaths: ["plugins"],
|
||||
});
|
||||
});
|
||||
|
||||
it("reports exclusive-slot side effects in established plugin config", async () => {
|
||||
const config = {
|
||||
plugins: {
|
||||
entries: { workboard: { enabled: false } },
|
||||
slots: { memory: "memory-core" },
|
||||
},
|
||||
};
|
||||
mocks.readConfig.mockResolvedValue(configSnapshot(config));
|
||||
mocks.slotSelection.mockImplementation((next) => ({
|
||||
config: {
|
||||
...(next as Record<string, unknown>),
|
||||
plugins: {
|
||||
...(next as { plugins?: Record<string, unknown> }).plugins,
|
||||
slots: { memory: "workboard" },
|
||||
},
|
||||
},
|
||||
warnings: ["Selected workboard for the memory slot."],
|
||||
}));
|
||||
mocks.replaceConfig.mockResolvedValue({});
|
||||
mocks.refreshRegistry.mockResolvedValue(undefined);
|
||||
mocks.metadata
|
||||
.mockReturnValueOnce(metadataSnapshot({ enabled: false }))
|
||||
.mockReturnValueOnce(metadataSnapshot({ enabled: true }));
|
||||
|
||||
const result = await setManagedPluginEnabled({
|
||||
pluginId: "workboard",
|
||||
enabled: true,
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(result.changedPaths).toEqual([
|
||||
"plugins.entries.workboard.enabled",
|
||||
"plugins.slots.memory",
|
||||
]);
|
||||
expect(result.warnings).toEqual(["Selected workboard for the memory slot."]);
|
||||
});
|
||||
|
||||
it("pins curated ClawHub installs to the expected runtime id", async () => {
|
||||
mocks.readConfig.mockResolvedValue(configSnapshot());
|
||||
mocks.officialCatalog.mockResolvedValue({
|
||||
source: "hosted",
|
||||
entries: [hostedFeedDiffsEntry],
|
||||
feed: { schemaVersion: 1, id: "test", generatedAt: "now", sequence: 1, entries: [] },
|
||||
metadata: { url: "https://clawhub.ai/feed", status: 200, checksum: "hash" },
|
||||
});
|
||||
mocks.clawhubInstall.mockResolvedValue({
|
||||
ok: true,
|
||||
pluginId: "impostor",
|
||||
targetDir: "/tmp/extensions/impostor",
|
||||
extensions: ["index.js"],
|
||||
packageName: "@openclaw/diffs",
|
||||
clawhub: {
|
||||
source: "clawhub",
|
||||
clawhubUrl: "https://clawhub.ai",
|
||||
clawhubPackage: "@openclaw/diffs",
|
||||
clawhubFamily: "code-plugin",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
installManagedPlugin({
|
||||
request: {
|
||||
source: "clawhub",
|
||||
packageName: "@openclaw/diffs",
|
||||
acknowledgeClawHubRisk: true,
|
||||
},
|
||||
env: {},
|
||||
}),
|
||||
).rejects.toThrow("expected diffs, got impostor");
|
||||
expect(mocks.clawhubInstall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
spec: "clawhub:@openclaw/diffs@2026.6.11",
|
||||
expectedPluginId: "diffs",
|
||||
expectedIntegrity: `sha256-${Buffer.from("a".repeat(64), "hex").toString("base64")}`,
|
||||
acknowledgeClawHubRisk: true,
|
||||
}),
|
||||
);
|
||||
expect(mocks.persistInstall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not pin a runtime id when the hosted entry only exposes its package name", async () => {
|
||||
const installRecord = {
|
||||
source: "clawhub",
|
||||
spec: "clawhub:@openclaw/bluebubbles",
|
||||
installPath: "/tmp/extensions/bluebubbles",
|
||||
};
|
||||
mocks.readConfig.mockResolvedValue(configSnapshot());
|
||||
mocks.officialCatalog.mockResolvedValue({
|
||||
source: "hosted",
|
||||
// Hosted feed row without a declared runtime id: the id falls back to
|
||||
// the package name, which must not become an expectedPluginId pin.
|
||||
entries: [
|
||||
{
|
||||
id: "@openclaw/bluebubbles",
|
||||
title: "BlueBubbles",
|
||||
state: "available",
|
||||
publisher: { id: "openclaw", trust: "official" },
|
||||
install: {
|
||||
candidates: [{ sourceRef: "public-clawhub", package: "@openclaw/bluebubbles" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
feed: { schemaVersion: 1, id: "test", generatedAt: "now", sequence: 1, entries: [] },
|
||||
metadata: { url: "https://clawhub.ai/feed", status: 200, checksum: "hash" },
|
||||
});
|
||||
mocks.clawhubInstall.mockResolvedValue({
|
||||
ok: true,
|
||||
pluginId: "bluebubbles",
|
||||
targetDir: "/tmp/extensions/bluebubbles",
|
||||
extensions: ["index.js"],
|
||||
packageName: "@openclaw/bluebubbles",
|
||||
clawhub: {
|
||||
source: "clawhub",
|
||||
clawhubUrl: "https://clawhub.ai",
|
||||
clawhubPackage: "@openclaw/bluebubbles",
|
||||
clawhubFamily: "code-plugin",
|
||||
},
|
||||
});
|
||||
mocks.persistInstall.mockResolvedValue({});
|
||||
mocks.refreshRegistry.mockResolvedValue(undefined);
|
||||
mocks.metadata.mockReturnValue(
|
||||
metadataSnapshot({
|
||||
enabled: false,
|
||||
id: "bluebubbles",
|
||||
name: "BlueBubbles",
|
||||
origin: "global",
|
||||
installRecord,
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await installManagedPlugin({
|
||||
request: { source: "clawhub", packageName: "@openclaw/bluebubbles" },
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(mocks.clawhubInstall).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({ expectedPluginId: expect.anything() }),
|
||||
);
|
||||
expect(result.plugin.id).toBe("bluebubbles");
|
||||
});
|
||||
|
||||
it("keeps the runtime-id pin when a declared id equals the package name", async () => {
|
||||
mocks.readConfig.mockResolvedValue(configSnapshot());
|
||||
mocks.officialCatalog.mockResolvedValue({
|
||||
source: "hosted",
|
||||
// Unscoped package whose declared plugin id legitimately equals its name.
|
||||
entries: [
|
||||
{
|
||||
id: "sonos",
|
||||
title: "Sonos",
|
||||
state: "available",
|
||||
publisher: { id: "openclaw", trust: "official" },
|
||||
openclaw: { plugin: { id: "sonos" } },
|
||||
install: { candidates: [{ sourceRef: "public-clawhub", package: "sonos" }] },
|
||||
},
|
||||
],
|
||||
feed: { schemaVersion: 1, id: "test", generatedAt: "now", sequence: 1, entries: [] },
|
||||
metadata: { url: "https://clawhub.ai/feed", status: 200, checksum: "hash" },
|
||||
});
|
||||
mocks.clawhubInstall.mockResolvedValue({
|
||||
ok: true,
|
||||
pluginId: "impostor",
|
||||
targetDir: "/tmp/extensions/impostor",
|
||||
extensions: ["index.js"],
|
||||
packageName: "sonos",
|
||||
clawhub: {
|
||||
source: "clawhub",
|
||||
clawhubUrl: "https://clawhub.ai",
|
||||
clawhubPackage: "sonos",
|
||||
clawhubFamily: "code-plugin",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
installManagedPlugin({
|
||||
request: { source: "clawhub", packageName: "sonos", acknowledgeClawHubRisk: true },
|
||||
env: {},
|
||||
}),
|
||||
).rejects.toThrow("expected sonos, got impostor");
|
||||
expect(mocks.clawhubInstall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ expectedPluginId: "sonos" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("threads hosted ClawHub candidate integrity into official installs", async () => {
|
||||
mocks.readConfig.mockResolvedValue(configSnapshot());
|
||||
mocks.officialCatalog.mockResolvedValue({
|
||||
source: "hosted",
|
||||
entries: [hostedFeedDiffsEntry],
|
||||
feed: { schemaVersion: 1, id: "test", generatedAt: "now", sequence: 1, entries: [] },
|
||||
metadata: { url: "https://clawhub.ai/feed", status: 200, checksum: "hash" },
|
||||
});
|
||||
mocks.clawhubInstall.mockResolvedValue({
|
||||
ok: true,
|
||||
pluginId: "diffs",
|
||||
targetDir: "/tmp/extensions/diffs",
|
||||
extensions: ["index.js"],
|
||||
packageName: "@openclaw/diffs",
|
||||
clawhub: {
|
||||
source: "clawhub",
|
||||
clawhubUrl: "https://clawhub.ai",
|
||||
clawhubPackage: "@openclaw/diffs",
|
||||
clawhubFamily: "code-plugin",
|
||||
},
|
||||
});
|
||||
mocks.persistInstall.mockResolvedValue({});
|
||||
mocks.metadata.mockReturnValue(
|
||||
metadataSnapshot({ enabled: true, id: "diffs", name: "Diffs", origin: "global" }),
|
||||
);
|
||||
|
||||
await installManagedPlugin({
|
||||
request: { source: "official", pluginId: "diffs" },
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(mocks.clawhubInstall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
spec: "clawhub:@openclaw/diffs@2026.6.11",
|
||||
expectedPluginId: "diffs",
|
||||
expectedIntegrity: `sha256-${Buffer.from("a".repeat(64), "hex").toString("base64")}`,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("removes only the newly installed managed target after persistence conflicts", async () => {
|
||||
const conflict = new Error("config changed during plugin install");
|
||||
const targetDir = "/tmp/extensions/demo";
|
||||
mocks.readConfig.mockResolvedValue(configSnapshot());
|
||||
mocks.clawhubInstall.mockResolvedValue({
|
||||
ok: true,
|
||||
pluginId: "demo",
|
||||
targetDir,
|
||||
extensions: ["index.js"],
|
||||
packageName: "community/demo",
|
||||
clawhub: {
|
||||
source: "clawhub",
|
||||
clawhubUrl: "https://clawhub.ai",
|
||||
clawhubPackage: "community/demo",
|
||||
clawhubFamily: "code-plugin",
|
||||
},
|
||||
});
|
||||
mocks.persistInstall.mockRejectedValue(conflict);
|
||||
mocks.planUninstall.mockReturnValue({
|
||||
ok: true,
|
||||
config: {},
|
||||
pluginId: "demo",
|
||||
actions: {},
|
||||
directoryRemoval: { target: targetDir },
|
||||
});
|
||||
|
||||
await expect(
|
||||
installManagedPlugin({
|
||||
request: { source: "clawhub", packageName: "community/demo" },
|
||||
env: {},
|
||||
}),
|
||||
).rejects.toBe(conflict);
|
||||
expect(mocks.planUninstall).toHaveBeenCalledWith({
|
||||
config: {
|
||||
plugins: {
|
||||
installs: {
|
||||
demo: expect.objectContaining({
|
||||
source: "clawhub",
|
||||
spec: "clawhub:community/demo",
|
||||
installPath: targetDir,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
pluginId: "demo",
|
||||
deleteFiles: true,
|
||||
extensionsDir: expect.any(String),
|
||||
});
|
||||
expect(mocks.applyUninstall).toHaveBeenCalledWith({ target: targetDir });
|
||||
});
|
||||
|
||||
it("retains a failed install target when the durable record already owns it", async () => {
|
||||
const persistenceError = new Error("post-commit refresh failed");
|
||||
const targetDir = "/tmp/extensions/demo";
|
||||
mocks.readConfig.mockResolvedValue(configSnapshot());
|
||||
mocks.clawhubInstall.mockResolvedValue({
|
||||
ok: true,
|
||||
pluginId: "demo",
|
||||
targetDir,
|
||||
extensions: ["index.js"],
|
||||
packageName: "community/demo",
|
||||
clawhub: {
|
||||
source: "clawhub",
|
||||
clawhubUrl: "https://clawhub.ai",
|
||||
clawhubPackage: "community/demo",
|
||||
clawhubFamily: "code-plugin",
|
||||
},
|
||||
});
|
||||
mocks.persistInstall.mockRejectedValue(persistenceError);
|
||||
mocks.installRecords.mockResolvedValue({
|
||||
demo: { source: "clawhub", installPath: targetDir },
|
||||
});
|
||||
|
||||
await expect(
|
||||
installManagedPlugin({
|
||||
request: { source: "clawhub", packageName: "community/demo" },
|
||||
env: {},
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
message: "post-commit refresh failed",
|
||||
warning: expect.stringContaining("retained the managed target"),
|
||||
cause: persistenceError,
|
||||
});
|
||||
expect(mocks.planUninstall).not.toHaveBeenCalled();
|
||||
expect(mocks.applyUninstall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("serializes install and enable mutations through one Gateway lock", async () => {
|
||||
let releasePersist: ((config: Record<string, unknown>) => void) | undefined;
|
||||
const heldPersist = new Promise<Record<string, unknown>>((resolve) => {
|
||||
releasePersist = resolve;
|
||||
});
|
||||
mocks.readConfig.mockResolvedValue(configSnapshot());
|
||||
mocks.clawhubInstall.mockResolvedValue({
|
||||
ok: true,
|
||||
pluginId: "demo",
|
||||
targetDir: "/tmp/extensions/demo",
|
||||
extensions: ["index.js"],
|
||||
packageName: "community/demo",
|
||||
clawhub: {
|
||||
source: "clawhub",
|
||||
clawhubUrl: "https://clawhub.ai",
|
||||
clawhubPackage: "community/demo",
|
||||
clawhubFamily: "code-plugin",
|
||||
},
|
||||
});
|
||||
mocks.persistInstall.mockReturnValueOnce(heldPersist);
|
||||
mocks.replaceConfig.mockResolvedValue({});
|
||||
mocks.refreshRegistry.mockResolvedValue(undefined);
|
||||
mocks.metadata
|
||||
.mockReturnValueOnce(metadataSnapshot({ enabled: true, id: "demo", origin: "global" }))
|
||||
.mockReturnValueOnce(metadataSnapshot({ enabled: false }))
|
||||
.mockReturnValueOnce(metadataSnapshot({ enabled: true }));
|
||||
|
||||
const install = installManagedPlugin({
|
||||
request: { source: "clawhub", packageName: "community/demo" },
|
||||
env: {},
|
||||
});
|
||||
await vi.waitFor(() => expect(mocks.persistInstall).toHaveBeenCalledTimes(1));
|
||||
const enable = setManagedPluginEnabled({ pluginId: "workboard", enabled: true, env: {} });
|
||||
await Promise.resolve();
|
||||
|
||||
expect(mocks.readConfig).toHaveBeenCalledTimes(1);
|
||||
releasePersist?.({});
|
||||
await install;
|
||||
await enable;
|
||||
expect(mocks.readConfig).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
code: "clawhub_risk_acknowledgement_required",
|
||||
expectedKind: "invalid-request",
|
||||
},
|
||||
{
|
||||
code: "clawhub_security_unavailable",
|
||||
expectedKind: "unavailable",
|
||||
},
|
||||
] as const)("classifies ClawHub failure $code", async ({ code, expectedKind }) => {
|
||||
mocks.readConfig.mockResolvedValue(configSnapshot());
|
||||
mocks.clawhubInstall.mockResolvedValue({
|
||||
ok: false,
|
||||
error: "ClawHub install failed",
|
||||
code,
|
||||
version: "1.2.3",
|
||||
warning: "Review the release",
|
||||
});
|
||||
|
||||
await expect(
|
||||
installManagedPlugin({
|
||||
request: { source: "clawhub", packageName: "community/plugin" },
|
||||
env: {},
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
kind: expectedKind,
|
||||
code,
|
||||
version: "1.2.3",
|
||||
warning: "Review the release",
|
||||
});
|
||||
});
|
||||
|
||||
it("suppresses hosted package-name-fallback entries once their package is installed", async () => {
|
||||
// Hosted curated row without a declared runtime id: its catalog id falls
|
||||
// back to the package name, which never matches the installed runtime id.
|
||||
const hostedRow = {
|
||||
id: "@openclaw/bluebubbles",
|
||||
title: "BlueBubbles",
|
||||
state: "available",
|
||||
publisher: { id: "openclaw", trust: "official" },
|
||||
openclaw: { catalog: { featured: true, order: 90 } },
|
||||
install: {
|
||||
candidates: [{ sourceRef: "public-clawhub", package: "@openclaw/bluebubbles" }],
|
||||
},
|
||||
};
|
||||
mocks.metadata.mockReturnValue(
|
||||
metadataSnapshot({
|
||||
enabled: true,
|
||||
id: "bluebubbles",
|
||||
name: "BlueBubbles",
|
||||
origin: "global",
|
||||
installRecord: { source: "clawhub", installPath: "/tmp/extensions/bluebubbles" },
|
||||
}),
|
||||
);
|
||||
|
||||
const catalog = await listManagedPlugins({
|
||||
config: {},
|
||||
env: {},
|
||||
officialCatalog: {
|
||||
entries: overlayBundledOfficialPluginCatalogMetadata(
|
||||
// Route through the hosted-feed normalizer used by loadOfficialCatalog.
|
||||
[hostedRow] as never,
|
||||
[],
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
expect(catalog.plugins.map((plugin) => plugin.id)).toEqual(["bluebubbles"]);
|
||||
});
|
||||
|
||||
it("marks external installs removable and bundled plugins non-removable", async () => {
|
||||
mocks.metadata.mockReturnValue(
|
||||
metadataSnapshot({
|
||||
enabled: true,
|
||||
id: "diffs",
|
||||
name: "Diffs",
|
||||
origin: "global",
|
||||
installRecord: { source: "clawhub", installPath: "/tmp/extensions/diffs" },
|
||||
}),
|
||||
);
|
||||
const external = await listManagedPlugins({
|
||||
config: {},
|
||||
env: {},
|
||||
officialCatalog: { entries: [] },
|
||||
});
|
||||
expect(external.plugins[0]).toMatchObject({ id: "diffs", removable: true });
|
||||
|
||||
mocks.metadata.mockReturnValue(metadataSnapshot({ enabled: false }));
|
||||
const bundled = await listManagedPlugins({
|
||||
config: {},
|
||||
env: {},
|
||||
officialCatalog: { entries: [] },
|
||||
});
|
||||
expect(bundled.plugins[0]).toMatchObject({ id: "workboard", removable: false });
|
||||
});
|
||||
|
||||
it("uninstalls an external plugin through commit, file removal, and registry refresh", async () => {
|
||||
const installRecord = {
|
||||
source: "clawhub",
|
||||
spec: "clawhub:@openclaw/diffs",
|
||||
installPath: "/tmp/extensions/diffs",
|
||||
};
|
||||
const prepared = configSnapshot({ plugins: { entries: { diffs: { enabled: true } } } });
|
||||
mocks.readConfig.mockResolvedValue(prepared);
|
||||
mocks.installRecords.mockResolvedValue({ diffs: installRecord });
|
||||
mocks.metadata.mockReturnValue(
|
||||
metadataSnapshot({
|
||||
enabled: true,
|
||||
id: "diffs",
|
||||
name: "Diffs",
|
||||
origin: "global",
|
||||
installRecord,
|
||||
}),
|
||||
);
|
||||
mocks.planUninstall.mockReturnValue({
|
||||
ok: true,
|
||||
config: { plugins: { installs: { diffs: installRecord } } },
|
||||
pluginId: "diffs",
|
||||
actions: {
|
||||
entry: true,
|
||||
install: true,
|
||||
allowlist: false,
|
||||
denylist: false,
|
||||
loadPath: false,
|
||||
memorySlot: false,
|
||||
contextEngineSlot: false,
|
||||
channelConfig: false,
|
||||
directory: false,
|
||||
},
|
||||
directoryRemoval: { target: "/tmp/extensions/diffs" },
|
||||
});
|
||||
mocks.commitRecords.mockResolvedValue(undefined);
|
||||
mocks.applyUninstall.mockResolvedValue({ directoryRemoved: true, warnings: [] });
|
||||
mocks.refreshRegistry.mockResolvedValue(undefined);
|
||||
|
||||
const result = await uninstallManagedPlugin({ pluginId: "diffs", env: {} });
|
||||
|
||||
expect(mocks.planUninstall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ pluginId: "diffs", deleteFiles: true }),
|
||||
);
|
||||
expect(mocks.commitRecords).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
previousInstallRecords: { diffs: installRecord },
|
||||
nextInstallRecords: {},
|
||||
baseHash: "base-hash",
|
||||
writeOptions: prepared.writeOptions,
|
||||
}),
|
||||
);
|
||||
// Transient install records never persist into the written config document.
|
||||
expect(mocks.commitRecords.mock.calls[0][0].nextConfig.plugins?.installs).toBeUndefined();
|
||||
expect(mocks.applyUninstall).toHaveBeenCalledWith({ target: "/tmp/extensions/diffs" });
|
||||
expect(mocks.refreshRegistry).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ reason: "source-changed", installRecords: {} }),
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
pluginId: "diffs",
|
||||
removed: ["config entry", "install record", "directory"],
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses to uninstall bundled plugins", async () => {
|
||||
mocks.readConfig.mockResolvedValue(configSnapshot());
|
||||
mocks.installRecords.mockResolvedValue({});
|
||||
mocks.metadata.mockReturnValue(metadataSnapshot({ enabled: false }));
|
||||
|
||||
await expect(uninstallManagedPlugin({ pluginId: "workboard", env: {} })).rejects.toThrow(
|
||||
"bundled plugin cannot be uninstalled",
|
||||
);
|
||||
expect(mocks.commitRecords).not.toHaveBeenCalled();
|
||||
expect(mocks.applyUninstall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces uninstall plan failures as lifecycle errors", async () => {
|
||||
mocks.readConfig.mockResolvedValue(configSnapshot());
|
||||
mocks.installRecords.mockResolvedValue({});
|
||||
mocks.metadata.mockReturnValue(emptyMetadataSnapshot());
|
||||
mocks.planUninstall.mockReturnValue({ ok: false, error: "Plugin not found: ghost" });
|
||||
|
||||
await expect(uninstallManagedPlugin({ pluginId: "ghost", env: {} })).rejects.toThrow(
|
||||
"Plugin not found: ghost",
|
||||
);
|
||||
expect(mocks.commitRecords).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,979 @@
|
||||
// Structured plugin catalog and lifecycle operations shared by Gateway-facing surfaces.
|
||||
import path from "node:path";
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { MANIFEST_KEY } from "../compat/legacy-names.js";
|
||||
import {
|
||||
assertConfigWriteAllowedInCurrentMode,
|
||||
readConfigFileSnapshotForWrite,
|
||||
replaceConfigFile,
|
||||
} from "../config/config.js";
|
||||
import { collectChangedPaths } from "../config/io.write-prepare.js";
|
||||
import { resolveIsNixMode } from "../config/paths.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { PluginInstallRecord } from "../config/types.plugins.js";
|
||||
import { parseClawHubPluginSpec } from "../infra/clawhub-spec.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { createAsyncLock } from "../infra/json-files.js";
|
||||
import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { CLAWHUB_INSTALL_ERROR_CODE } from "./clawhub-error-codes.js";
|
||||
import { buildClawHubPluginInstallRecordFields } from "./clawhub-install-records.js";
|
||||
import { installPluginFromClawHub } from "./clawhub.js";
|
||||
import { enableExplicitlySelectedPluginInConfig } from "./enable.js";
|
||||
import { resolveDefaultPluginExtensionsDir } from "./install-paths.js";
|
||||
import {
|
||||
resolveInstallConfigMutationPreflights,
|
||||
selectInstallMutationWriteOptions,
|
||||
persistPluginInstall,
|
||||
type ConfigSnapshotForInstallPersist,
|
||||
} from "./install-persistence.js";
|
||||
import { commitPluginInstallRecordsWithConfig } from "./install-record-commit.js";
|
||||
import { installPluginFromNpmSpec } from "./install.js";
|
||||
import {
|
||||
loadInstalledPluginIndexInstallRecords,
|
||||
removePluginInstallRecordFromRecords,
|
||||
withPluginInstallRecords,
|
||||
withoutPluginInstallRecords,
|
||||
} from "./installed-plugin-index-records.js";
|
||||
import { buildNpmResolutionInstallFields } from "./installs.js";
|
||||
import type { PluginManifestRecord } from "./manifest-registry.js";
|
||||
import type { PluginDiagnostic } from "./manifest-types.js";
|
||||
import {
|
||||
getOfficialExternalPluginCatalogManifest,
|
||||
listOfficialExternalPluginCatalogEntries,
|
||||
loadConfiguredHostedOfficialExternalPluginCatalogEntries,
|
||||
resolveOfficialExternalPluginId,
|
||||
resolveOfficialExternalPluginInstall,
|
||||
resolveOfficialExternalPluginLabel,
|
||||
type HostedOfficialExternalPluginCatalogLoadResult,
|
||||
type OfficialExternalPluginCatalogEntry,
|
||||
} from "./official-external-plugin-catalog.js";
|
||||
import { loadPluginMetadataSnapshot } from "./plugin-metadata-snapshot.js";
|
||||
import { refreshPluginRegistryAfterConfigMutation } from "./registry-refresh.js";
|
||||
import { applySlotSelectionForPlugin } from "./slot-selection.js";
|
||||
import { setPluginEnabledInConfig } from "./toggle-config.js";
|
||||
import {
|
||||
applyPluginUninstallDirectoryRemoval,
|
||||
formatUninstallActionLabels,
|
||||
planPluginUninstall,
|
||||
} from "./uninstall.js";
|
||||
|
||||
export type ManagedPluginCatalogEntry = {
|
||||
id: string;
|
||||
name: string;
|
||||
packageName?: string;
|
||||
description?: string;
|
||||
version?: string;
|
||||
kind?: string[];
|
||||
origin?: string;
|
||||
installed: boolean;
|
||||
enabled: boolean;
|
||||
state: "enabled" | "disabled" | "not-installed" | "error";
|
||||
featured?: boolean;
|
||||
order?: number;
|
||||
install?: { source: "clawhub"; packageName: string } | { source: "official"; pluginId: string };
|
||||
error?: string;
|
||||
category?: string;
|
||||
removable?: boolean;
|
||||
};
|
||||
|
||||
export type ManagedPluginCatalog = {
|
||||
plugins: ManagedPluginCatalogEntry[];
|
||||
diagnostics: unknown[];
|
||||
mutationAllowed: boolean;
|
||||
};
|
||||
|
||||
export type ManagedPluginInstallRequest =
|
||||
| {
|
||||
source: "clawhub";
|
||||
packageName: string;
|
||||
version?: string;
|
||||
acknowledgeClawHubRisk?: boolean;
|
||||
}
|
||||
| { source: "official"; pluginId: string };
|
||||
|
||||
export class ManagedPluginLifecycleError extends Error {
|
||||
readonly kind: "invalid-request" | "unavailable";
|
||||
readonly code?: string;
|
||||
readonly version?: string;
|
||||
readonly warning?: string;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
details?: {
|
||||
kind?: "invalid-request" | "unavailable";
|
||||
code?: string;
|
||||
version?: string;
|
||||
warning?: string;
|
||||
cause?: unknown;
|
||||
},
|
||||
) {
|
||||
super(message, details?.cause !== undefined ? { cause: details.cause } : undefined);
|
||||
this.name = "ManagedPluginLifecycleError";
|
||||
this.kind = details?.kind ?? "invalid-request";
|
||||
this.code = details?.code;
|
||||
this.version = details?.version;
|
||||
this.warning = details?.warning;
|
||||
}
|
||||
}
|
||||
|
||||
type OfficialCatalogResult = Pick<HostedOfficialExternalPluginCatalogLoadResult, "entries"> & {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
let officialCatalogCache:
|
||||
| { key: string; result: Promise<HostedOfficialExternalPluginCatalogLoadResult> }
|
||||
| undefined;
|
||||
|
||||
function officialCatalogCacheKey(config: OpenClawConfig): string {
|
||||
return JSON.stringify(config.marketplaces ?? null);
|
||||
}
|
||||
|
||||
/** Clear the process-stable hosted catalog snapshot after an explicit owner reload. */
|
||||
export function clearManagedPluginOfficialCatalogCache(): void {
|
||||
officialCatalogCache = undefined;
|
||||
}
|
||||
|
||||
function mergeCatalogMetadata(
|
||||
hosted: OfficialExternalPluginCatalogEntry,
|
||||
bundled: OfficialExternalPluginCatalogEntry,
|
||||
): OfficialExternalPluginCatalogEntry {
|
||||
const hostedManifest = getOfficialExternalPluginCatalogManifest(hosted);
|
||||
const bundledManifest = getOfficialExternalPluginCatalogManifest(bundled);
|
||||
const bundledCatalog = bundledManifest?.catalog;
|
||||
const bundledPlugin = bundledManifest?.plugin;
|
||||
const bundledName = normalizeOptionalString(bundled.name);
|
||||
const bundledDescription = normalizeOptionalString(bundled.description);
|
||||
const bundledKind = normalizeOptionalString(bundled.kind);
|
||||
const bundledSource = normalizeOptionalString(bundled.source);
|
||||
if (!bundledCatalog && !bundledPlugin) {
|
||||
return hosted;
|
||||
}
|
||||
return {
|
||||
...hosted,
|
||||
...(!normalizeOptionalString(hosted.name) && bundledName ? { name: bundledName } : {}),
|
||||
...(!normalizeOptionalString(hosted.description) && bundledDescription
|
||||
? { description: bundledDescription }
|
||||
: {}),
|
||||
...(!normalizeOptionalString(hosted.kind) && bundledKind ? { kind: bundledKind } : {}),
|
||||
...(!normalizeOptionalString(hosted.source) && bundledSource ? { source: bundledSource } : {}),
|
||||
[MANIFEST_KEY]: {
|
||||
...hostedManifest,
|
||||
...(bundledPlugin ? { plugin: { ...hostedManifest?.plugin, ...bundledPlugin } } : {}),
|
||||
...(bundledCatalog ? { catalog: { ...hostedManifest?.catalog, ...bundledCatalog } } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCatalogPackageSourceIdentities(
|
||||
entry: OfficialExternalPluginCatalogEntry,
|
||||
): Set<string> {
|
||||
const install = resolveOfficialExternalPluginInstall(entry);
|
||||
const clawhubPackage = install?.clawhubSpec
|
||||
? parseClawHubPluginSpec(install.clawhubSpec)?.name
|
||||
: undefined;
|
||||
const npmPackage = install?.npmSpec ? parseRegistryNpmSpec(install.npmSpec)?.name : undefined;
|
||||
return new Set([
|
||||
...(clawhubPackage ? [`clawhub:${clawhubPackage}`] : []),
|
||||
...(npmPackage ? [`npm:${npmPackage}`] : []),
|
||||
]);
|
||||
}
|
||||
|
||||
function matchesBundledCatalogIdentity(params: {
|
||||
hosted: OfficialExternalPluginCatalogEntry;
|
||||
bundled: OfficialExternalPluginCatalogEntry;
|
||||
}): boolean {
|
||||
const hostedSources = resolveCatalogPackageSourceIdentities(params.hosted);
|
||||
const bundledSources = resolveCatalogPackageSourceIdentities(params.bundled);
|
||||
return [...hostedSources].some((identity) => bundledSources.has(identity));
|
||||
}
|
||||
|
||||
/** Overlay local runtime identity and editorial hints after an exact package/source match. */
|
||||
export function overlayBundledOfficialPluginCatalogMetadata(
|
||||
entries: readonly OfficialExternalPluginCatalogEntry[],
|
||||
bundledEntries: readonly OfficialExternalPluginCatalogEntry[] = listOfficialExternalPluginCatalogEntries(),
|
||||
): OfficialExternalPluginCatalogEntry[] {
|
||||
return entries.map((entry) => {
|
||||
const matches = bundledEntries.filter((bundled) =>
|
||||
matchesBundledCatalogIdentity({ hosted: entry, bundled }),
|
||||
);
|
||||
const bundled = matches.length === 1 ? matches[0] : undefined;
|
||||
return bundled ? mergeCatalogMetadata(entry, bundled) : entry;
|
||||
});
|
||||
}
|
||||
|
||||
async function loadOfficialCatalog(config: OpenClawConfig): Promise<OfficialCatalogResult> {
|
||||
const key = officialCatalogCacheKey(config);
|
||||
if (officialCatalogCache?.key !== key) {
|
||||
officialCatalogCache = {
|
||||
key,
|
||||
result: loadConfiguredHostedOfficialExternalPluginCatalogEntries(config),
|
||||
};
|
||||
}
|
||||
const result = await officialCatalogCache.result;
|
||||
return {
|
||||
entries: overlayBundledOfficialPluginCatalogMetadata(result.entries),
|
||||
...("error" in result ? { error: result.error } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeKinds(kind: string | readonly string[] | undefined): string[] | undefined {
|
||||
const values = (typeof kind === "string" ? [kind] : (kind ?? []))
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
return values.length > 0 ? [...new Set(values)] : undefined;
|
||||
}
|
||||
|
||||
function normalizeCatalogMetadata(
|
||||
value: unknown,
|
||||
): { featured?: boolean; order?: number } | undefined {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const featured = typeof record.featured === "boolean" ? record.featured : undefined;
|
||||
const order =
|
||||
typeof record.order === "number" && Number.isFinite(record.order) ? record.order : undefined;
|
||||
return featured === undefined && order === undefined
|
||||
? undefined
|
||||
: {
|
||||
...(featured !== undefined ? { featured } : {}),
|
||||
...(order !== undefined ? { order } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCatalogInstallAction(params: {
|
||||
config: OpenClawConfig;
|
||||
entry: OfficialExternalPluginCatalogEntry;
|
||||
pluginId: string;
|
||||
}): ManagedPluginCatalogEntry["install"] {
|
||||
const install = resolveOfficialExternalPluginInstall(params.entry, {
|
||||
catalogConfig: params.config.marketplaces,
|
||||
});
|
||||
const clawhub = install?.clawhubSpec ? parseClawHubPluginSpec(install.clawhubSpec) : undefined;
|
||||
if (clawhub && !clawhub.version) {
|
||||
return { source: "clawhub", packageName: clawhub.name };
|
||||
}
|
||||
return install ? { source: "official", pluginId: params.pluginId } : undefined;
|
||||
}
|
||||
|
||||
/** Coarse manifest-derived grouping so catalog UIs can shelve a large inventory. */
|
||||
export function derivePluginCategory(
|
||||
manifest: PluginManifestRecord | undefined,
|
||||
): string | undefined {
|
||||
if (!manifest) {
|
||||
return undefined;
|
||||
}
|
||||
if (manifest.channels.length > 0 || Object.keys(manifest.channelConfigs ?? {}).length > 0) {
|
||||
return "channel";
|
||||
}
|
||||
const mediaProvider =
|
||||
Object.keys(manifest.imageGenerationProviderMetadata ?? {}).length > 0 ||
|
||||
Object.keys(manifest.videoGenerationProviderMetadata ?? {}).length > 0 ||
|
||||
Object.keys(manifest.musicGenerationProviderMetadata ?? {}).length > 0 ||
|
||||
Object.keys(manifest.mediaUnderstandingProviderMetadata ?? {}).length > 0;
|
||||
if (
|
||||
manifest.providers.length > 0 ||
|
||||
manifest.providerEndpoints?.length ||
|
||||
manifest.modelCatalog ||
|
||||
mediaProvider
|
||||
) {
|
||||
return "provider";
|
||||
}
|
||||
const kinds = normalizeKinds(manifest.kind);
|
||||
if (kinds?.includes("memory")) {
|
||||
return "memory";
|
||||
}
|
||||
if (kinds?.includes("context-engine")) {
|
||||
return "context-engine";
|
||||
}
|
||||
if (
|
||||
manifest.contracts?.tools?.length ||
|
||||
Object.keys(manifest.toolMetadata ?? {}).length > 0 ||
|
||||
manifest.skills.length > 0
|
||||
) {
|
||||
return "tool";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function firstPluginError(
|
||||
diagnostics: readonly PluginDiagnostic[],
|
||||
pluginId: string,
|
||||
): string | undefined {
|
||||
return diagnostics.find(
|
||||
(diagnostic) => diagnostic.level === "error" && diagnostic.pluginId === pluginId,
|
||||
)?.message;
|
||||
}
|
||||
|
||||
function compareCatalogEntries(
|
||||
left: ManagedPluginCatalogEntry,
|
||||
right: ManagedPluginCatalogEntry,
|
||||
): number {
|
||||
const featured = Number(Boolean(right.featured)) - Number(Boolean(left.featured));
|
||||
if (featured !== 0) {
|
||||
return featured;
|
||||
}
|
||||
const order = (left.order ?? Number.MAX_SAFE_INTEGER) - (right.order ?? Number.MAX_SAFE_INTEGER);
|
||||
return order !== 0 ? order : left.name.localeCompare(right.name);
|
||||
}
|
||||
|
||||
/** Build cold installed state merged with the hosted official catalog and bundled curation. */
|
||||
export async function listManagedPlugins(params: {
|
||||
config: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
officialCatalog?: OfficialCatalogResult;
|
||||
}): Promise<ManagedPluginCatalog> {
|
||||
const env = params.env ?? process.env;
|
||||
const metadata = loadPluginMetadataSnapshot({ config: params.config, env });
|
||||
const officialCatalog = params.officialCatalog ?? (await loadOfficialCatalog(params.config));
|
||||
const plugins = metadata.index.plugins.map((record): ManagedPluginCatalogEntry => {
|
||||
const manifest = metadata.byPluginId.get(record.pluginId);
|
||||
const catalog = normalizeCatalogMetadata(manifest?.catalog);
|
||||
const error = firstPluginError(metadata.diagnostics, record.pluginId);
|
||||
const kind = normalizeKinds(manifest?.kind);
|
||||
const category = derivePluginCategory(manifest);
|
||||
// Only externally installed plugins (tracked install record, non-bundled) can be removed.
|
||||
const removable =
|
||||
record.origin !== "bundled" && Boolean(metadata.index.installRecords[record.pluginId]);
|
||||
// Prefer human labels over package specifiers: the registry backfills a
|
||||
// missing manifest name with the npm package name, which is an install
|
||||
// spec rather than a display name.
|
||||
const manifestName =
|
||||
manifest?.name && manifest.name !== record.packageName ? manifest.name : undefined;
|
||||
const name = manifestName ?? manifest?.channelCatalogMeta?.label ?? record.pluginId;
|
||||
const description =
|
||||
manifest?.description ?? manifest?.channelCatalogMeta?.blurb ?? manifest?.packageDescription;
|
||||
return {
|
||||
id: record.pluginId,
|
||||
name,
|
||||
...(record.packageName ? { packageName: record.packageName } : {}),
|
||||
...(description ? { description } : {}),
|
||||
...(record.packageVersion || manifest?.version
|
||||
? { version: record.packageVersion ?? manifest?.version }
|
||||
: {}),
|
||||
...(kind ? { kind } : {}),
|
||||
...(record.origin ? { origin: record.origin } : {}),
|
||||
installed: true,
|
||||
enabled: record.enabled,
|
||||
state: error ? "error" : record.enabled ? "enabled" : "disabled",
|
||||
...(catalog?.featured !== undefined ? { featured: catalog.featured } : {}),
|
||||
...(catalog?.order !== undefined ? { order: catalog.order } : {}),
|
||||
...(error ? { error } : {}),
|
||||
...(category ? { category } : {}),
|
||||
removable,
|
||||
};
|
||||
});
|
||||
const installedIds = new Set(plugins.map((plugin) => plugin.id));
|
||||
const installedPackageNames = new Set(
|
||||
plugins.flatMap((plugin) => (plugin.packageName ? [plugin.packageName] : [])),
|
||||
);
|
||||
// Hosted rows without a declared runtime id fall back to their package name,
|
||||
// so id matching alone would keep them visible after a successful install.
|
||||
const entryPackageInstalled = (entry: OfficialExternalPluginCatalogEntry) =>
|
||||
[...resolveCatalogPackageSourceIdentities(entry)].some((identity) =>
|
||||
installedPackageNames.has(identity.slice(identity.indexOf(":") + 1)),
|
||||
);
|
||||
for (const entry of officialCatalog.entries) {
|
||||
const pluginId = resolveOfficialExternalPluginId(entry);
|
||||
const manifest = getOfficialExternalPluginCatalogManifest(entry);
|
||||
const catalog = normalizeCatalogMetadata(manifest?.catalog);
|
||||
if (!pluginId || !catalog || installedIds.has(pluginId) || entryPackageInstalled(entry)) {
|
||||
continue;
|
||||
}
|
||||
const kind = normalizeKinds(entry.kind);
|
||||
const install = resolveCatalogInstallAction({ config: params.config, entry, pluginId });
|
||||
const description = normalizeOptionalString(entry.description);
|
||||
const version = normalizeOptionalString(entry.version);
|
||||
plugins.push({
|
||||
id: pluginId,
|
||||
name: resolveOfficialExternalPluginLabel(entry),
|
||||
...(description ? { description } : {}),
|
||||
...(version ? { version } : {}),
|
||||
...(kind ? { kind } : {}),
|
||||
origin: "official",
|
||||
installed: false,
|
||||
enabled: false,
|
||||
state: "not-installed",
|
||||
...(catalog.featured !== undefined ? { featured: catalog.featured } : {}),
|
||||
...(catalog.order !== undefined ? { order: catalog.order } : {}),
|
||||
...(install ? { install } : {}),
|
||||
});
|
||||
}
|
||||
const diagnostics: unknown[] = [...metadata.diagnostics];
|
||||
if (officialCatalog.error) {
|
||||
diagnostics.push({
|
||||
level: "warn",
|
||||
message: `Official plugin catalog fallback: ${officialCatalog.error}`,
|
||||
});
|
||||
}
|
||||
return {
|
||||
plugins: plugins.toSorted(compareCatalogEntries),
|
||||
diagnostics,
|
||||
mutationAllowed: !resolveIsNixMode(env),
|
||||
};
|
||||
}
|
||||
|
||||
const withManagedPluginMutationLock = createAsyncLock();
|
||||
|
||||
function assertValidConfigSnapshot(
|
||||
prepared: Awaited<ReturnType<typeof readConfigFileSnapshotForWrite>>,
|
||||
): ConfigSnapshotForInstallPersist {
|
||||
const { snapshot, writeOptions } = prepared;
|
||||
if (!snapshot.valid) {
|
||||
throw new ManagedPluginLifecycleError(
|
||||
"Config invalid; run `openclaw doctor --fix` before managing plugins.",
|
||||
);
|
||||
}
|
||||
const mutationWriteOptions = selectInstallMutationWriteOptions(writeOptions);
|
||||
const { pluginMutation } = resolveInstallConfigMutationPreflights({
|
||||
parsed: (snapshot.parsed ?? {}) as Record<string, unknown>,
|
||||
snapshotPath: snapshot.path,
|
||||
writeOptions: mutationWriteOptions,
|
||||
});
|
||||
if (pluginMutation.mode === "blocked") {
|
||||
throw new ManagedPluginLifecycleError(pluginMutation.reason);
|
||||
}
|
||||
return {
|
||||
config: snapshot.sourceConfig,
|
||||
baseHash: snapshot.hash,
|
||||
writeOptions: mutationWriteOptions,
|
||||
};
|
||||
}
|
||||
|
||||
async function readPluginMutationSnapshot(
|
||||
env: NodeJS.ProcessEnv,
|
||||
): Promise<ConfigSnapshotForInstallPersist> {
|
||||
try {
|
||||
assertConfigWriteAllowedInCurrentMode({ env });
|
||||
} catch (error) {
|
||||
throw new ManagedPluginLifecycleError(formatErrorMessage(error), { cause: error });
|
||||
}
|
||||
return assertValidConfigSnapshot(await readConfigFileSnapshotForWrite());
|
||||
}
|
||||
|
||||
function createSilentRuntime(): RuntimeEnv {
|
||||
return {
|
||||
log: () => undefined,
|
||||
error: () => undefined,
|
||||
exit: (code) => {
|
||||
throw new ManagedPluginLifecycleError(`plugin lifecycle exited with code ${code}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createInstallLogger(warnings: string[]) {
|
||||
return {
|
||||
info: () => undefined,
|
||||
warn: (message: string) => warnings.push(message),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveOfficialEntryById(
|
||||
entries: readonly OfficialExternalPluginCatalogEntry[],
|
||||
pluginId: string,
|
||||
): OfficialExternalPluginCatalogEntry | undefined {
|
||||
return entries.find((entry) => resolveOfficialExternalPluginId(entry) === pluginId);
|
||||
}
|
||||
|
||||
/** Explicitly declared runtime id, ignoring the entry-id fallback used for display. */
|
||||
function resolveDeclaredOfficialPluginId(
|
||||
entry: OfficialExternalPluginCatalogEntry,
|
||||
): string | undefined {
|
||||
const manifest = getOfficialExternalPluginCatalogManifest(entry);
|
||||
return (
|
||||
normalizeOptionalString(manifest?.plugin?.id) ??
|
||||
normalizeOptionalString(manifest?.channel?.id) ??
|
||||
normalizeOptionalString(manifest?.providers?.[0]?.id)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveOfficialEntryByClawHubPackage(
|
||||
entries: readonly OfficialExternalPluginCatalogEntry[],
|
||||
config: OpenClawConfig,
|
||||
packageName: string,
|
||||
): OfficialExternalPluginCatalogEntry | undefined {
|
||||
// Bundled identities remain the local trust anchor when a hosted feed omits
|
||||
// its ClawHub candidate; hosted install/version metadata is never copied back.
|
||||
return [...listOfficialExternalPluginCatalogEntries(), ...entries].find((entry) => {
|
||||
const install = resolveOfficialExternalPluginInstall(entry, {
|
||||
catalogConfig: config.marketplaces,
|
||||
});
|
||||
return parseClawHubPluginSpec(install?.clawhubSpec ?? "")?.name === packageName;
|
||||
});
|
||||
}
|
||||
|
||||
function resolveHostedOfficialEntryByClawHubPackage(
|
||||
entries: readonly OfficialExternalPluginCatalogEntry[],
|
||||
config: OpenClawConfig,
|
||||
packageName: string,
|
||||
): OfficialExternalPluginCatalogEntry | undefined {
|
||||
return entries.find((entry) => {
|
||||
const install = resolveOfficialExternalPluginInstall(entry, {
|
||||
catalogConfig: config.marketplaces,
|
||||
});
|
||||
return parseClawHubPluginSpec(install?.clawhubSpec ?? "")?.name === packageName;
|
||||
});
|
||||
}
|
||||
|
||||
function buildClawHubSpec(packageName: string, version?: string): string {
|
||||
const parsed = parseClawHubPluginSpec(`clawhub:${packageName}`);
|
||||
if (!parsed || parsed.version) {
|
||||
throw new ManagedPluginLifecycleError(`invalid ClawHub package name: ${packageName}`);
|
||||
}
|
||||
return `clawhub:${packageName}${version ? `@${version}` : ""}`;
|
||||
}
|
||||
|
||||
function throwInstallFailure(result: {
|
||||
error: string;
|
||||
code?: string;
|
||||
version?: string;
|
||||
warning?: string;
|
||||
}): never {
|
||||
const unavailable =
|
||||
!result.code ||
|
||||
result.code === CLAWHUB_INSTALL_ERROR_CODE.ARTIFACT_UNAVAILABLE ||
|
||||
result.code === CLAWHUB_INSTALL_ERROR_CODE.ARTIFACT_DOWNLOAD_UNAVAILABLE ||
|
||||
result.code === CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_SECURITY_UNAVAILABLE;
|
||||
throw new ManagedPluginLifecycleError(result.error, {
|
||||
kind: unavailable ? "unavailable" : "invalid-request",
|
||||
code: result.code,
|
||||
version: result.version,
|
||||
warning: result.warning,
|
||||
cause: result,
|
||||
});
|
||||
}
|
||||
|
||||
function installRecordOwnsTarget(
|
||||
record: PluginInstallRecord | undefined,
|
||||
targetDir: string,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
record?.installPath && path.resolve(record.installPath) === path.resolve(targetDir),
|
||||
);
|
||||
}
|
||||
|
||||
async function cleanupFailedManagedPluginInstall(params: {
|
||||
pluginId: string;
|
||||
install: PluginInstallRecord;
|
||||
targetDir: string;
|
||||
extensionsDir: string;
|
||||
}): Promise<string[]> {
|
||||
let installRecords: Record<string, PluginInstallRecord>;
|
||||
try {
|
||||
installRecords = await loadInstalledPluginIndexInstallRecords();
|
||||
} catch (error) {
|
||||
return [
|
||||
`Could not verify whether the failed plugin install was committed; retained ${params.targetDir}: ${formatErrorMessage(error)}`,
|
||||
];
|
||||
}
|
||||
if (installRecordOwnsTarget(installRecords[params.pluginId], params.targetDir)) {
|
||||
return [
|
||||
`Plugin install persistence reported an error after ${params.targetDir} was recorded; retained the managed target.`,
|
||||
];
|
||||
}
|
||||
|
||||
const plan = planPluginUninstall({
|
||||
config: {
|
||||
plugins: { installs: { [params.pluginId]: params.install } },
|
||||
},
|
||||
pluginId: params.pluginId,
|
||||
deleteFiles: true,
|
||||
extensionsDir: params.extensionsDir,
|
||||
});
|
||||
if (!plan.ok) {
|
||||
return [`Could not plan cleanup for failed plugin install: ${plan.error}`];
|
||||
}
|
||||
if (!plan.directoryRemoval) {
|
||||
return [
|
||||
`Could not resolve a managed cleanup target for failed plugin install ${params.pluginId}.`,
|
||||
];
|
||||
}
|
||||
if (path.resolve(plan.directoryRemoval.target) !== path.resolve(params.targetDir)) {
|
||||
return [
|
||||
`Refused cleanup for failed plugin install ${params.pluginId}: planned target does not match the newly installed target.`,
|
||||
];
|
||||
}
|
||||
try {
|
||||
const cleanup = await applyPluginUninstallDirectoryRemoval(plan.directoryRemoval);
|
||||
return cleanup.warnings;
|
||||
} catch (error) {
|
||||
return [
|
||||
`Failed to remove the newly installed target after plugin persistence failed: ${formatErrorMessage(error)}`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
function throwPersistenceFailureWithCleanupWarnings(error: unknown, warnings: string[]): never {
|
||||
if (warnings.length === 0) {
|
||||
throw error;
|
||||
}
|
||||
const cleanupWarning = [...new Set(warnings)].join("\n");
|
||||
if (error instanceof ManagedPluginLifecycleError) {
|
||||
throw new ManagedPluginLifecycleError(error.message, {
|
||||
kind: error.kind,
|
||||
code: error.code,
|
||||
version: error.version,
|
||||
warning: [error.warning, cleanupWarning].filter(Boolean).join("\n"),
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
throw new ManagedPluginLifecycleError(formatErrorMessage(error), {
|
||||
kind: "unavailable",
|
||||
warning: cleanupWarning,
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
|
||||
async function persistManagedPluginInstall(params: {
|
||||
snapshot: ConfigSnapshotForInstallPersist;
|
||||
pluginId: string;
|
||||
install: PluginInstallRecord;
|
||||
targetDir: string;
|
||||
extensionsDir: string;
|
||||
}): Promise<OpenClawConfig> {
|
||||
try {
|
||||
return await persistPluginInstall({
|
||||
snapshot: params.snapshot,
|
||||
pluginId: params.pluginId,
|
||||
install: params.install,
|
||||
invalidateRuntimeCache: false,
|
||||
runtime: createSilentRuntime(),
|
||||
});
|
||||
} catch (error) {
|
||||
const cleanupWarnings = await cleanupFailedManagedPluginInstall({
|
||||
pluginId: params.pluginId,
|
||||
install: params.install,
|
||||
targetDir: params.targetDir,
|
||||
extensionsDir: params.extensionsDir,
|
||||
});
|
||||
return throwPersistenceFailureWithCleanupWarnings(error, cleanupWarnings);
|
||||
}
|
||||
}
|
||||
|
||||
async function installFromClawHub(params: {
|
||||
request: Extract<ManagedPluginInstallRequest, { source: "clawhub" }>;
|
||||
snapshot: ConfigSnapshotForInstallPersist;
|
||||
officialEntries: readonly OfficialExternalPluginCatalogEntry[];
|
||||
env: NodeJS.ProcessEnv;
|
||||
warnings: string[];
|
||||
expectedIntegrity?: string;
|
||||
}): Promise<{ pluginId: string; config: OpenClawConfig }> {
|
||||
const packageName = params.request.packageName.trim();
|
||||
const official = resolveOfficialEntryByClawHubPackage(
|
||||
params.officialEntries,
|
||||
params.snapshot.config,
|
||||
packageName,
|
||||
);
|
||||
// Pin the runtime id only when the catalog entry declares one; the entry-id
|
||||
// fallback is just the package name and would reject legitimate installs,
|
||||
// while a declared id must stay enforced even if it equals the package name.
|
||||
const expectedPluginId = official ? resolveDeclaredOfficialPluginId(official) : undefined;
|
||||
const hostedOfficial = resolveHostedOfficialEntryByClawHubPackage(
|
||||
params.officialEntries,
|
||||
params.snapshot.config,
|
||||
packageName,
|
||||
);
|
||||
const hostedInstall = hostedOfficial
|
||||
? resolveOfficialExternalPluginInstall(hostedOfficial, {
|
||||
catalogConfig: params.snapshot.config.marketplaces,
|
||||
})
|
||||
: undefined;
|
||||
const hostedClawHub = parseClawHubPluginSpec(hostedInstall?.clawhubSpec ?? "");
|
||||
const requestMatchesHostedCandidate =
|
||||
!params.request.version || params.request.version === hostedClawHub?.version;
|
||||
const expectedIntegrity =
|
||||
params.expectedIntegrity ??
|
||||
(requestMatchesHostedCandidate ? hostedInstall?.expectedIntegrity : undefined);
|
||||
const version =
|
||||
params.request.version ?? (requestMatchesHostedCandidate ? hostedClawHub?.version : undefined);
|
||||
const spec = buildClawHubSpec(packageName, version);
|
||||
const extensionsDir = resolveDefaultPluginExtensionsDir(params.env);
|
||||
const result = await installPluginFromClawHub({
|
||||
spec,
|
||||
config: params.snapshot.config,
|
||||
extensionsDir,
|
||||
logger: createInstallLogger(params.warnings),
|
||||
...(expectedPluginId ? { expectedPluginId } : {}),
|
||||
...(expectedIntegrity ? { expectedIntegrity } : {}),
|
||||
...(params.request.acknowledgeClawHubRisk ? { acknowledgeClawHubRisk: true } : {}),
|
||||
});
|
||||
if (!result.ok) {
|
||||
return throwInstallFailure(result);
|
||||
}
|
||||
if (expectedPluginId && result.pluginId !== expectedPluginId) {
|
||||
throw new ManagedPluginLifecycleError(
|
||||
`official catalog plugin id mismatch: expected ${expectedPluginId}, got ${result.pluginId}`,
|
||||
);
|
||||
}
|
||||
const install: PluginInstallRecord = {
|
||||
...buildClawHubPluginInstallRecordFields(result.clawhub),
|
||||
spec,
|
||||
installPath: result.targetDir,
|
||||
};
|
||||
const config = await persistManagedPluginInstall({
|
||||
snapshot: params.snapshot,
|
||||
pluginId: result.pluginId,
|
||||
install,
|
||||
targetDir: result.targetDir,
|
||||
extensionsDir,
|
||||
});
|
||||
return { pluginId: result.pluginId, config };
|
||||
}
|
||||
|
||||
async function installFromOfficialCatalog(params: {
|
||||
request: Extract<ManagedPluginInstallRequest, { source: "official" }>;
|
||||
snapshot: ConfigSnapshotForInstallPersist;
|
||||
officialEntries: readonly OfficialExternalPluginCatalogEntry[];
|
||||
env: NodeJS.ProcessEnv;
|
||||
warnings: string[];
|
||||
}): Promise<{ pluginId: string; config: OpenClawConfig }> {
|
||||
const entry = resolveOfficialEntryById(params.officialEntries, params.request.pluginId);
|
||||
if (!entry) {
|
||||
throw new ManagedPluginLifecycleError(
|
||||
`unknown official plugin catalog entry: ${params.request.pluginId}`,
|
||||
);
|
||||
}
|
||||
const pluginId = resolveOfficialExternalPluginId(entry);
|
||||
const install = resolveOfficialExternalPluginInstall(entry, {
|
||||
catalogConfig: params.snapshot.config.marketplaces,
|
||||
});
|
||||
if (!pluginId || !install) {
|
||||
throw new ManagedPluginLifecycleError(
|
||||
`official plugin catalog entry is not installable: ${params.request.pluginId}`,
|
||||
);
|
||||
}
|
||||
const clawhub = install.clawhubSpec ? parseClawHubPluginSpec(install.clawhubSpec) : undefined;
|
||||
if (clawhub) {
|
||||
return await installFromClawHub({
|
||||
request: {
|
||||
source: "clawhub",
|
||||
packageName: clawhub.name,
|
||||
...(clawhub.version ? { version: clawhub.version } : {}),
|
||||
},
|
||||
snapshot: params.snapshot,
|
||||
officialEntries: params.officialEntries,
|
||||
env: params.env,
|
||||
warnings: params.warnings,
|
||||
...(install.expectedIntegrity ? { expectedIntegrity: install.expectedIntegrity } : {}),
|
||||
});
|
||||
}
|
||||
if (!install.npmSpec) {
|
||||
throw new ManagedPluginLifecycleError(
|
||||
`official plugin catalog entry has no supported install source: ${params.request.pluginId}`,
|
||||
);
|
||||
}
|
||||
const extensionsDir = resolveDefaultPluginExtensionsDir(params.env);
|
||||
const result = await installPluginFromNpmSpec({
|
||||
spec: install.npmSpec,
|
||||
config: params.snapshot.config,
|
||||
extensionsDir,
|
||||
expectedPluginId: pluginId,
|
||||
...(install.expectedIntegrity ? { expectedIntegrity: install.expectedIntegrity } : {}),
|
||||
trustedSourceLinkedOfficialInstall: true,
|
||||
logger: createInstallLogger(params.warnings),
|
||||
});
|
||||
if (!result.ok) {
|
||||
return throwInstallFailure(result);
|
||||
}
|
||||
if (result.pluginId !== pluginId) {
|
||||
throw new ManagedPluginLifecycleError(
|
||||
`official catalog plugin id mismatch: expected ${pluginId}, got ${result.pluginId}`,
|
||||
);
|
||||
}
|
||||
const installRecord: PluginInstallRecord = {
|
||||
source: "npm",
|
||||
spec: install.npmSpec,
|
||||
installPath: result.targetDir,
|
||||
...(result.version ? { version: result.version } : {}),
|
||||
...buildNpmResolutionInstallFields(result.npmResolution),
|
||||
};
|
||||
const config = await persistManagedPluginInstall({
|
||||
snapshot: params.snapshot,
|
||||
pluginId,
|
||||
install: installRecord,
|
||||
targetDir: result.targetDir,
|
||||
extensionsDir,
|
||||
});
|
||||
return { pluginId, config };
|
||||
}
|
||||
|
||||
/** Install a ClawHub or curated official plugin through the canonical install pipeline. */
|
||||
export async function installManagedPlugin(params: {
|
||||
request: ManagedPluginInstallRequest;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<{ plugin: ManagedPluginCatalogEntry; warnings?: string[] }> {
|
||||
return await withManagedPluginMutationLock(async () => {
|
||||
const env = params.env ?? process.env;
|
||||
const snapshot = await readPluginMutationSnapshot(env);
|
||||
const officialCatalog = await loadOfficialCatalog(snapshot.config);
|
||||
const warnings: string[] = [];
|
||||
const installed =
|
||||
params.request.source === "clawhub"
|
||||
? await installFromClawHub({
|
||||
request: params.request,
|
||||
snapshot,
|
||||
officialEntries: officialCatalog.entries,
|
||||
env,
|
||||
warnings,
|
||||
})
|
||||
: await installFromOfficialCatalog({
|
||||
request: params.request,
|
||||
snapshot,
|
||||
officialEntries: officialCatalog.entries,
|
||||
env,
|
||||
warnings,
|
||||
});
|
||||
const catalog = await listManagedPlugins({
|
||||
config: installed.config,
|
||||
env,
|
||||
officialCatalog,
|
||||
});
|
||||
const plugin = catalog.plugins.find((entry) => entry.id === installed.pluginId);
|
||||
if (!plugin) {
|
||||
throw new ManagedPluginLifecycleError(
|
||||
`installed plugin missing from refreshed registry: ${installed.pluginId}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
plugin,
|
||||
...(warnings.length > 0 ? { warnings: [...new Set(warnings)] } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Persist desired plugin policy while preserving allow/deny, slot, include, and hash guards. */
|
||||
export async function setManagedPluginEnabled(params: {
|
||||
pluginId: string;
|
||||
enabled: boolean;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<{
|
||||
plugin: ManagedPluginCatalogEntry;
|
||||
changedPaths: string[];
|
||||
warnings?: string[];
|
||||
}> {
|
||||
return await withManagedPluginMutationLock(async () => {
|
||||
const env = params.env ?? process.env;
|
||||
const snapshot = await readPluginMutationSnapshot(env);
|
||||
const metadata = loadPluginMetadataSnapshot({ config: snapshot.config, env });
|
||||
const pluginId = metadata.normalizePluginId(params.pluginId.trim());
|
||||
if (!metadata.index.plugins.some((plugin) => plugin.pluginId === pluginId)) {
|
||||
throw new ManagedPluginLifecycleError(`plugin not installed: ${params.pluginId}`);
|
||||
}
|
||||
let next = snapshot.config;
|
||||
const warnings: string[] = [];
|
||||
let policyPluginId = pluginId;
|
||||
if (params.enabled) {
|
||||
const enableResult = enableExplicitlySelectedPluginInConfig(next, pluginId, {
|
||||
updateChannelConfig: false,
|
||||
});
|
||||
if (!enableResult.enabled) {
|
||||
throw new ManagedPluginLifecycleError(
|
||||
`plugin "${pluginId}" could not be enabled (${enableResult.reason ?? "unknown reason"})`,
|
||||
);
|
||||
}
|
||||
next = enableResult.config;
|
||||
policyPluginId = enableResult.pluginId;
|
||||
const slotResult = applySlotSelectionForPlugin(next, pluginId);
|
||||
next = slotResult.config;
|
||||
warnings.push(...slotResult.warnings);
|
||||
} else {
|
||||
next = setPluginEnabledInConfig(next, pluginId, false, { updateChannelConfig: false });
|
||||
}
|
||||
const changedPaths = new Set<string>();
|
||||
collectChangedPaths(snapshot.config, next, "", changedPaths);
|
||||
await replaceConfigFile({
|
||||
nextConfig: next,
|
||||
baseHash: snapshot.baseHash,
|
||||
writeOptions: snapshot.writeOptions,
|
||||
});
|
||||
await refreshPluginRegistryAfterConfigMutation({
|
||||
config: next,
|
||||
reason: "policy-changed",
|
||||
invalidateRuntimeCache: false,
|
||||
policyPluginIds: [policyPluginId],
|
||||
});
|
||||
const catalog = await listManagedPlugins({ config: next, env });
|
||||
const plugin = catalog.plugins.find((entry) => entry.id === pluginId);
|
||||
if (!plugin) {
|
||||
throw new ManagedPluginLifecycleError(
|
||||
`updated plugin missing from refreshed registry: ${pluginId}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
plugin,
|
||||
changedPaths: [...changedPaths].filter(Boolean).toSorted(),
|
||||
...(warnings.length > 0 ? { warnings } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove an installed plugin: config references, install record, and managed files. */
|
||||
export async function uninstallManagedPlugin(params: {
|
||||
pluginId: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<{ pluginId: string; removed: string[]; warnings?: string[] }> {
|
||||
return await withManagedPluginMutationLock(async () => {
|
||||
const env = params.env ?? process.env;
|
||||
const snapshot = await readPluginMutationSnapshot(env);
|
||||
const installRecords = await loadInstalledPluginIndexInstallRecords();
|
||||
// Mirror the CLI uninstall flow: plan against config carrying install records
|
||||
// so managed npm/git directories resolve, then persist the stripped config.
|
||||
const configWithRecords = withPluginInstallRecords(snapshot.config, installRecords);
|
||||
const metadata = loadPluginMetadataSnapshot({ config: configWithRecords, env });
|
||||
const pluginId = metadata.normalizePluginId(params.pluginId.trim());
|
||||
const record = metadata.index.plugins.find((plugin) => plugin.pluginId === pluginId);
|
||||
if (record?.origin === "bundled") {
|
||||
throw new ManagedPluginLifecycleError(
|
||||
`bundled plugin cannot be uninstalled: ${pluginId}; disable it instead`,
|
||||
);
|
||||
}
|
||||
const manifest = metadata.byPluginId.get(pluginId);
|
||||
// Mirror the CLI cold path: pass channel ownership only when declared so
|
||||
// planPluginUninstall keeps its plugin-id fallback for channel config keys.
|
||||
const channelIds = manifest && manifest.channels.length > 0 ? manifest.channels : undefined;
|
||||
const extensionsDir = resolveDefaultPluginExtensionsDir(env);
|
||||
const plan = planPluginUninstall({
|
||||
config: configWithRecords,
|
||||
pluginId,
|
||||
...(channelIds ? { channelIds } : {}),
|
||||
deleteFiles: true,
|
||||
extensionsDir,
|
||||
});
|
||||
if (!plan.ok) {
|
||||
throw new ManagedPluginLifecycleError(plan.error);
|
||||
}
|
||||
const nextConfig = withoutPluginInstallRecords(plan.config);
|
||||
const nextInstallRecords = removePluginInstallRecordFromRecords(installRecords, pluginId);
|
||||
await commitPluginInstallRecordsWithConfig({
|
||||
previousInstallRecords: installRecords,
|
||||
nextInstallRecords,
|
||||
nextConfig,
|
||||
baseHash: snapshot.baseHash,
|
||||
writeOptions: snapshot.writeOptions,
|
||||
});
|
||||
const directoryResult = await applyPluginUninstallDirectoryRemoval(plan.directoryRemoval);
|
||||
const warnings = [...directoryResult.warnings];
|
||||
await refreshPluginRegistryAfterConfigMutation({
|
||||
config: nextConfig,
|
||||
reason: "source-changed",
|
||||
installRecords: nextInstallRecords,
|
||||
invalidateRuntimeCache: false,
|
||||
logger: { warn: (message) => warnings.push(message) },
|
||||
});
|
||||
const removed = formatUninstallActionLabels({
|
||||
...plan.actions,
|
||||
directory: directoryResult.directoryRemoved,
|
||||
});
|
||||
return {
|
||||
pluginId,
|
||||
removed,
|
||||
...(warnings.length > 0 ? { warnings: [...new Set(warnings)] } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Normalize unexpected lifecycle failures for Gateway response adapters. */
|
||||
export function formatManagedPluginLifecycleError(error: unknown): string {
|
||||
return formatErrorMessage(error);
|
||||
}
|
||||
@@ -478,6 +478,25 @@ describe("loadPluginManifestRegistry", () => {
|
||||
expect(registry.plugins[0]?.icon).toBe("https://cdn.simpleicons.org/simpleicons");
|
||||
});
|
||||
|
||||
it("preserves manifest catalog metadata on registry records", () => {
|
||||
const dir = makeTempDir();
|
||||
writeManifest(dir, {
|
||||
id: "catalog-demo",
|
||||
catalog: { featured: true, order: 20 },
|
||||
configSchema: { type: "object" },
|
||||
});
|
||||
|
||||
const registry = loadRegistry([
|
||||
createPluginCandidate({
|
||||
idHint: "catalog-demo",
|
||||
rootDir: dir,
|
||||
origin: "bundled",
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(registry.plugins[0]?.catalog).toEqual({ featured: true, order: 20 });
|
||||
});
|
||||
|
||||
it("keeps only the higher-precedence plugin for truly distinct duplicates", () => {
|
||||
const dirA = makeTempDir();
|
||||
const dirB = makeTempDir();
|
||||
@@ -1426,6 +1445,26 @@ describe("loadPluginManifestRegistry", () => {
|
||||
expectNoRegistryDiagnosticContains(registry, "without channelConfigs metadata");
|
||||
});
|
||||
|
||||
it("hydrates and overlays official external catalog curation metadata", () => {
|
||||
const dir = makeTempDir();
|
||||
writeManifest(dir, {
|
||||
id: "diffs",
|
||||
catalog: { featured: false },
|
||||
configSchema: { type: "object" },
|
||||
});
|
||||
|
||||
const registry = loadRegistry([
|
||||
createPluginCandidate({
|
||||
idHint: "diffs",
|
||||
rootDir: dir,
|
||||
origin: "global",
|
||||
packageName: "@openclaw/diffs",
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(registry.plugins[0]?.catalog).toEqual({ featured: false, order: 40 });
|
||||
});
|
||||
|
||||
it("fills missing official external catalog descriptors for partial npm channel configs", () => {
|
||||
const dir = makeTempDir();
|
||||
writeManifest(dir, {
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
loadPluginManifest,
|
||||
type OpenClawPackageManifest,
|
||||
type PluginManifestActivation,
|
||||
type PluginManifestCatalog,
|
||||
type PluginManifestConfigContracts,
|
||||
type PluginManifest,
|
||||
type PluginManifestCapabilityProviderMetadata,
|
||||
@@ -202,6 +203,7 @@ export type PluginManifestRecord = {
|
||||
id: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
catalog?: PluginManifestCatalog;
|
||||
icon?: string;
|
||||
version?: string;
|
||||
packageName?: string;
|
||||
@@ -444,6 +446,26 @@ function mergeCatalogChannelConfigs(params: {
|
||||
return Object.keys(merged).length > 0 ? merged : undefined;
|
||||
}
|
||||
|
||||
function mergeManifestCatalog(
|
||||
manifestCatalog: PluginManifestCatalog | undefined,
|
||||
officialCatalog: PluginManifestCatalog | undefined,
|
||||
): PluginManifestCatalog | undefined {
|
||||
const featuredCandidate = manifestCatalog?.featured ?? officialCatalog?.featured;
|
||||
const orderCandidate = manifestCatalog?.order ?? officialCatalog?.order;
|
||||
const featured = typeof featuredCandidate === "boolean" ? featuredCandidate : undefined;
|
||||
const order =
|
||||
typeof orderCandidate === "number" && Number.isFinite(orderCandidate)
|
||||
? orderCandidate
|
||||
: undefined;
|
||||
if (featured === undefined && order === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
...(featured !== undefined ? { featured } : {}),
|
||||
...(order !== undefined ? { order } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildRecord(params: {
|
||||
manifest: PluginManifest;
|
||||
candidate: PluginCandidate;
|
||||
@@ -491,6 +513,7 @@ function buildRecord(params: {
|
||||
name: normalizeOptionalString(params.manifest.name) ?? params.candidate.packageName,
|
||||
description:
|
||||
normalizeOptionalString(params.manifest.description) ?? params.candidate.packageDescription,
|
||||
catalog: mergeManifestCatalog(params.manifest.catalog, officialCatalogManifest?.catalog),
|
||||
icon: normalizeOptionalString(params.manifest.icon),
|
||||
version: normalizeOptionalString(params.manifest.version) ?? params.candidate.packageVersion,
|
||||
packageName: params.candidate.packageName,
|
||||
|
||||
@@ -148,6 +148,26 @@ describe("loadPluginManifest JSON5 tolerance", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("normalizes catalog curation metadata from the manifest", () => {
|
||||
const dir = makeTempDir();
|
||||
const json5Content = `{
|
||||
id: "catalog-plugin",
|
||||
catalog: {
|
||||
featured: false,
|
||||
order: 0,
|
||||
},
|
||||
configSchema: { type: "object" }
|
||||
}`;
|
||||
fs.writeFileSync(path.join(dir, "openclaw.plugin.json"), json5Content, "utf-8");
|
||||
|
||||
const result = loadPluginManifest(dir, false);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.manifest.catalog).toEqual({ featured: false, order: 0 });
|
||||
}
|
||||
});
|
||||
|
||||
it("normalizes activation and setup descriptor metadata from the manifest", () => {
|
||||
const dir = makeTempDir();
|
||||
const json5Content = `{
|
||||
|
||||
@@ -294,6 +294,11 @@ export type PluginManifestConfigContracts = {
|
||||
secretInputs?: PluginManifestSecretInputContracts;
|
||||
};
|
||||
|
||||
export type PluginManifestCatalog = {
|
||||
featured?: boolean;
|
||||
order?: number;
|
||||
};
|
||||
|
||||
export type PluginManifest = {
|
||||
id: string;
|
||||
configSchema: JsonSchemaObject;
|
||||
@@ -378,6 +383,8 @@ export type PluginManifest = {
|
||||
skills?: string[];
|
||||
name?: string;
|
||||
description?: string;
|
||||
/** Optional presentation hints for plugin catalog surfaces. */
|
||||
catalog?: PluginManifestCatalog;
|
||||
/** Optional HTTPS URL for marketplace/catalog card artwork. */
|
||||
icon?: string;
|
||||
version?: string;
|
||||
@@ -847,6 +854,22 @@ function normalizePluginToolMetadata(
|
||||
return Object.keys(normalized).length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
function normalizeManifestCatalog(value: unknown): PluginManifestCatalog | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const featured = typeof value.featured === "boolean" ? value.featured : undefined;
|
||||
const order =
|
||||
typeof value.order === "number" && Number.isFinite(value.order) ? value.order : undefined;
|
||||
if (featured === undefined && order === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
...(featured !== undefined ? { featured } : {}),
|
||||
...(order !== undefined ? { order } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeManifestContracts(value: unknown): PluginManifestContracts | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
@@ -1776,6 +1799,7 @@ export function loadPluginManifest(
|
||||
);
|
||||
const name = normalizeOptionalString(raw.name);
|
||||
const description = normalizeOptionalString(raw.description);
|
||||
const catalog = normalizeManifestCatalog(raw.catalog);
|
||||
const icon = normalizeOptionalString(raw.icon);
|
||||
const version = normalizeOptionalString(raw.version);
|
||||
const channels = normalizeTrimmedStringList(raw.channels);
|
||||
@@ -1871,6 +1895,7 @@ export function loadPluginManifest(
|
||||
skills,
|
||||
name,
|
||||
description,
|
||||
catalog,
|
||||
icon,
|
||||
version,
|
||||
uiHints,
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_URL,
|
||||
createInMemoryHostedOfficialExternalPluginCatalogSnapshotStore,
|
||||
getOfficialExternalPluginCatalogEntry,
|
||||
getOfficialExternalPluginCatalogManifest,
|
||||
isOfficialExternalPluginCatalogFeed,
|
||||
filterOfficialExternalPluginCatalogEntriesBySourceRefs,
|
||||
listOfficialExternalPluginCatalogEntries,
|
||||
@@ -131,6 +132,28 @@ describe("official external plugin catalog", () => {
|
||||
expect(officialExternalPluginCatalog.entries.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("curates featured external plugins with ClawHub install alternatives", () => {
|
||||
const featured = [
|
||||
["diffs", "@openclaw/diffs", 40],
|
||||
["lobster", "@openclaw/lobster", 50],
|
||||
["tokenjuice", "@openclaw/tokenjuice", 60],
|
||||
["memory-lancedb", "@openclaw/memory-lancedb", 70],
|
||||
] as const;
|
||||
|
||||
for (const [id, npmSpec, order] of featured) {
|
||||
const entry = expectCatalogEntry(id);
|
||||
expect(getOfficialExternalPluginCatalogManifest(entry)?.catalog).toEqual({
|
||||
featured: true,
|
||||
order,
|
||||
});
|
||||
expect(resolveOfficialExternalPluginInstall(entry)).toMatchObject({
|
||||
clawhubSpec: `clawhub:${npmSpec}`,
|
||||
npmSpec,
|
||||
defaultChoice: "npm",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("does not allow malformed feed wrappers to count as feed documents", () => {
|
||||
expect(
|
||||
isOfficialExternalPluginCatalogFeed({
|
||||
|
||||
@@ -9,6 +9,7 @@ import { MANIFEST_KEY } from "../compat/legacy-names.js";
|
||||
import { normalizeClawHubSha256Integrity } from "../infra/clawhub.js";
|
||||
import { isRecord } from "../utils.js";
|
||||
import type {
|
||||
PluginManifestCatalog,
|
||||
PluginManifestChannelConfig,
|
||||
PluginManifestContracts,
|
||||
PluginManifestProviderEndpoint,
|
||||
@@ -76,6 +77,7 @@ export type OfficialExternalPluginCatalogManifest = {
|
||||
id?: string;
|
||||
label?: string;
|
||||
};
|
||||
catalog?: PluginManifestCatalog;
|
||||
channel?: {
|
||||
id?: string;
|
||||
label?: string;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// Registry refresh helper shared by plugin config mutations that need post-write discovery repair.
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { loadInstalledPluginIndexInstallRecords } from "../plugins/installed-plugin-index-records.js";
|
||||
import type { InstalledPluginIndexRefreshReason } from "../plugins/installed-plugin-index.js";
|
||||
import { tracePluginLifecyclePhaseAsync } from "../plugins/plugin-lifecycle-trace.js";
|
||||
import { refreshPluginRegistry } from "../plugins/plugin-registry.js";
|
||||
import { loadInstalledPluginIndexInstallRecords } from "./installed-plugin-index-records.js";
|
||||
import type { InstalledPluginIndexRefreshReason } from "./installed-plugin-index.js";
|
||||
import { tracePluginLifecyclePhaseAsync } from "./plugin-lifecycle-trace.js";
|
||||
import { refreshPluginRegistry } from "./plugin-registry.js";
|
||||
|
||||
/** Optional warning sink for best-effort registry/cache refresh failures. */
|
||||
export type PluginRegistryRefreshLogger = {
|
||||
@@ -56,7 +56,7 @@ export async function invalidatePluginRuntimeDiscoveryAfterConfigMutation(params
|
||||
logger?: PluginRegistryRefreshLogger;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const { clearPluginRegistryLoadCache } = await import("../plugins/loader.js");
|
||||
const { clearPluginRegistryLoadCache } = await import("./loader.js");
|
||||
clearPluginRegistryLoadCache();
|
||||
} catch (error) {
|
||||
params.logger?.warn?.(`Plugin runtime cache invalidation failed: ${formatErrorMessage(error)}`);
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { PluginKind } from "./plugin-kind.types.js";
|
||||
import { loadPluginMetadataSnapshot } from "./plugin-metadata-snapshot.js";
|
||||
import { applyExclusiveSlotSelection } from "./slots.js";
|
||||
import { buildPluginDiagnosticsReport } from "./status.js";
|
||||
|
||||
type SlotSelectionPlugin = {
|
||||
id: string;
|
||||
kind?: PluginKind | PluginKind[];
|
||||
};
|
||||
|
||||
type SlotSelectionRegistry = {
|
||||
plugins: SlotSelectionPlugin[];
|
||||
};
|
||||
|
||||
function mergeRuntimeKinds(
|
||||
report: SlotSelectionRegistry,
|
||||
runtimeReport: SlotSelectionRegistry,
|
||||
): SlotSelectionRegistry {
|
||||
const runtimeKinds = new Map(
|
||||
runtimeReport.plugins
|
||||
.filter((plugin) => plugin.kind)
|
||||
.map((plugin) => [plugin.id, plugin.kind] as const),
|
||||
);
|
||||
return {
|
||||
plugins: report.plugins.map((plugin) => {
|
||||
if (plugin.kind) {
|
||||
return plugin;
|
||||
}
|
||||
const runtimeKind = runtimeKinds.get(plugin.id);
|
||||
return runtimeKind ? { ...plugin, kind: runtimeKind } : plugin;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function loadRuntimeKindReportForPlugins(config: OpenClawConfig, pluginIds: readonly string[]) {
|
||||
return buildPluginDiagnosticsReport({
|
||||
config,
|
||||
onlyPluginIds: [...pluginIds],
|
||||
});
|
||||
}
|
||||
|
||||
function buildSlotSelectionRegistry(
|
||||
config: OpenClawConfig,
|
||||
pluginId: string,
|
||||
): SlotSelectionRegistry {
|
||||
const plugins = loadPluginMetadataSnapshot({
|
||||
config,
|
||||
env: process.env,
|
||||
}).plugins.filter((plugin) => plugin.id === pluginId);
|
||||
return {
|
||||
plugins: plugins.map((plugin) => ({
|
||||
id: plugin.id,
|
||||
kind: plugin.kind,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function applySlotSelectionForPlugin(
|
||||
config: OpenClawConfig,
|
||||
pluginId: string,
|
||||
): { config: OpenClawConfig; warnings: string[] } {
|
||||
// Static metadata is preferred; runtime diagnostics fill in kind for older manifests.
|
||||
const report = buildSlotSelectionRegistry(config, pluginId);
|
||||
const plugin = report.plugins.find((entry) => entry.id === pluginId);
|
||||
if (!plugin) {
|
||||
return { config, warnings: [] };
|
||||
}
|
||||
if (!plugin.kind) {
|
||||
const runtimeReport = loadRuntimeKindReportForPlugins(config, [plugin.id]);
|
||||
const runtimePlugin = runtimeReport.plugins.find((entry) => entry.id === plugin.id);
|
||||
if (runtimePlugin?.kind) {
|
||||
const result = applyExclusiveSlotSelection({
|
||||
config,
|
||||
selectedId: runtimePlugin.id,
|
||||
selectedKind: runtimePlugin.kind,
|
||||
registry: mergeRuntimeKinds(report, runtimeReport),
|
||||
});
|
||||
return { config: result.config, warnings: result.warnings };
|
||||
}
|
||||
}
|
||||
const result = applyExclusiveSlotSelection({
|
||||
config,
|
||||
selectedId: plugin.id,
|
||||
selectedKind: plugin.kind,
|
||||
registry: report,
|
||||
});
|
||||
return { config: result.config, warnings: result.warnings };
|
||||
}
|
||||
@@ -6,10 +6,9 @@ const mocks = vi.hoisted(() => ({
|
||||
commitConfigWriteWithPendingPluginInstalls: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../cli/plugins-install-record-commit.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../cli/plugins-install-record-commit.js")>()),
|
||||
commitConfigWriteWithPendingPluginInstalls:
|
||||
mocks.commitConfigWriteWithPendingPluginInstalls,
|
||||
vi.mock("../plugins/install-record-commit.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../plugins/install-record-commit.js")>()),
|
||||
commitConfigWriteWithPendingPluginInstalls: mocks.commitConfigWriteWithPendingPluginInstalls,
|
||||
}));
|
||||
|
||||
import { writeWizardConfigFile } from "./setup.shared.js";
|
||||
@@ -32,9 +31,9 @@ describe("writeWizardConfigFile pending install ownership", () => {
|
||||
plugins: { installs: { demo: { source: "npm", spec: "demo@1.0.0" } } },
|
||||
};
|
||||
|
||||
await expect(
|
||||
writeWizardConfigFile(config, { allowConfigSizeDrop: false }),
|
||||
).rejects.toThrow("declare migration ownership");
|
||||
await expect(writeWizardConfigFile(config, { allowConfigSizeDrop: false })).rejects.toThrow(
|
||||
"declare migration ownership",
|
||||
);
|
||||
expect(mocks.commitConfigWriteWithPendingPluginInstalls).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
// Shared setup-wizard steps used by the classic wizard and the bootstrap onboarding flow.
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import type { GatewayAuthChoice, OnboardOptions } from "../commands/onboard-types.js";
|
||||
import { createConfigIO, replaceConfigFile, resolveGatewayPort } from "../config/config.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import {
|
||||
commitConfigWriteWithPendingPluginInstalls,
|
||||
hasPendingPluginInstallRecords,
|
||||
stripPendingPluginInstallRecords,
|
||||
unchangedPendingPluginInstallRecordIds,
|
||||
} from "../cli/plugins-install-record-commit.js";
|
||||
import type { GatewayAuthChoice, OnboardOptions } from "../commands/onboard-types.js";
|
||||
import { createConfigIO, replaceConfigFile, resolveGatewayPort } from "../config/config.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
} from "../plugins/install-record-commit.js";
|
||||
import { isPlainObject } from "../utils.js";
|
||||
import { t } from "./i18n/index.js";
|
||||
import { WizardCancelledError, type WizardPrompter } from "./prompts.js";
|
||||
|
||||
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 13 KiB |