mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-04 02:00:10 +00:00
feat: inspect ClawHub plugin and skill details before installation (#163460)
* feat: inspect ClawHub plugin and skill details before installation * fix: keep ClawHub release projection types acyclic
This commit is contained in:
parent
3932cbe4c9
commit
259033fe97
24 changed files with 1252 additions and 133 deletions
|
|
@ -69,7 +69,9 @@ Methods an operator client calls on behalf of a person: helper reads, exec appro
|
|||
- The response includes eligibility, missing requirements, config checks,
|
||||
and sanitized install options without exposing raw secret values.
|
||||
- `skills.search` and `skills.detail` (`operator.read`) return ClawHub
|
||||
discovery metadata.
|
||||
discovery metadata. `skills.detail({ slug, version? })` accepts the publisher-qualified
|
||||
`installRef` from search and reads that release's card and scan summary. See
|
||||
[Skill registry details](/gateway/protocol/operator-methods#skill-registry-details).
|
||||
- `skills.upload.begin`, `skills.upload.chunk`, and `skills.upload.commit`
|
||||
(`operator.admin`) stage a private skill archive before installing it. This
|
||||
is a separate admin upload path for trusted clients, not the normal ClawHub
|
||||
|
|
@ -217,6 +219,44 @@ capability before sending the new fields; an older Gateway requires an update
|
|||
or restart, not a silent local fallback. The model CLI uses this contract for
|
||||
`models list` and `models list --refresh`.
|
||||
|
||||
## Skill registry details
|
||||
|
||||
Use the exact `installRef` from `skills.search` when requesting details. For example:
|
||||
|
||||
```json
|
||||
{ "slug": "@example-publisher/example-skill", "version": "1.2.0" }
|
||||
```
|
||||
|
||||
Omitting `version` selects the latest published release. The response retains
|
||||
`skill`, `latestVersion`, `metadata`, and `owner`, and adds `registry`, `source`,
|
||||
`installRef`, and `selectedRelease`. `latestVersion` and `metadata` always describe
|
||||
the listing's latest release; `selectedRelease`, `card`, and `security` describe
|
||||
the requested release. Publisher and release mismatches never substitute another
|
||||
skill or version.
|
||||
|
||||
`card` contains full card text when its `status` is `available`. Otherwise it has
|
||||
`status: "unavailable"` and a `reason`. `security` reports `scanStatus`,
|
||||
`hasWarnings`, `hasScanResult`, and any scan time, summary, or VirusTotal URL.
|
||||
Missing scans are explicitly unavailable. Optional release or card failures leave
|
||||
basic listing metadata readable and appear in the affected section or `warnings`.
|
||||
|
||||
`requirements` reports the latest release's registry setup keys, operating
|
||||
systems, and systems when available. Its `scope: "registry-setup"` and `note`
|
||||
explain that setup keys combine environment and configuration requirements and do
|
||||
not include binary requirements. Structured requirements for older releases are
|
||||
unavailable because ClawHub only publishes these facts for latest. These are
|
||||
registry declarations, not checks of a local agent's eligibility; use
|
||||
`skills.status` for local requirements and configuration checks.
|
||||
|
||||
`downloadability` is independent of card availability, listing visibility, and
|
||||
scan results. Missing or removed releases are `unavailable`; other skill releases
|
||||
are `unknown` because ClawHub does not publish an exact-release artifact
|
||||
availability assertion. Both states include a reason. Installation still performs
|
||||
its own resolution, integrity, and policy checks.
|
||||
|
||||
External `skills-sh:` references remain install-only. `skills.detail` rejects
|
||||
them rather than returning a native registry skill with the same slug.
|
||||
|
||||
## Exec approvals
|
||||
|
||||
- When an exec request needs approval, the gateway broadcasts
|
||||
|
|
|
|||
|
|
@ -52,9 +52,10 @@ RPC method families for gateway status and identity, models and usage, channels
|
|||
## 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. It includes the current runtime `generation` and each plugin's runtime state separately from configured enablement.
|
||||
- `plugins.inspect` (`operator.read`) inspects one plugin with `{ pluginId }`, including declared capabilities, grants, trust details, and a `reviewToken` for capability consent.
|
||||
- `plugins.inspect` (`operator.read`) accepts `{ pluginId }` for installed, staged, or official candidates; `{ source: "clawhub", packageName, version? }` for arbitrary ClawHub plugins; or `{ catalogId, version? }` using a discovery identity. Installed and staged inspections include a `reviewToken` for capability consent. Remote inspections expose the selected catalog detail, applicable grants, and release trust. Their `declaredSurfaceStatus` is `partial` or `unavailable`: registry summaries omit some capability groups and package siblings, so they cannot issue a consent token. Empty unsupported groups do not mean the package declares no such capabilities.
|
||||
- `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.catalog.browse` (`operator.read`) returns ClawHub discovery results with Gateway-local installed and bundled state. The Control UI adds `searchSource: "openclaw-control-ui"` only after manual input of at least two characters settles for 250 ms. Initial browsing, refreshes, filter changes, and generic API searches omit it. The Gateway honors `CLAWHUB_DISABLE_TELEMETRY` and does not replay attributed HTTP searches after transient failures. ClawHub records the normalized query, source, and remote result counts; those counts exclude local-only matches added by the Gateway. Installed inventory and operator, device, and session identities are not included in the observation.
|
||||
- `plugins.catalog.get` (`operator.read`) accepts `{ id, version? }` using the unchanged discovery ID from `plugins.catalog.browse`. Detail includes publisher metadata, README, topics, package tags, selected-release notes, capabilities, configuration, verification, and security when supplied by ClawHub. `detail.selectedRelease` names the actual release independently of `plugin.catalog.latestVersion`; null means no release was selected. `detail.metadata` explicitly reports available or missing README, manifest, and security data. An installed counterpart can supply local detail during registry outages; `remoteError` explains the failure, and remote selected-release facts remain unknown. Local-only identities do not support remote version selection.
|
||||
- `plugins.install` (`operator.admin`) accepts these source-specific request fields:
|
||||
|
||||
| `source` | Fields |
|
||||
|
|
@ -77,6 +78,33 @@ RPC method families for gateway status and identity, models and usage, channels
|
|||
- `plugins.refresh` (`operator.admin`) refreshes plugin metadata and applies the resulting registry with `{}`.
|
||||
- `plugins.uninstall` (`operator.admin`) removes one externally installed plugin with `{ pluginId, keepFiles? }`: config references, the install record, and managed files. Bundled plugins cannot be uninstalled, only disabled. The response lists the removal actions.
|
||||
|
||||
### Catalog detail and client confirmation
|
||||
|
||||
`detail.downloadability` has one of these shapes:
|
||||
|
||||
| Status | Meaning |
|
||||
| ---------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| `{ "status": "downloadable" }` | The source has confirmed selected-release artifact availability. |
|
||||
| `{ "status": "unavailable", "reason": "..." }` | A missing release or source download policy prevents download. |
|
||||
| `{ "status": "unknown", "reason": "..." }` | The source cannot establish availability, or the registry read failed. |
|
||||
|
||||
ClawHub currently exposes a selected plugin release's download-policy block, but
|
||||
its read-only detail and artifact resolver do not check stored artifact bytes.
|
||||
A permitted security verdict, download URL, listing, or local install action
|
||||
therefore produces `unknown`, never `downloadable`. A side-effect-free per-release
|
||||
availability fact requires a ClawHub contract extension. Gateway inspection does
|
||||
not download packages to probe them; registry download routes record telemetry.
|
||||
|
||||
A native client can fetch `plugins.catalog.get`, inspect the same `catalogId`,
|
||||
display the returned facts, and collect confirmation itself. After approval,
|
||||
call `plugins.install` with `source: "clawhub"`, the exact returned `packageName`,
|
||||
and `selectedRelease.version` when present. Preserve scope and publisher spelling;
|
||||
do not rebuild the package name from a runtime plugin ID. If installation requires
|
||||
capability consent or install-policy acknowledgment, display that owner-issued
|
||||
review and retry the same intent with its acknowledgment. Catalog inspection does
|
||||
not grant consent or bypass install policy, integrity, trust, or authorization.
|
||||
Cancel sends no installation RPC. The Gateway does not manage confirmation dialogs.
|
||||
|
||||
Runtime-only refresh works with read-only, Nix-managed, and root `$include` configurations without rewriting them.
|
||||
Plugin lifecycle and Claw package removal requests return retryable `UNAVAILABLE` with `retryAfterMs` when another plugin or config operation is already applying. This busy response occurs before the requested mutation starts; retry after the current operation completes. Failures after a mutation starts retain their application details and are not automatically retryable.
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ import {
|
|||
import { NonEmptyString, Sha256String } from "./primitives.js";
|
||||
import { GitHubSetupHandleSchema } from "./secrets.js";
|
||||
import { SessionPermissionModeSchema } from "./sessions-row.js";
|
||||
import { SkillsDetailResultSchema } from "./skill-detail.js";
|
||||
|
||||
export { SkillsDetailResultSchema } from "./skill-detail.js";
|
||||
|
||||
export {
|
||||
ModelChoiceSchema,
|
||||
|
|
@ -397,6 +400,7 @@ export const SkillsSearchResultSchema = closedObject({
|
|||
/** Reads registry detail for one skill. */
|
||||
export const SkillsDetailParamsSchema = closedObject({
|
||||
slug: Type.String({ minLength: 1, description: CLAWHUB_SKILL_REF_DESCRIPTION }),
|
||||
version: Type.Optional(NonEmptyString),
|
||||
});
|
||||
|
||||
/** Reads current security verdicts for configured skills. */
|
||||
|
|
@ -404,56 +408,6 @@ export const SkillsSecurityVerdictsParamsSchema = closedObject({
|
|||
agentId: Type.Optional(NonEmptyString),
|
||||
});
|
||||
|
||||
/** Skill registry detail, latest version, metadata, and owner info. */
|
||||
export const SkillsDetailResultSchema = closedObject({
|
||||
skill: Type.Union([
|
||||
closedObject({
|
||||
slug: NonEmptyString,
|
||||
displayName: NonEmptyString,
|
||||
summary: Type.Optional(Type.String()),
|
||||
icon: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
||||
tags: Type.Optional(Type.Record(NonEmptyString, Type.String())),
|
||||
channel: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
||||
isOfficial: Type.Optional(Type.Union([Type.Boolean(), Type.Null()])),
|
||||
createdAt: Type.Integer(),
|
||||
updatedAt: Type.Integer(),
|
||||
}),
|
||||
Type.Null(),
|
||||
]),
|
||||
latestVersion: Type.Optional(
|
||||
Type.Union([
|
||||
closedObject({
|
||||
version: NonEmptyString,
|
||||
createdAt: Type.Integer(),
|
||||
changelog: Type.Optional(Type.String()),
|
||||
}),
|
||||
Type.Null(),
|
||||
]),
|
||||
),
|
||||
metadata: Type.Optional(
|
||||
Type.Union([
|
||||
closedObject({
|
||||
os: Type.Optional(Type.Union([Type.Array(Type.String()), Type.Null()])),
|
||||
systems: Type.Optional(Type.Union([Type.Array(Type.String()), Type.Null()])),
|
||||
}),
|
||||
Type.Null(),
|
||||
]),
|
||||
),
|
||||
owner: Type.Optional(
|
||||
Type.Union([
|
||||
closedObject({
|
||||
handle: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
displayName: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
image: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
||||
official: Type.Optional(Type.Union([Type.Boolean(), Type.Null()])),
|
||||
channel: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
||||
isOfficial: Type.Optional(Type.Union([Type.Boolean(), Type.Null()])),
|
||||
}),
|
||||
Type.Null(),
|
||||
]),
|
||||
),
|
||||
});
|
||||
|
||||
/** Security verdict report for installed/requested skills. */
|
||||
export const SkillsSecurityVerdictsResultSchema = closedObject({
|
||||
schema: Type.Literal("openclaw.skills.security-verdicts.v1"),
|
||||
|
|
|
|||
28
packages/gateway-protocol/src/schema/clawhub-listing.ts
Normal file
28
packages/gateway-protocol/src/schema/clawhub-listing.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { Type, type Static } from "typebox";
|
||||
import { closedObject } from "./closed-object.js";
|
||||
import { NonEmptyString } from "./primitives.js";
|
||||
|
||||
/** The release actually selected by the registry, independently of listing latest metadata. */
|
||||
export const ClawHubSelectedReleaseSchema = closedObject({
|
||||
version: NonEmptyString,
|
||||
createdAt: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
changelog: Type.Optional(Type.String()),
|
||||
tags: Type.Optional(Type.Array(NonEmptyString)),
|
||||
});
|
||||
|
||||
/** Artifact availability is advisory; installation still rechecks integrity and policy. */
|
||||
export const ClawHubDownloadabilitySchema = Type.Union([
|
||||
closedObject({ status: Type.Literal("downloadable") }),
|
||||
closedObject({ status: Type.Literal("unavailable"), reason: NonEmptyString }),
|
||||
closedObject({ status: Type.Literal("unknown"), reason: NonEmptyString }),
|
||||
]);
|
||||
|
||||
export type ClawHubSelectedRelease = Static<typeof ClawHubSelectedReleaseSchema>;
|
||||
export type ClawHubDownloadability = Static<typeof ClawHubDownloadabilitySchema>;
|
||||
|
||||
/** Presence of the selected plugin release's registry summary, README, and scan metadata. */
|
||||
export const ClawHubPluginMetadataSchema = closedObject({
|
||||
manifest: Type.Union([Type.Literal("available"), Type.Literal("missing")]),
|
||||
readme: Type.Union([Type.Literal("available"), Type.Literal("missing")]),
|
||||
security: Type.Union([Type.Literal("available"), Type.Literal("missing")]),
|
||||
});
|
||||
|
|
@ -2,6 +2,11 @@
|
|||
import type { Static } from "typebox";
|
||||
import { Type } from "typebox";
|
||||
import { PLUGIN_UI_CAPABILITIES } from "../plugin-ui-capabilities.js";
|
||||
import {
|
||||
ClawHubDownloadabilitySchema,
|
||||
ClawHubSelectedReleaseSchema,
|
||||
ClawHubPluginMetadataSchema,
|
||||
} from "./clawhub-listing.js";
|
||||
import { closedObject } from "./closed-object.js";
|
||||
import {
|
||||
ControlUiLinkReaderMetadataSchema,
|
||||
|
|
@ -297,9 +302,18 @@ export const PluginsListResultSchema = closedObject({
|
|||
});
|
||||
|
||||
/** Request payload for inspecting one plugin's declared capability surface. */
|
||||
export const PluginsInspectParamsSchema = closedObject({
|
||||
pluginId: NonEmptyString,
|
||||
});
|
||||
export const PluginsInspectParamsSchema = Type.Union([
|
||||
closedObject({ pluginId: NonEmptyString }),
|
||||
closedObject({
|
||||
source: Type.Literal("clawhub"),
|
||||
packageName: NonEmptyString,
|
||||
version: Type.Optional(NonEmptyString),
|
||||
}),
|
||||
closedObject({
|
||||
catalogId: Type.String({ minLength: 1, maxLength: 512, pattern: "^[A-Za-z0-9_-]+$" }),
|
||||
version: Type.Optional(NonEmptyString),
|
||||
}),
|
||||
]);
|
||||
|
||||
/** Newly declared capability items grouped by their existing manifest surface. */
|
||||
export const PluginDeclaredSurfaceWideningSchema = Type.Partial(PluginDeclaredSurfaceSchema, {
|
||||
|
|
@ -472,6 +486,13 @@ const PluginDiscoveryVersionSchema = closedObject({
|
|||
export const PluginDiscoveryDetailSchema = closedObject({
|
||||
origin: Type.Union([Type.Literal("clawhub"), Type.Literal("local")]),
|
||||
packageName: Type.Optional(NonEmptyString),
|
||||
registry: Type.Optional(NonEmptyString),
|
||||
requestedVersion: Type.Optional(NonEmptyString),
|
||||
tags: Type.Optional(Type.Record(NonEmptyString, NonEmptyString)),
|
||||
selectedRelease: Type.Optional(Type.Union([ClawHubSelectedReleaseSchema, Type.Null()])),
|
||||
downloadability: Type.Optional(ClawHubDownloadabilitySchema),
|
||||
remoteError: Type.Optional(Type.String()),
|
||||
metadata: Type.Optional(ClawHubPluginMetadataSchema),
|
||||
author: Type.Optional(
|
||||
closedObject({
|
||||
handle: Type.Optional(NonEmptyString),
|
||||
|
|
@ -588,7 +609,11 @@ export const PluginsInspectResultSchema = closedObject({
|
|||
source: Type.Optional(PluginInspectSourceSchema),
|
||||
declared: PluginDeclaredSurfaceSchema,
|
||||
components: PluginInstalledComponentsSchema,
|
||||
reviewToken: NonEmptyString,
|
||||
/** Catalog summaries are partial and cannot produce a capability-consent token. */
|
||||
reviewToken: Type.Optional(NonEmptyString),
|
||||
declaredSurfaceStatus: Type.Optional(
|
||||
Type.Union([Type.Literal("partial"), Type.Literal("unavailable")]),
|
||||
),
|
||||
grants: PluginOperatorGrantsSchema,
|
||||
trust: Type.Optional(PluginInstallTrustSchema),
|
||||
/** Exact installed-version ClawHub metadata when a canonical package match exists. */
|
||||
|
|
|
|||
124
packages/gateway-protocol/src/schema/skill-detail.ts
Normal file
124
packages/gateway-protocol/src/schema/skill-detail.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { Type } from "typebox";
|
||||
import { ClawHubDownloadabilitySchema, ClawHubSelectedReleaseSchema } from "./clawhub-listing.js";
|
||||
import { closedObject } from "./closed-object.js";
|
||||
import { NonEmptyString } from "./primitives.js";
|
||||
|
||||
/** Skill registry detail, latest version, metadata, and owner info. */
|
||||
const SkillRegistrySetupSchema = Type.Array(
|
||||
closedObject({
|
||||
key: NonEmptyString,
|
||||
required: Type.Boolean(),
|
||||
}),
|
||||
);
|
||||
const SkillDetailUnavailableSchema = closedObject({
|
||||
status: Type.Literal("unavailable"),
|
||||
reason: NonEmptyString,
|
||||
});
|
||||
|
||||
export const SkillsDetailResultSchema = closedObject({
|
||||
registry: Type.Optional(NonEmptyString),
|
||||
source: Type.Optional(Type.Literal("clawhub")),
|
||||
installRef: Type.Optional(NonEmptyString),
|
||||
selectedRelease: Type.Optional(Type.Union([ClawHubSelectedReleaseSchema, Type.Null()])),
|
||||
downloadability: Type.Optional(ClawHubDownloadabilitySchema),
|
||||
card: Type.Optional(
|
||||
Type.Union([
|
||||
closedObject({ status: Type.Literal("available"), content: Type.String() }),
|
||||
SkillDetailUnavailableSchema,
|
||||
]),
|
||||
),
|
||||
requirements: Type.Optional(
|
||||
Type.Union([
|
||||
closedObject({
|
||||
status: Type.Literal("available"),
|
||||
setup: SkillRegistrySetupSchema,
|
||||
os: Type.Optional(Type.Union([Type.Array(Type.String()), Type.Null()])),
|
||||
systems: Type.Optional(Type.Union([Type.Array(Type.String()), Type.Null()])),
|
||||
scope: Type.Literal("registry-setup"),
|
||||
note: NonEmptyString,
|
||||
}),
|
||||
SkillDetailUnavailableSchema,
|
||||
]),
|
||||
),
|
||||
security: Type.Optional(
|
||||
Type.Union([
|
||||
closedObject({
|
||||
status: Type.Literal("available"),
|
||||
scanStatus: NonEmptyString,
|
||||
hasWarnings: Type.Boolean(),
|
||||
hasScanResult: Type.Boolean(),
|
||||
checkedAt: Type.Optional(Type.Union([Type.Number(), Type.Null()])),
|
||||
summary: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
||||
virustotalUrl: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
||||
}),
|
||||
SkillDetailUnavailableSchema,
|
||||
]),
|
||||
),
|
||||
warnings: Type.Optional(Type.Array(Type.String())),
|
||||
skill: Type.Union([
|
||||
closedObject({
|
||||
slug: NonEmptyString,
|
||||
displayName: NonEmptyString,
|
||||
summary: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
||||
description: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
||||
icon: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
||||
topics: Type.Optional(Type.Array(Type.String())),
|
||||
stats: Type.Optional(Type.Record(Type.String(), Type.Number())),
|
||||
tags: Type.Optional(Type.Record(NonEmptyString, Type.String())),
|
||||
channel: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
||||
isOfficial: Type.Optional(Type.Union([Type.Boolean(), Type.Null()])),
|
||||
createdAt: Type.Integer(),
|
||||
updatedAt: Type.Integer(),
|
||||
}),
|
||||
Type.Null(),
|
||||
]),
|
||||
latestVersion: Type.Optional(
|
||||
Type.Union([
|
||||
closedObject({
|
||||
version: NonEmptyString,
|
||||
createdAt: Type.Integer(),
|
||||
changelog: Type.Optional(Type.String()),
|
||||
license: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
||||
}),
|
||||
Type.Null(),
|
||||
]),
|
||||
),
|
||||
metadata: Type.Optional(
|
||||
Type.Union([
|
||||
closedObject({
|
||||
setup: Type.Optional(SkillRegistrySetupSchema),
|
||||
os: Type.Optional(Type.Union([Type.Array(Type.String()), Type.Null()])),
|
||||
systems: Type.Optional(Type.Union([Type.Array(Type.String()), Type.Null()])),
|
||||
}),
|
||||
Type.Null(),
|
||||
]),
|
||||
),
|
||||
moderation: Type.Optional(
|
||||
Type.Union([
|
||||
closedObject({
|
||||
isSuspicious: Type.Boolean(),
|
||||
isMalwareBlocked: Type.Boolean(),
|
||||
verdict: Type.String(),
|
||||
reasonCodes: Type.Array(Type.String()),
|
||||
summary: Type.Union([Type.String(), Type.Null()]),
|
||||
engineVersion: Type.Union([Type.String(), Type.Null()]),
|
||||
updatedAt: Type.Union([Type.Number(), Type.Null()]),
|
||||
}),
|
||||
Type.Null(),
|
||||
]),
|
||||
),
|
||||
owner: Type.Optional(
|
||||
Type.Union([
|
||||
closedObject({
|
||||
handle: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
userId: Type.Optional(NonEmptyString),
|
||||
displayName: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
image: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
||||
official: Type.Optional(Type.Union([Type.Boolean(), Type.Null()])),
|
||||
channel: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
||||
isOfficial: Type.Optional(Type.Union([Type.Boolean(), Type.Null()])),
|
||||
}),
|
||||
Type.Null(),
|
||||
]),
|
||||
),
|
||||
});
|
||||
|
|
@ -294,6 +294,26 @@ describe("plugin lifecycle CLI transport", () => {
|
|||
},
|
||||
);
|
||||
|
||||
it("refuses capability consent when inspection has no artifact review token", async () => {
|
||||
mocks.call
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error("consent required"), {
|
||||
details: buildCapabilityConsentErrorDetails({
|
||||
pluginId: "demo",
|
||||
reviewToken: "a".repeat(64),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce({ plugin: { id: "demo", name: "Demo" }, declared: {}, grants: {} });
|
||||
const consent = vi.fn();
|
||||
const gateway = await resolvePluginLifecycleGateway();
|
||||
await expect(
|
||||
gateway?.("plugins.setEnabled", { pluginId: "demo", enabled: true }, consent),
|
||||
).rejects.toThrow("Gateway did not return a capability-consent token");
|
||||
expect(consent).not.toHaveBeenCalled();
|
||||
expect(mocks.call).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it.each(["accepted", "declined", "rejected-again", "connection-lost"])(
|
||||
"reviews successive batch capabilities without repeating uncertain mutations (%s)",
|
||||
async (outcome) => {
|
||||
|
|
|
|||
|
|
@ -123,8 +123,14 @@ export async function resolvePluginLifecycleGateway(): Promise<PluginLifecycleGa
|
|||
const { plugin, ...inspection } = await request<PluginsInspectResult>("plugins.inspect", {
|
||||
pluginId: consent.pluginId,
|
||||
});
|
||||
if (!inspection.reviewToken) {
|
||||
throw new Error(`Gateway did not return a capability-consent token for "${plugin.id}".`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
const acknowledgeCapabilities = await onCapabilityConsent({
|
||||
...inspection,
|
||||
reviewToken: inspection.reviewToken,
|
||||
pluginId: plugin.id,
|
||||
name: plugin.name,
|
||||
...(plugin.version ? { version: plugin.version } : {}),
|
||||
|
|
|
|||
|
|
@ -195,6 +195,38 @@ describe("plugin management Gateway handlers", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
params: { source: "clawhub", packageName: "community/plugin", version: "1.2.3" },
|
||||
target: { clawhub: { packageName: "community/plugin", version: "1.2.3" } },
|
||||
},
|
||||
{
|
||||
params: { catalogId: "ch_Y29tbXVuaXR5L3BsdWdpbg", version: "1.2.3" },
|
||||
target: { clawhub: { packageName: "community/plugin", version: "1.2.3" } },
|
||||
},
|
||||
{ params: { catalogId: "local_d29ya2JvYXJk" }, target: { pluginId: "workboard" } },
|
||||
])("routes plugin inspection identity $params to its owner", async ({ params, target }) => {
|
||||
const inspection = { ok: true, plugin: { id: "workboard", installed: false, enabled: false } };
|
||||
managementMocks.inspect.mockResolvedValue(inspection);
|
||||
|
||||
const result = await callHandler("plugins.inspect", params);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(managementMocks.inspect).toHaveBeenCalledWith({ config: {}, ...target });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ catalogId: "ch_not-canonical" },
|
||||
{ catalogId: "local_d29ya2JvYXJk", version: "1.2.3" },
|
||||
{ pluginId: "workboard", source: "clawhub", packageName: "community/plugin" },
|
||||
{ source: "clawhub", packageName: "community/plugin", catalogId: "ch_Y29tbXVuaXR5L3BsdWdpbg" },
|
||||
])("rejects invalid or ambiguous plugin inspection identity %j", async (params) => {
|
||||
const result = await callHandler("plugins.inspect", params);
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: { code: "INVALID_REQUEST" } });
|
||||
expect(managementMocks.inspect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("classifies unknown plugin inspections as invalid requests", async () => {
|
||||
managementMocks.inspect.mockRejectedValue(
|
||||
new ManagedPluginLifecycleError('Plugin "unknown" not found.'),
|
||||
|
|
@ -587,14 +619,27 @@ describe("plugin management Gateway handlers", () => {
|
|||
});
|
||||
catalogMocks.detail.mockRejectedValue(new Error("ClawHub offline"));
|
||||
|
||||
const result = await callHandler("plugins.catalog.get", { id: "ch_bWVtb3J5LXBsdXM" });
|
||||
const result = await callHandler("plugins.catalog.get", {
|
||||
id: "ch_bWVtb3J5LXBsdXM",
|
||||
version: "2.0.0",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(matches);
|
||||
if (matches) {
|
||||
expect(result.response).toMatchObject({
|
||||
plugin: { local: { pluginId: "workboard", installed: true, action: "manage" } },
|
||||
plugin: {
|
||||
id: "ch_bWVtb3J5LXBsdXM",
|
||||
catalog: { packageName: "memory-plus" },
|
||||
local: { pluginId: "workboard", installed: true, action: "manage" },
|
||||
},
|
||||
detail: {
|
||||
origin: "local",
|
||||
packageName: "memory-plus",
|
||||
requestedVersion: "2.0.0",
|
||||
selectedRelease: null,
|
||||
downloadability: { status: "unknown" },
|
||||
remoteError: expect.stringContaining("ClawHub offline"),
|
||||
registry: expect.any(String),
|
||||
contracts: { tools: ["workboard_read"] },
|
||||
mcpServers: ["workboard"],
|
||||
skills: [{ name: "Local planning" }],
|
||||
|
|
@ -608,6 +653,15 @@ describe("plugin management Gateway handlers", () => {
|
|||
},
|
||||
);
|
||||
|
||||
it("rejects selecting a remote release from a local catalog identity", async () => {
|
||||
const result = await callHandler("plugins.catalog.get", {
|
||||
id: "local_d29ya2JvYXJk",
|
||||
version: "1.2.3",
|
||||
});
|
||||
expect(result).toMatchObject({ ok: false, error: { code: "INVALID_REQUEST" } });
|
||||
expect(managementMocks.list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not misclassify local catalog entries when ordinary ClawHub browse fails", async () => {
|
||||
catalogMocks.overview.mockRejectedValue(new Error("service unavailable"));
|
||||
managementMocks.list.mockResolvedValue({
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
validatePluginsListParams,
|
||||
validatePluginsSearchParams,
|
||||
} from "../../../packages/gateway-protocol/src/index.js";
|
||||
import { resolveClawHubBaseUrl } from "../../infra/clawhub-client.js";
|
||||
import {
|
||||
fetchClawHubPluginCatalog,
|
||||
fetchClawHubPluginCategories,
|
||||
|
|
@ -150,18 +151,46 @@ export const pluginsHandlers: GatewayRequestHandlers = {
|
|||
return;
|
||||
}
|
||||
try {
|
||||
let target: { pluginId: string } | { clawhub: { packageName: string; version?: string } };
|
||||
if ("pluginId" in params) {
|
||||
target = { pluginId: params.pluginId };
|
||||
} else if ("source" in params) {
|
||||
target = { clawhub: { packageName: params.packageName, version: params.version } };
|
||||
} else {
|
||||
const identity = resolvePluginDiscoveryIdentity(params.catalogId);
|
||||
if (!identity) {
|
||||
throw new ManagedPluginLifecycleError("Unknown plugin catalog identity.", {
|
||||
kind: "invalid-request",
|
||||
});
|
||||
}
|
||||
if (identity.origin === "local" && params.version) {
|
||||
throw new ManagedPluginLifecycleError(
|
||||
"Local plugin inspection does not select releases.",
|
||||
{
|
||||
kind: "invalid-request",
|
||||
},
|
||||
);
|
||||
}
|
||||
target =
|
||||
identity.origin === "clawhub"
|
||||
? { clawhub: { packageName: identity.identity, version: params.version } }
|
||||
: { pluginId: identity.identity };
|
||||
}
|
||||
const remote = "clawhub" in target;
|
||||
const inspected = await inspectManagedPlugin({
|
||||
config: context.getRuntimeConfig(),
|
||||
pluginId: params.pluginId,
|
||||
...target,
|
||||
});
|
||||
const { inspectDecisionProviders } = await import("../../decisions/runtime.js");
|
||||
respond(
|
||||
true,
|
||||
{
|
||||
...inspected,
|
||||
decisions: inspectDecisionProviders(context.getRuntimeConfig()).filter(
|
||||
(entry) => entry.pluginId === params.pluginId,
|
||||
),
|
||||
decisions: remote
|
||||
? []
|
||||
: inspectDecisionProviders(context.getRuntimeConfig()).filter(
|
||||
(entry) => entry.pluginId === inspected.plugin.id,
|
||||
),
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
|
@ -341,9 +370,18 @@ export const pluginsHandlers: GatewayRequestHandlers = {
|
|||
);
|
||||
return;
|
||||
}
|
||||
if (identity.origin === "local" && params.version) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "Local plugin details do not select releases."),
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const local = await listManagedPlugins({ config: context.getRuntimeConfig() });
|
||||
const localPlugin = findLocalPluginByIdentity(local, identity.identity, identity.origin);
|
||||
let remoteError: string | undefined;
|
||||
if (identity.origin !== "local") {
|
||||
try {
|
||||
const remote = await fetchClawHubPluginDetail({
|
||||
|
|
@ -357,6 +395,7 @@ export const pluginsHandlers: GatewayRequestHandlers = {
|
|||
if (!localPlugin) {
|
||||
throw error;
|
||||
}
|
||||
remoteError = `ClawHub details are unavailable: ${formatErrorMessage(error)}. Showing installed plugin metadata.`;
|
||||
}
|
||||
} else if (!localPlugin) {
|
||||
respond(
|
||||
|
|
@ -377,7 +416,24 @@ export const pluginsHandlers: GatewayRequestHandlers = {
|
|||
pluginId: inspectionPluginId,
|
||||
})
|
||||
: undefined;
|
||||
respond(true, joinLocalPluginDetail({ plugin: localPlugin, local, inspection }), undefined);
|
||||
const result = joinLocalPluginDetail({ plugin: localPlugin, local, inspection });
|
||||
if (remoteError) {
|
||||
result.plugin.id = params.id;
|
||||
result.plugin.catalog.packageName = identity.identity;
|
||||
result.detail = {
|
||||
...result.detail,
|
||||
packageName: identity.identity,
|
||||
registry: resolveClawHubBaseUrl(),
|
||||
remoteError,
|
||||
...(params.version ? { requestedVersion: params.version } : {}),
|
||||
selectedRelease: null,
|
||||
downloadability: {
|
||||
status: "unknown",
|
||||
reason: "ClawHub release metadata is unavailable.",
|
||||
},
|
||||
};
|
||||
}
|
||||
respond(true, result, undefined);
|
||||
} catch (error) {
|
||||
respond(
|
||||
false,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@
|
|||
// HTTP layer is faked here; search, the Gateway handlers, and the detail client are real.
|
||||
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { Value } from "typebox/value";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SkillsDetailResultSchema } from "../../../packages/gateway-protocol/src/schema/skill-detail.js";
|
||||
|
||||
const installSkillFromClawHubMock = vi.fn();
|
||||
|
||||
|
|
@ -63,6 +65,10 @@ function searchPayload() {
|
|||
}
|
||||
|
||||
let requestedUrls: string[] = [];
|
||||
let unavailableRelease = false;
|
||||
let noHostedRelease = false;
|
||||
let malformedScan = false;
|
||||
let wrongIdentity: "slug" | "owner" | "version" | undefined;
|
||||
|
||||
function fakeClawHub(input: string): Response {
|
||||
const url = new URL(input);
|
||||
|
|
@ -82,6 +88,36 @@ function fakeClawHub(input: string): Response {
|
|||
),
|
||||
});
|
||||
}
|
||||
if (url.pathname.startsWith(`/api/v1/skills/${SLUG}/versions/`)) {
|
||||
if (unavailableRelease) {
|
||||
return new Response("Version not found", { status: 404 });
|
||||
}
|
||||
return Response.json({
|
||||
version: {
|
||||
version:
|
||||
wrongIdentity === "version"
|
||||
? "9.9.9"
|
||||
: decodeURIComponent(url.pathname.split("/").at(-1) ?? ""),
|
||||
createdAt: 1,
|
||||
changelog: "Selected release notes",
|
||||
security: malformedScan
|
||||
? { status: "", hasWarnings: "true" }
|
||||
: {
|
||||
status: "suspicious",
|
||||
hasWarnings: true,
|
||||
hasScanResult: true,
|
||||
checkedAt: 3,
|
||||
scanners: { llm: { summary: "Review network access." } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (url.pathname === `/api/v1/skills/${SLUG}/card`) {
|
||||
if (unavailableRelease) {
|
||||
return new Response("Skill Card not found", { status: 404 });
|
||||
}
|
||||
return new Response(`# Email skill ${url.searchParams.get("version")}\nFull card content.`);
|
||||
}
|
||||
if (url.pathname === `/api/v1/skills/${SLUG}`) {
|
||||
const ownerHandle = url.searchParams.get("ownerHandle");
|
||||
if (!ownerHandle) {
|
||||
|
|
@ -92,8 +128,20 @@ function fakeClawHub(input: string): Response {
|
|||
);
|
||||
}
|
||||
return Response.json({
|
||||
skill: { slug: SLUG, displayName: SLUG, createdAt: 1, updatedAt: 2 },
|
||||
owner: { handle: ownerHandle, displayName: ownerHandle },
|
||||
skill: {
|
||||
slug: wrongIdentity === "slug" ? "other-skill" : SLUG,
|
||||
displayName: SLUG,
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
owner: {
|
||||
handle: wrongIdentity === "owner" ? "other-publisher" : ownerHandle,
|
||||
displayName: ownerHandle,
|
||||
},
|
||||
latestVersion: noHostedRelease
|
||||
? null
|
||||
: { version: "2.0.0", createdAt: 2, changelog: "Current release" },
|
||||
metadata: { setup: [{ key: "EMAIL_TOKEN", required: true }], os: ["linux"] },
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected ClawHub request: ${input}`);
|
||||
|
|
@ -105,6 +153,10 @@ const callSkillsHandler = (method: string, params: Record<string, unknown>) =>
|
|||
describe("ClawHub publisher identity across skills.search, skills.detail, and skills.install", () => {
|
||||
beforeEach(() => {
|
||||
requestedUrls = [];
|
||||
unavailableRelease = false;
|
||||
noHostedRelease = false;
|
||||
malformedScan = false;
|
||||
wrongIdentity = undefined;
|
||||
installSkillFromClawHubMock.mockReset();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
|
|
@ -154,6 +206,14 @@ describe("ClawHub publisher identity across skills.search, skills.detail, and sk
|
|||
expect(error).toBeUndefined();
|
||||
expect(ok).toBe(true);
|
||||
expect((response as { owner: { handle: string } }).owner.handle).toBe(ownerHandle);
|
||||
expect(response).toMatchObject({
|
||||
selectedRelease: { version: "2.0.0" },
|
||||
requirements: {
|
||||
status: "available",
|
||||
setup: [{ key: "EMAIL_TOKEN", required: true }],
|
||||
os: ["linux"],
|
||||
},
|
||||
});
|
||||
const detailUrl = expectDefined(
|
||||
requestedUrls.find((url) => url.includes(`/api/v1/skills/${SLUG}`)),
|
||||
"detail request",
|
||||
|
|
@ -161,6 +221,131 @@ describe("ClawHub publisher identity across skills.search, skills.detail, and sk
|
|||
expect(new URL(detailUrl).searchParams.get("ownerHandle")).toBe(ownerHandle);
|
||||
});
|
||||
|
||||
it("reads the selected release card and scan without relabeling latest requirements", async () => {
|
||||
const { ok, response, error } = await callSkillsHandler("skills.detail", {
|
||||
slug: `@wangchenyu8/${SLUG}`,
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
expect(error).toBeUndefined();
|
||||
expect(ok).toBe(true);
|
||||
expect(Value.Check(SkillsDetailResultSchema, response)).toBe(true);
|
||||
expect(response).toMatchObject({
|
||||
registry: "https://clawhub.ai",
|
||||
source: "clawhub",
|
||||
installRef: `@wangchenyu8/${SLUG}`,
|
||||
latestVersion: { version: "2.0.0" },
|
||||
selectedRelease: { version: "1.0.0", changelog: "Selected release notes" },
|
||||
card: { status: "available", content: "# Email skill 1.0.0\nFull card content." },
|
||||
requirements: { status: "unavailable" },
|
||||
security: {
|
||||
status: "available",
|
||||
scanStatus: "suspicious",
|
||||
summary: "Review network access.",
|
||||
},
|
||||
downloadability: { status: "unknown" },
|
||||
});
|
||||
for (const request of requestedUrls) {
|
||||
expect(new URL(request).searchParams.get("ownerHandle")).toBe("wangchenyu8");
|
||||
}
|
||||
expect(requestedUrls.map((request) => new URL(request).pathname)).toContain(
|
||||
`/api/v1/skills/${SLUG}/versions/1.0.0`,
|
||||
);
|
||||
const cardRequest = requestedUrls.find((request) =>
|
||||
new URL(request).pathname.endsWith("/card"),
|
||||
);
|
||||
expect(new URL(expectDefined(cardRequest, "card request")).searchParams.get("version")).toBe(
|
||||
"1.0.0",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps listing metadata when selected release and card are unavailable", async () => {
|
||||
unavailableRelease = true;
|
||||
const { ok, response } = await callSkillsHandler("skills.detail", {
|
||||
slug: `@wangchenyu8/${SLUG}`,
|
||||
version: "0.1.0",
|
||||
});
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(Value.Check(SkillsDetailResultSchema, response)).toBe(true);
|
||||
expect(response).toMatchObject({
|
||||
skill: { slug: SLUG },
|
||||
selectedRelease: null,
|
||||
card: { status: "unavailable", reason: expect.stringContaining("404") },
|
||||
requirements: { status: "unavailable" },
|
||||
security: { status: "unavailable" },
|
||||
downloadability: { status: "unavailable" },
|
||||
warnings: [expect.stringContaining("404")],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps source-backed availability unknown when a listing has no hosted release", async () => {
|
||||
noHostedRelease = true;
|
||||
const { ok, response } = await callSkillsHandler("skills.detail", {
|
||||
slug: `@wangchenyu8/${SLUG}`,
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
expect(Value.Check(SkillsDetailResultSchema, response)).toBe(true);
|
||||
expect(response).toMatchObject({
|
||||
skill: { slug: SLUG },
|
||||
selectedRelease: null,
|
||||
downloadability: { status: "unknown", reason: expect.stringContaining("source-backed") },
|
||||
card: { status: "unavailable" },
|
||||
});
|
||||
expect(requestedUrls).toHaveLength(1);
|
||||
expect(installSkillFromClawHubMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["slug", "owner"] as const)(
|
||||
"refuses registry detail with mismatched %s identity",
|
||||
async (identity) => {
|
||||
wrongIdentity = identity;
|
||||
const { ok, error } = await callSkillsHandler("skills.detail", {
|
||||
slug: `@wangchenyu8/${SLUG}`,
|
||||
});
|
||||
|
||||
expect(ok).toBe(false);
|
||||
expect(error).toMatchObject({
|
||||
code: "UNAVAILABLE",
|
||||
message: expect.stringContaining("different"),
|
||||
});
|
||||
expect(requestedUrls).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not relabel a different release returned by the registry", async () => {
|
||||
wrongIdentity = "version";
|
||||
const { ok, response } = await callSkillsHandler("skills.detail", {
|
||||
slug: `@wangchenyu8/${SLUG}`,
|
||||
version: "2.0.0",
|
||||
});
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(response).toMatchObject({
|
||||
selectedRelease: null,
|
||||
security: { status: "unavailable" },
|
||||
requirements: { status: "unavailable" },
|
||||
downloadability: { status: "unknown", reason: expect.stringContaining("different release") },
|
||||
warnings: [expect.stringContaining("different release")],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a malformed optional scan out of the detail response", async () => {
|
||||
malformedScan = true;
|
||||
const { ok, response } = await callSkillsHandler("skills.detail", {
|
||||
slug: `@wangchenyu8/${SLUG}`,
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(Value.Check(SkillsDetailResultSchema, response)).toBe(true);
|
||||
expect(response).toMatchObject({
|
||||
selectedRelease: { version: "1.0.0" },
|
||||
card: { status: "available" },
|
||||
security: { status: "unavailable" },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([{}, { query: " ", limit: 2 }])(
|
||||
"browses source-qualified trending skills for an empty query: %j",
|
||||
async (params) => {
|
||||
|
|
|
|||
|
|
@ -197,7 +197,10 @@ describe("skills.detail handler", () => {
|
|||
slug: "github",
|
||||
});
|
||||
|
||||
expect(fetchClawHubSkillDetailMock).toHaveBeenCalledWith({ slug: "github" });
|
||||
expect(fetchClawHubSkillDetailMock).toHaveBeenCalledWith({
|
||||
slug: "github",
|
||||
includeInspection: true,
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
expect(error).toBeUndefined();
|
||||
expect(response).toEqual(detail);
|
||||
|
|
|
|||
|
|
@ -258,6 +258,8 @@ export const skillsHandlers: GatewayRequestHandlers = {
|
|||
}
|
||||
const detail = await fetchClawHubSkillDetail({
|
||||
slug: requested.slug,
|
||||
includeInspection: true,
|
||||
...(params.version ? { version: params.version } : {}),
|
||||
...(requested.ownerHandle ? { ownerHandle: requested.ownerHandle } : {}),
|
||||
});
|
||||
registerClawHubCatalogIconUrls([
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ function isBlockingClawHubTrust(trust: ClawHubPackageSecurityTrust): boolean {
|
|||
});
|
||||
}
|
||||
|
||||
function assessClawHubTrust(trust: ClawHubPackageSecurityTrust): ClawHubTrustDisposition {
|
||||
export function assessClawHubTrust(trust: ClawHubPackageSecurityTrust): ClawHubTrustDisposition {
|
||||
const hasRiskReasons = hasClawHubRiskReasons(trust);
|
||||
if (!hasRiskReasons && !trust.pending && !trust.stale) {
|
||||
return "clean";
|
||||
|
|
|
|||
|
|
@ -366,6 +366,7 @@ describe("ClawHub plugin catalog client", () => {
|
|||
package: {
|
||||
...remotePlugin,
|
||||
topics: ["Retrieval"],
|
||||
tags: { latest: "1.2.3", stable: "1.2.2" },
|
||||
createdAt: 100,
|
||||
updatedAt: 300,
|
||||
compatibility: { minGatewayVersion: ">=2.0.0" },
|
||||
|
|
@ -467,6 +468,17 @@ describe("ClawHub plugin catalog client", () => {
|
|||
official: true,
|
||||
},
|
||||
topics: ["Retrieval"],
|
||||
registry: "https://example.com",
|
||||
tags: { latest: "1.2.3", stable: "1.2.2" },
|
||||
selectedRelease: {
|
||||
version: "1.2.2",
|
||||
createdAt: 200,
|
||||
changelog: "Previous release",
|
||||
tags: [],
|
||||
},
|
||||
downloadability: { status: "unknown" },
|
||||
metadata: { manifest: "available", readme: "available", security: "available" },
|
||||
trust: { disposition: "clean", pending: false, stale: false },
|
||||
createdAt: 100,
|
||||
updatedAt: 300,
|
||||
readme: "# Memory Plus\n\nLong-term memory.",
|
||||
|
|
@ -535,10 +547,78 @@ describe("ClawHub plugin catalog client", () => {
|
|||
fetchImpl,
|
||||
});
|
||||
expect(detail).toMatchObject({ packageName: "memory-plus", versions: [], configFields: [] });
|
||||
expect(detail.selectedRelease).toBeNull();
|
||||
expect(detail.downloadability).toEqual({
|
||||
status: "unavailable",
|
||||
reason: "The listing has no selected release.",
|
||||
});
|
||||
expect(detail.metadata).toEqual({
|
||||
manifest: "missing",
|
||||
readme: "missing",
|
||||
security: "missing",
|
||||
});
|
||||
expect(detail.readme).toBeUndefined();
|
||||
expect(detail.security).toBeUndefined();
|
||||
expect(detail.compatibility).toEqual({ minGatewayVersion: ">=2.0.0" });
|
||||
expect(fetchImpl).toHaveBeenCalledOnce();
|
||||
},
|
||||
);
|
||||
it.each([
|
||||
{ blocked: true, securityVersion: "1.0.0", expected: "unavailable" },
|
||||
{ blocked: false, securityVersion: "1.0.0", expected: "unknown" },
|
||||
{ blocked: true, securityVersion: "2.0.0", expected: "unknown" },
|
||||
])(
|
||||
"reports release availability without treating policy permission as stored bytes: $expected/$securityVersion",
|
||||
async ({ blocked, securityVersion, expected }) => {
|
||||
const detail = await fetchClawHubPluginDetail({
|
||||
packageName: "memory-plus",
|
||||
version: "1.0.0",
|
||||
skipAuth: true,
|
||||
fetchImpl: mockResponse({
|
||||
package: remotePlugin,
|
||||
version: { version: "1.0.0", createdAt: 100 },
|
||||
versions: { items: [] },
|
||||
security: {
|
||||
package: { name: "memory-plus" },
|
||||
release: { version: securityVersion },
|
||||
overview: "Selected release policy",
|
||||
securityAuditUrl: "https://example.com/audit",
|
||||
trust: {
|
||||
blockedFromDownload: blocked,
|
||||
reasons: blocked ? ["scan:malicious"] : [],
|
||||
pending: false,
|
||||
stale: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(detail.selectedRelease?.version).toBe("1.0.0");
|
||||
expect(detail.downloadability.status).toBe(expected);
|
||||
if (detail.downloadability.status !== "downloadable") {
|
||||
expect(detail.downloadability.reason).toBeTruthy();
|
||||
}
|
||||
expect(detail.metadata.security).toBe(securityVersion === "1.0.0" ? "available" : "missing");
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ packageName: "@bob/memory-plus", version: "1.0.0" },
|
||||
{ packageName: "memory-plus", version: "2.0.0" },
|
||||
])(
|
||||
"rejects registry identity substitution: $packageName/$version",
|
||||
async ({ packageName, version }) => {
|
||||
await expect(
|
||||
fetchClawHubPluginDetail({
|
||||
packageName: "memory-plus",
|
||||
version: "1.0.0",
|
||||
skipAuth: true,
|
||||
fetchImpl: mockResponse({
|
||||
package: { ...remotePlugin, name: packageName },
|
||||
version: { version },
|
||||
versions: { items: [] },
|
||||
}),
|
||||
}),
|
||||
).rejects.toThrow(/identity|requested plugin release/);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import type {
|
||||
ClawHubDownloadability,
|
||||
ClawHubSelectedRelease,
|
||||
} from "../../packages/gateway-protocol/src/schema/clawhub-listing.js";
|
||||
import type { PluginInstallTrust } from "../../packages/gateway-protocol/src/schema/plugins.js";
|
||||
import { validatePluginCategories } from "../../packages/plugin-package-contract/src/index.js";
|
||||
import {
|
||||
fetchClawHubJson,
|
||||
|
|
@ -9,18 +14,19 @@ import {
|
|||
readRequiredClawHubNumberField,
|
||||
readRequiredClawHubStringField,
|
||||
resolveClawHubImageUrl,
|
||||
resolveClawHubBaseUrl,
|
||||
type ClawHubRequestParams,
|
||||
} from "./clawhub-client.js";
|
||||
import {
|
||||
parseClawHubPackageSecurityResponse,
|
||||
type ClawHubPackageSecurityResponse,
|
||||
} from "./clawhub-packages.js";
|
||||
import {
|
||||
parseClawHubPluginCapabilities,
|
||||
parseClawHubPluginCompatibility,
|
||||
type ClawHubPluginCompatibility,
|
||||
type ClawHubPluginCapabilities,
|
||||
} from "./clawhub-plugin-manifest.js";
|
||||
import {
|
||||
readClawHubPluginReleaseFacts,
|
||||
type ClawHubPluginSecurity,
|
||||
} from "./clawhub-plugin-release.js";
|
||||
|
||||
export type ClawHubPluginCatalogEntry = {
|
||||
packageName: string;
|
||||
|
|
@ -56,6 +62,16 @@ export type ClawHubPluginDetail = ClawHubPluginCatalogEntry &
|
|||
mcpServers: string[];
|
||||
skills: Array<{ name: string; description?: string }>;
|
||||
versions: ClawHubPluginVersion[];
|
||||
registry: string;
|
||||
tags: Record<string, string>;
|
||||
selectedRelease: ClawHubSelectedRelease | null;
|
||||
downloadability: ClawHubDownloadability;
|
||||
metadata: {
|
||||
manifest: "available" | "missing";
|
||||
readme: "available" | "missing";
|
||||
security: "available" | "missing";
|
||||
};
|
||||
trust?: PluginInstallTrust;
|
||||
verification?: ClawHubPluginVerification;
|
||||
security?: ClawHubPluginSecurity;
|
||||
};
|
||||
|
|
@ -83,15 +99,6 @@ type ClawHubPluginVerification = {
|
|||
scanStatus?: string;
|
||||
};
|
||||
|
||||
type ClawHubPluginSecurity = {
|
||||
status: string;
|
||||
auditUrl?: string;
|
||||
verdict?: string;
|
||||
summary?: string;
|
||||
guidance?: string;
|
||||
checkedAt?: number;
|
||||
};
|
||||
|
||||
export type ClawHubPluginCategory = {
|
||||
slug: string;
|
||||
label: string;
|
||||
|
|
@ -409,27 +416,6 @@ function parseVerification(
|
|||
};
|
||||
}
|
||||
|
||||
function projectSecurity(value: ClawHubPackageSecurityResponse): ClawHubPluginSecurity {
|
||||
const trust = value.trust;
|
||||
const moderationStatus =
|
||||
trust.moderationState && trust.moderationState !== "approved"
|
||||
? trust.moderationState
|
||||
: undefined;
|
||||
const status = trust.blockedFromDownload
|
||||
? "blocked"
|
||||
: trust.pending
|
||||
? "pending"
|
||||
: trust.stale
|
||||
? "stale"
|
||||
: (moderationStatus ?? trust.scanStatus ?? "unknown");
|
||||
return {
|
||||
status,
|
||||
...(value.verdict ? { verdict: value.verdict } : {}),
|
||||
auditUrl: value.securityAuditUrl,
|
||||
summary: value.overview,
|
||||
};
|
||||
}
|
||||
|
||||
function parseVersions(value: unknown): ClawHubPluginVersion[] {
|
||||
if (!isRecord(value) || !Array.isArray(value.items)) {
|
||||
throw new Error("Malformed ClawHub plugin versions response: expected items to be an array.");
|
||||
|
|
@ -610,6 +596,16 @@ export async function fetchClawHubPluginDetail(
|
|||
throw new Error("Malformed ClawHub plugin detail response: expected package to be an object.");
|
||||
}
|
||||
const catalog = parseCatalogPackage(value.package, "plugin detail", params.baseUrl);
|
||||
if (catalog.packageName !== params.packageName.trim().toLowerCase()) {
|
||||
throw new Error("ClawHub returned a different plugin package identity.");
|
||||
}
|
||||
const tagsRecord = readOptionalRecord(value.package, "tags", "plugin detail");
|
||||
const tags = Object.fromEntries(
|
||||
Object.keys(tagsRecord ?? {}).map((tag) => [
|
||||
tag,
|
||||
readRequiredClawHubStringField(tagsRecord ?? {}, tag, "plugin tags"),
|
||||
]),
|
||||
);
|
||||
const topics = readClawHubStringArrayField(value.package, "topics", "plugin detail") ?? [];
|
||||
const createdAt = readOptionalNonNegativeNumber(value.package, "createdAt", "plugin detail");
|
||||
const updatedAt = readOptionalNonNegativeNumber(value.package, "updatedAt", "plugin detail");
|
||||
|
|
@ -629,27 +625,19 @@ export async function fetchClawHubPluginDetail(
|
|||
: undefined;
|
||||
|
||||
const versionRecord = readOptionalRecord(value, "version", "plugin detail response");
|
||||
const readme = readClawHubStringField(value, "readme", "plugin detail response");
|
||||
if (readme && Buffer.byteLength(readme, "utf8") > 512 * 1024) {
|
||||
throw new Error("ClawHub plugin README exceeded 524288 bytes.");
|
||||
}
|
||||
let security: ClawHubPluginSecurity | undefined;
|
||||
if (value.security != null) {
|
||||
try {
|
||||
security = projectSecurity(parseClawHubPackageSecurityResponse(value.security));
|
||||
} catch {
|
||||
// Security metadata is optional; malformed audit data must not hide the package.
|
||||
}
|
||||
}
|
||||
const manifest = parseManifest(
|
||||
versionRecord
|
||||
? readOptionalRecord(versionRecord, "pluginManifestSummary", "plugin version")
|
||||
: readOptionalRecord(value.package, "pluginManifestSummary", "plugin detail"),
|
||||
);
|
||||
const { selectedRelease, readme, security, trust, downloadability } =
|
||||
await readClawHubPluginReleaseFacts({
|
||||
value,
|
||||
versionRecord,
|
||||
packageName: catalog.packageName,
|
||||
version: params.version,
|
||||
});
|
||||
const manifestRecord = versionRecord
|
||||
? readOptionalRecord(versionRecord, "pluginManifestSummary", "plugin version")
|
||||
: undefined;
|
||||
const manifest = parseManifest(manifestRecord);
|
||||
const verification = parseVerification(
|
||||
versionRecord
|
||||
? readOptionalRecord(versionRecord, "verification", "plugin version")
|
||||
: readOptionalRecord(value.package, "verification", "plugin detail"),
|
||||
versionRecord ? readOptionalRecord(versionRecord, "verification", "plugin version") : undefined,
|
||||
);
|
||||
const owner = {
|
||||
...(ownerHandle ? { handle: ownerHandle } : {}),
|
||||
|
|
@ -659,6 +647,16 @@ export async function fetchClawHubPluginDetail(
|
|||
};
|
||||
return {
|
||||
...catalog,
|
||||
registry: resolveClawHubBaseUrl(params.baseUrl),
|
||||
tags,
|
||||
selectedRelease,
|
||||
downloadability,
|
||||
metadata: {
|
||||
manifest: manifestRecord ? "available" : "missing",
|
||||
readme: readme != null ? "available" : "missing",
|
||||
security: security ? "available" : "missing",
|
||||
},
|
||||
...(trust ? { trust } : {}),
|
||||
...(ownerHandle && !catalog.ownerHandle ? { ownerHandle } : {}),
|
||||
...(Object.keys(owner).length > 0 ? { owner } : {}),
|
||||
topics,
|
||||
|
|
|
|||
128
src/infra/clawhub-plugin-release.ts
Normal file
128
src/infra/clawhub-plugin-release.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import type {
|
||||
ClawHubDownloadability,
|
||||
ClawHubSelectedRelease,
|
||||
} from "../../packages/gateway-protocol/src/schema/clawhub-listing.js";
|
||||
import type { PluginInstallTrust } from "../../packages/gateway-protocol/src/schema/plugins.js";
|
||||
import {
|
||||
readClawHubStringField,
|
||||
readClawHubStringArrayField,
|
||||
readRequiredClawHubStringField,
|
||||
readRequiredClawHubNumberField,
|
||||
} from "./clawhub-client.js";
|
||||
import {
|
||||
parseClawHubPackageSecurityResponse,
|
||||
type ClawHubPackageSecurityResponse,
|
||||
} from "./clawhub-packages.js";
|
||||
export type ClawHubPluginSecurity = {
|
||||
status: string;
|
||||
auditUrl?: string;
|
||||
verdict?: string;
|
||||
summary?: string;
|
||||
guidance?: string;
|
||||
checkedAt?: number;
|
||||
};
|
||||
|
||||
function projectSecurity(value: ClawHubPackageSecurityResponse): ClawHubPluginSecurity {
|
||||
const trust = value.trust;
|
||||
const moderationStatus =
|
||||
trust.moderationState && trust.moderationState !== "approved"
|
||||
? trust.moderationState
|
||||
: undefined;
|
||||
const status = trust.blockedFromDownload
|
||||
? "blocked"
|
||||
: trust.pending
|
||||
? "pending"
|
||||
: trust.stale
|
||||
? "stale"
|
||||
: (moderationStatus ?? trust.scanStatus ?? "unknown");
|
||||
return {
|
||||
status,
|
||||
...(value.verdict ? { verdict: value.verdict } : {}),
|
||||
auditUrl: value.securityAuditUrl,
|
||||
summary: value.overview,
|
||||
};
|
||||
}
|
||||
|
||||
/** Project exact release facts; listing and artifact URLs cannot prove stored download bytes. */
|
||||
export async function readClawHubPluginReleaseFacts(params: {
|
||||
value: Record<string, unknown>;
|
||||
versionRecord: Record<string, unknown> | undefined;
|
||||
packageName: string;
|
||||
version?: string;
|
||||
}) {
|
||||
const { value, versionRecord } = params;
|
||||
const selectedRelease: ClawHubSelectedRelease | null = versionRecord
|
||||
? {
|
||||
version: readRequiredClawHubStringField(
|
||||
versionRecord,
|
||||
"version",
|
||||
"selected plugin release",
|
||||
),
|
||||
...(versionRecord.createdAt != null
|
||||
? {
|
||||
createdAt: readRequiredClawHubNumberField(
|
||||
versionRecord,
|
||||
"createdAt",
|
||||
"selected plugin release",
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(versionRecord.changelog != null
|
||||
? {
|
||||
changelog:
|
||||
readClawHubStringField(versionRecord, "changelog", "selected plugin release") ??
|
||||
undefined,
|
||||
}
|
||||
: {}),
|
||||
tags:
|
||||
readClawHubStringArrayField(versionRecord, "distTags", "selected plugin release") ?? [],
|
||||
}
|
||||
: null;
|
||||
if (params.version && selectedRelease?.version !== params.version) {
|
||||
throw new Error("ClawHub did not return the requested plugin release.");
|
||||
}
|
||||
const readme = readClawHubStringField(value, "readme", "plugin detail response");
|
||||
if (readme && Buffer.byteLength(readme, "utf8") > 512 * 1024) {
|
||||
throw new Error("ClawHub plugin README exceeded 524288 bytes.");
|
||||
}
|
||||
let security: ClawHubPluginSecurity | undefined;
|
||||
let trust: PluginInstallTrust | undefined;
|
||||
let downloadability: ClawHubDownloadability = selectedRelease
|
||||
? {
|
||||
status: "unknown",
|
||||
reason: "ClawHub does not expose artifact storage availability for this release.",
|
||||
}
|
||||
: { status: "unavailable", reason: "The listing has no selected release." };
|
||||
if (value.security != null) {
|
||||
try {
|
||||
const parsedSecurity = parseClawHubPackageSecurityResponse(value.security);
|
||||
if (
|
||||
!selectedRelease ||
|
||||
(parsedSecurity.package?.name && parsedSecurity.package.name !== params.packageName) ||
|
||||
(parsedSecurity.release?.version &&
|
||||
parsedSecurity.release.version !== selectedRelease.version)
|
||||
) {
|
||||
throw new Error("ClawHub security metadata does not describe the selected release.");
|
||||
}
|
||||
security = projectSecurity(parsedSecurity);
|
||||
const { assessClawHubTrust } = await import("./clawhub-install-trust.js");
|
||||
trust = {
|
||||
disposition: assessClawHubTrust(parsedSecurity.trust),
|
||||
reasons: parsedSecurity.trust.reasons,
|
||||
pending: parsedSecurity.trust.pending,
|
||||
stale: parsedSecurity.trust.stale,
|
||||
};
|
||||
if (parsedSecurity.trust.blockedFromDownload) {
|
||||
downloadability = {
|
||||
status: "unavailable",
|
||||
reason:
|
||||
parsedSecurity.trust.reasons.join("; ") || "ClawHub blocks downloads of this release.",
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Security metadata is optional; malformed audit data must not hide the package.
|
||||
}
|
||||
}
|
||||
|
||||
return { selectedRelease, readme, security, trust, downloadability };
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import type { SkillsDetailResult } from "@openclaw/gateway-protocol";
|
|||
// ClawHub skill metadata, trust, install resolution, cards, and telemetry.
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import {
|
||||
ClawHubRequestError,
|
||||
createClawHubError,
|
||||
decodeClawHubResponseBody,
|
||||
fetchClawHubJson,
|
||||
|
|
@ -287,28 +288,179 @@ function toClawHubSkillSearchResult(
|
|||
}
|
||||
}
|
||||
|
||||
type ClawHubSkillVersionDetail = {
|
||||
version: {
|
||||
version: string;
|
||||
createdAt: number;
|
||||
changelog?: string;
|
||||
security?: {
|
||||
status: string;
|
||||
hasWarnings: boolean;
|
||||
hasScanResult: boolean;
|
||||
checkedAt?: number | null;
|
||||
virustotalUrl?: string | null;
|
||||
scanners?: { llm?: { summary?: string | null } | null };
|
||||
} | null;
|
||||
};
|
||||
};
|
||||
|
||||
export async function fetchClawHubSkillDetail(
|
||||
params: ClawHubFetchOptions & {
|
||||
slug: string;
|
||||
ownerHandle?: string;
|
||||
version?: string;
|
||||
/** Interactive detail reads include card and release scans; install resolution needs metadata only. */
|
||||
includeInspection?: boolean;
|
||||
},
|
||||
): Promise<ClawHubSkillDetail> {
|
||||
const registry = resolveClawHubBaseUrl(params.baseUrl);
|
||||
const detail = await fetchClawHubJson<ClawHubSkillDetail>({
|
||||
baseUrl: params.baseUrl,
|
||||
...params,
|
||||
baseUrl: registry,
|
||||
path: `/api/v1/skills/${encodeURIComponent(params.slug)}`,
|
||||
token: params.token,
|
||||
timeoutMs: params.timeoutMs,
|
||||
fetchImpl: params.fetchImpl,
|
||||
search: params.ownerHandle ? { ownerHandle: params.ownerHandle } : undefined,
|
||||
});
|
||||
if (detail.skill && detail.skill.slug !== params.slug) {
|
||||
throw new Error("ClawHub returned details for a different skill.");
|
||||
}
|
||||
if (params.ownerHandle && detail.owner?.handle && detail.owner.handle !== params.ownerHandle) {
|
||||
throw new Error("ClawHub returned details for a different publisher.");
|
||||
}
|
||||
if (!params.includeInspection && params.version === undefined) {
|
||||
return {
|
||||
...detail,
|
||||
skill: detail.skill
|
||||
? { ...detail.skill, icon: resolveClawHubImageUrl(detail.skill.icon, registry) }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
const ownerHandle = params.ownerHandle ?? detail.owner?.handle ?? undefined;
|
||||
const version = normalizeOptionalString(params.version) ?? detail.latestVersion?.version;
|
||||
const request = { ...params, baseUrl: registry, ownerHandle, version };
|
||||
const warnings: string[] = [];
|
||||
// The version endpoint is the owner of release-specific security. Base metadata only
|
||||
// describes latest, so it must never be relabeled as requirements for an older release.
|
||||
const [release, card] = version
|
||||
? await Promise.allSettled([
|
||||
fetchClawHubJson<ClawHubSkillVersionDetail>({
|
||||
...request,
|
||||
path: `/api/v1/skills/${encodeURIComponent(params.slug)}/versions/${encodeURIComponent(version)}`,
|
||||
search: ownerHandle ? { ownerHandle } : undefined,
|
||||
}),
|
||||
fetchClawHubSkillCard(request),
|
||||
])
|
||||
: [];
|
||||
if (release?.status === "rejected") {
|
||||
warnings.push(`Selected release details unavailable: ${String(release.reason)}`);
|
||||
}
|
||||
const returnedVersion = release?.status === "fulfilled" ? release.value.version : undefined;
|
||||
const selectedVersion = returnedVersion?.version === version ? returnedVersion : undefined;
|
||||
const releaseMismatch = release?.status === "fulfilled" && selectedVersion === undefined;
|
||||
if (releaseMismatch) {
|
||||
warnings.push("ClawHub returned details for a different release.");
|
||||
}
|
||||
const releaseUnavailable =
|
||||
release?.status === "rejected" &&
|
||||
release.reason instanceof ClawHubRequestError &&
|
||||
[404, 410].includes(release.reason.status);
|
||||
const security = selectedVersion?.security;
|
||||
const validSecurity =
|
||||
security &&
|
||||
typeof security.status === "string" &&
|
||||
security.status.trim().length > 0 &&
|
||||
typeof security.hasWarnings === "boolean" &&
|
||||
typeof security.hasScanResult === "boolean";
|
||||
const isLatest = version !== undefined && version === detail.latestVersion?.version;
|
||||
const canUseLatest = isLatest && !releaseUnavailable && !releaseMismatch;
|
||||
return {
|
||||
...detail,
|
||||
skill: detail.skill
|
||||
registry,
|
||||
source: "clawhub",
|
||||
installRef: ownerHandle ? `@${ownerHandle}/${params.slug}` : params.slug,
|
||||
selectedRelease: selectedVersion
|
||||
? {
|
||||
...detail.skill,
|
||||
icon: resolveClawHubImageUrl(detail.skill.icon, params.baseUrl),
|
||||
version: selectedVersion.version,
|
||||
createdAt: selectedVersion.createdAt,
|
||||
changelog: selectedVersion.changelog,
|
||||
tags: Object.entries(detail.skill?.tags ?? {})
|
||||
.filter(([, taggedVersion]) => taggedVersion === selectedVersion.version)
|
||||
.map(([tag]) => tag),
|
||||
}
|
||||
: canUseLatest && detail.latestVersion
|
||||
? {
|
||||
version: detail.latestVersion.version,
|
||||
createdAt: detail.latestVersion.createdAt,
|
||||
changelog: detail.latestVersion.changelog,
|
||||
}
|
||||
: null,
|
||||
// Neither listing visibility, successful card reads, nor scan verdicts assert that
|
||||
// this exact release has a downloadable artifact. ClawHub's install resolver picks latest.
|
||||
downloadability: !version
|
||||
? {
|
||||
status: "unknown",
|
||||
reason: "The listing has no hosted release; source-backed availability is not reported.",
|
||||
}
|
||||
: releaseUnavailable
|
||||
? { status: "unavailable", reason: "The selected release is not available from ClawHub." }
|
||||
: {
|
||||
status: "unknown",
|
||||
reason:
|
||||
release?.status === "rejected"
|
||||
? `Selected release details unavailable: ${String(release.reason)}`
|
||||
: releaseMismatch
|
||||
? "ClawHub returned details for a different release."
|
||||
: "ClawHub does not report downloadability for a selected skill release.",
|
||||
},
|
||||
card:
|
||||
card?.status === "fulfilled"
|
||||
? { status: "available", content: card.value }
|
||||
: {
|
||||
status: "unavailable",
|
||||
reason:
|
||||
card?.status === "rejected"
|
||||
? String(card.reason)
|
||||
: "No published release is available for a skill card.",
|
||||
},
|
||||
requirements:
|
||||
canUseLatest && detail.metadata?.setup
|
||||
? {
|
||||
status: "available",
|
||||
setup: detail.metadata.setup,
|
||||
os: detail.metadata.os,
|
||||
systems: detail.metadata.systems,
|
||||
scope: "registry-setup",
|
||||
note: "Registry setup keys combine environment and configuration requirements; binary requirements are not reported.",
|
||||
}
|
||||
: {
|
||||
status: "unavailable",
|
||||
reason: isLatest
|
||||
? "ClawHub does not report setup requirements for this release."
|
||||
: "ClawHub reports structured setup requirements only for the latest release.",
|
||||
},
|
||||
security: validSecurity
|
||||
? {
|
||||
status: "available",
|
||||
scanStatus: security.status,
|
||||
hasWarnings: security.hasWarnings,
|
||||
hasScanResult: security.hasScanResult,
|
||||
checkedAt: typeof security.checkedAt === "number" ? security.checkedAt : null,
|
||||
summary:
|
||||
typeof security.scanners?.llm?.summary === "string"
|
||||
? security.scanners.llm.summary
|
||||
: null,
|
||||
virustotalUrl: typeof security.virustotalUrl === "string" ? security.virustotalUrl : null,
|
||||
}
|
||||
: {
|
||||
status: "unavailable",
|
||||
reason: "ClawHub has no security scan snapshot for this release.",
|
||||
},
|
||||
warnings,
|
||||
skill: detail.skill
|
||||
? { ...detail.skill, icon: resolveClawHubImageUrl(detail.skill.icon, registry) }
|
||||
: null,
|
||||
owner: detail.owner
|
||||
? { ...detail.owner, image: resolveClawHubImageUrl(detail.owner.image, registry) }
|
||||
: detail.owner,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -237,6 +237,7 @@ export type PluginCapabilityConsentReview = Omit<
|
|||
"ok" | "plugin" | "components" | "catalog"
|
||||
> & {
|
||||
pluginId: string;
|
||||
reviewToken: string;
|
||||
name: string;
|
||||
version?: string;
|
||||
widened?: Partial<PluginAcceptedDeclaredSurface>;
|
||||
|
|
|
|||
|
|
@ -9,10 +9,14 @@ import type {
|
|||
PluginsListResult,
|
||||
} from "../../packages/gateway-protocol/src/schema/plugins.js";
|
||||
import { comparePluginCatalogEntries } from "../../packages/plugin-package-contract/src/catalog-order.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type {
|
||||
ClawHubPluginCatalogEntry,
|
||||
ClawHubPluginDetail,
|
||||
} from "../infra/clawhub-plugin-catalog.js";
|
||||
import { buildPluginCapabilitySummary } from "./capability-summary.js";
|
||||
import { emptyInstalledPluginComponents } from "./installed-plugin-components.js";
|
||||
import { projectPluginOverviewCapabilities } from "./installed-plugin-overview.js";
|
||||
|
||||
const DISCOVERY_ID_PREFIX = "ch_";
|
||||
const LOCAL_DISCOVERY_ID_PREFIX = "local_";
|
||||
|
|
@ -317,6 +321,11 @@ export function joinLocalPluginDetail(params: {
|
|||
mcpServers: inspection?.components.mcpServers ?? [],
|
||||
skills: (inspection?.components.skills ?? []).map((name) => ({ name })),
|
||||
versions: [],
|
||||
selectedRelease: null,
|
||||
downloadability: {
|
||||
status: "unknown",
|
||||
reason: "Local metadata does not establish ClawHub release downloadability.",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -332,6 +341,11 @@ export function joinClawHubPluginDetail(params: {
|
|||
const detail: PluginDiscoveryDetail = {
|
||||
origin: "clawhub",
|
||||
packageName: params.remote.packageName,
|
||||
registry: params.remote.registry,
|
||||
tags: params.remote.tags,
|
||||
selectedRelease: params.remote.selectedRelease,
|
||||
downloadability: params.remote.downloadability,
|
||||
metadata: params.remote.metadata,
|
||||
...(params.remote.owner ? { author: params.remote.owner } : {}),
|
||||
topics: params.remote.topics,
|
||||
...(params.remote.createdAt !== undefined ? { createdAt: params.remote.createdAt } : {}),
|
||||
|
|
@ -355,3 +369,69 @@ export function joinClawHubPluginDetail(params: {
|
|||
};
|
||||
return { plugin, detail };
|
||||
}
|
||||
|
||||
/** Project advisory registry metadata without issuing package capability consent. */
|
||||
export function projectClawHubPluginInspection(params: {
|
||||
remote: ClawHubPluginDetail;
|
||||
local: PluginsListResult;
|
||||
config: OpenClawConfig;
|
||||
}): PluginsInspectResult {
|
||||
const { remote, local, config } = params;
|
||||
const localPlugin = findLocalPluginByIdentity(local, remote.packageName);
|
||||
const installedPlugin = localPlugin?.installed ? localPlugin : undefined;
|
||||
const runtimePlugin = remote.runtimeId
|
||||
? findLocalPluginByIdentity(
|
||||
{ ...local, plugins: local.plugins.filter((plugin) => plugin.id === remote.runtimeId) },
|
||||
remote.packageName,
|
||||
)
|
||||
: undefined;
|
||||
const summary = buildPluginCapabilitySummary({
|
||||
manifest: {
|
||||
contracts: remote.contracts,
|
||||
channels: remote.channels,
|
||||
providers: remote.providers,
|
||||
mcpServers: Object.fromEntries(remote.mcpServers.map((name) => [name, {}])),
|
||||
skills: remote.skills.map((skill) => skill.name),
|
||||
},
|
||||
origin: "global",
|
||||
entryConfig: runtimePlugin?.installed ? config.plugins?.entries?.[runtimePlugin.id] : undefined,
|
||||
});
|
||||
const catalog = joinClawHubPluginDetail({ remote, local });
|
||||
return {
|
||||
ok: true,
|
||||
plugin: {
|
||||
id: installedPlugin?.id ?? catalog.plugin.id,
|
||||
name: remote.displayName,
|
||||
...(remote.selectedRelease ? { version: remote.selectedRelease.version } : {}),
|
||||
...(remote.summary ? { description: remote.summary } : {}),
|
||||
origin: "clawhub",
|
||||
installed: Boolean(installedPlugin),
|
||||
enabled: installedPlugin?.enabled ?? false,
|
||||
},
|
||||
source: {
|
||||
kind: "clawhub",
|
||||
packageName: remote.packageName,
|
||||
},
|
||||
...summary,
|
||||
// Registry summaries omit package siblings and some declared capability groups.
|
||||
// Only staged or installed package inspection can issue capability consent.
|
||||
declaredSurfaceStatus: remote.metadata.manifest === "available" ? "partial" : "unavailable",
|
||||
components: emptyInstalledPluginComponents(),
|
||||
overview: {
|
||||
...(remote.metadata.manifest === "available"
|
||||
? {
|
||||
capabilities: projectPluginOverviewCapabilities(
|
||||
summary.declared,
|
||||
remote.uiCapabilities,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(remote.readme ? { readme: remote.readme } : {}),
|
||||
...(remote.repositoryUrl ? { repositoryUrl: remote.repositoryUrl } : {}),
|
||||
...(remote.documentationUrl ? { documentationUrl: remote.documentationUrl } : {}),
|
||||
...(remote.owner?.displayName ? { publisherName: remote.owner.displayName } : {}),
|
||||
},
|
||||
...(remote.trust ? { trust: remote.trust } : {}),
|
||||
catalog,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -553,7 +553,7 @@ describe("managed plugin capability consent", () => {
|
|||
config,
|
||||
env,
|
||||
pluginId: inspection.plugin.id,
|
||||
acknowledge: { reviewToken: inspection.reviewToken },
|
||||
acknowledge: { reviewToken },
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,17 @@ import { clearPluginMetadataLifecycleCaches } from "./plugin-metadata-lifecycle.
|
|||
import { createEmptyPluginRegistry } from "./registry-empty.js";
|
||||
import { withPluginRuntimeRegistryScope } from "./runtime/gateway-request-scope.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ metadata: vi.fn(), officialCatalog: vi.fn(), mcpAuth: vi.fn() }));
|
||||
const mocks = vi.hoisted(() => ({
|
||||
metadata: vi.fn(),
|
||||
officialCatalog: vi.fn(),
|
||||
mcpAuth: vi.fn(),
|
||||
remoteDetail: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../infra/clawhub-plugin-catalog.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../infra/clawhub-plugin-catalog.js")>()),
|
||||
fetchClawHubPluginDetail: (...args: unknown[]) => mocks.remoteDetail(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../agents/mcp-oauth.js", () => ({
|
||||
readMcpOAuthCredentialsStatuses: (...args: unknown[]) => mocks.mcpAuth(...args),
|
||||
|
|
@ -47,8 +57,134 @@ describe("managed plugin inspection", () => {
|
|||
mocks.officialCatalog.mockReset();
|
||||
mocks.officialCatalog.mockResolvedValue({ source: "hosted", entries: [] });
|
||||
mocks.mcpAuth.mockReset();
|
||||
mocks.remoteDetail.mockReset();
|
||||
});
|
||||
|
||||
it.each([true, false])(
|
||||
"inspects an arbitrary selected ClawHub release with manifest available: %s",
|
||||
async (manifestAvailable) => {
|
||||
// A runtime ID collision is not a canonical ClawHub package match.
|
||||
mocks.metadata.mockReturnValue(metadataSnapshot({ enabled: true, id: "community-plugin" }));
|
||||
mocks.remoteDetail.mockResolvedValue({
|
||||
packageName: "community/plugin",
|
||||
displayName: "Community Plugin",
|
||||
family: "code-plugin",
|
||||
runtimeId: "community-plugin",
|
||||
isOfficial: false,
|
||||
categories: [],
|
||||
topics: [],
|
||||
configFields: [],
|
||||
mcpServers: manifestAvailable ? ["docs"] : [],
|
||||
skills: manifestAvailable ? [{ name: "research" }] : [],
|
||||
...(manifestAvailable
|
||||
? { contracts: { tools: ["research_lookup"] }, providers: ["search"] }
|
||||
: {}),
|
||||
versions: [],
|
||||
selectedRelease: { version: "1.2.3" },
|
||||
tags: { latest: "2.0.0" },
|
||||
downloadability: { status: "downloadable" },
|
||||
metadata: {
|
||||
manifest: manifestAvailable ? "available" : "missing",
|
||||
readme: "available",
|
||||
security: "missing",
|
||||
},
|
||||
readme: "# Community Plugin",
|
||||
trust: { disposition: "review-required", reasons: ["Unverified publisher"] },
|
||||
});
|
||||
const inspection = await inspectManagedPlugin({
|
||||
config: {
|
||||
plugins: {
|
||||
entries: { "community-plugin": { hooks: { allowConversationAccess: true } } },
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
clawhub: { packageName: "community/plugin", version: "1.2.3" },
|
||||
});
|
||||
|
||||
expect(inspection).toMatchObject({
|
||||
plugin: { name: "Community Plugin", version: "1.2.3", installed: false, enabled: false },
|
||||
source: { kind: "clawhub", packageName: "community/plugin" },
|
||||
declaredSurfaceStatus: manifestAvailable ? "partial" : "unavailable",
|
||||
declared: {
|
||||
tools: manifestAvailable ? ["research_lookup"] : [],
|
||||
mcpServers: manifestAvailable ? ["docs"] : [],
|
||||
skills: manifestAvailable ? ["research"] : [],
|
||||
},
|
||||
grants: { hooks: { allowConversationAccess: { effective: false } } },
|
||||
trust: { disposition: "review-required" },
|
||||
catalog: {
|
||||
detail: {
|
||||
packageName: "community/plugin",
|
||||
readme: "# Community Plugin",
|
||||
selectedRelease: { version: "1.2.3" },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(inspection.reviewToken).toBeUndefined();
|
||||
expect(inspection.overview?.capabilities === undefined).toBe(!manifestAvailable);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["community-plugin", undefined, "another-plugin"])(
|
||||
"joins installed grants only for the selected runtime identity: %s",
|
||||
async (runtimeId) => {
|
||||
mocks.metadata.mockReturnValue(
|
||||
metadataSnapshot({
|
||||
enabled: false,
|
||||
id: "community-plugin",
|
||||
origin: "global",
|
||||
installRecord: {
|
||||
source: "clawhub",
|
||||
clawhubPackage: "community/plugin",
|
||||
clawhubUrl: "https://clawhub.ai",
|
||||
installPath: "/tmp/community-plugin",
|
||||
},
|
||||
}),
|
||||
);
|
||||
mocks.remoteDetail.mockResolvedValue({
|
||||
packageName: "community/plugin",
|
||||
runtimeId,
|
||||
displayName: "Community Plugin",
|
||||
family: "code-plugin",
|
||||
isOfficial: false,
|
||||
categories: [],
|
||||
topics: [],
|
||||
configFields: [],
|
||||
mcpServers: [],
|
||||
skills: [],
|
||||
versions: [],
|
||||
selectedRelease: { version: "2.0.0" },
|
||||
tags: {},
|
||||
downloadability: { status: "downloadable" },
|
||||
metadata: { manifest: "available", readme: "missing", security: "missing" },
|
||||
contracts: { tools: ["new_tool"] },
|
||||
});
|
||||
const inspection = await inspectManagedPlugin({
|
||||
config: {
|
||||
plugins: {
|
||||
entries: { "community-plugin": { hooks: { allowConversationAccess: true } } },
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
clawhub: { packageName: "community/plugin", version: "2.0.0" },
|
||||
});
|
||||
|
||||
expect(inspection).toMatchObject({
|
||||
plugin: { id: "community-plugin", installed: true, version: "2.0.0" },
|
||||
declared: { tools: ["new_tool"] },
|
||||
grants: {
|
||||
hooks: {
|
||||
allowConversationAccess:
|
||||
runtimeId === "community-plugin"
|
||||
? { effective: true, configured: true }
|
||||
: { effective: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(inspection.reviewToken).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it("projects only eligible operator MCP connections without credential details", async () => {
|
||||
const snapshot = metadataSnapshot({ enabled: true });
|
||||
const states = [
|
||||
|
|
|
|||
|
|
@ -10,7 +10,10 @@ import { resolveConfigWidePluginMetadataSnapshot } from "../config/io.plugin-met
|
|||
import { resolveIsConfigReadOnly } from "../config/paths.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { resolveClawHubBaseUrl } from "../infra/clawhub-client.js";
|
||||
import { fetchClawHubPluginVersionCategories } from "../infra/clawhub-plugin-catalog.js";
|
||||
import {
|
||||
fetchClawHubPluginDetail,
|
||||
fetchClawHubPluginVersionCategories,
|
||||
} from "../infra/clawhub-plugin-catalog.js";
|
||||
import { resolvePluginActivationSourceConfig } from "./activation-source-config.js";
|
||||
import { resolvePendingPluginCapabilityReview } from "./capability-consent.js";
|
||||
import {
|
||||
|
|
@ -20,6 +23,7 @@ import {
|
|||
resolvePluginInstallRecordTrust,
|
||||
resolvePluginPackageDeclaredSurface,
|
||||
} from "./capability-summary.js";
|
||||
import { projectClawHubPluginInspection } from "./catalog-discovery.js";
|
||||
import { normalizeCatalogIconUrl } from "./catalog-icon-registry.js";
|
||||
import {
|
||||
appendPluginControlPlaneWorkspaceDiagnostic,
|
||||
|
|
@ -535,10 +539,23 @@ export const listManagedPlugins = withManagedPluginCache(
|
|||
export const inspectManagedPlugin = withManagedPluginCache(
|
||||
async (params: {
|
||||
config: OpenClawConfig;
|
||||
pluginId: string;
|
||||
pluginId?: string;
|
||||
clawhub?: { packageName: string; version?: string };
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<PluginsInspectResult> => {
|
||||
const env = params.env ?? process.env;
|
||||
if (params.clawhub) {
|
||||
const [remote, local] = await Promise.all([
|
||||
fetchClawHubPluginDetail(params.clawhub),
|
||||
listManagedPlugins({ config: params.config, env }),
|
||||
]);
|
||||
return projectClawHubPluginInspection({ remote, local, config: params.config });
|
||||
}
|
||||
if (!params.pluginId) {
|
||||
throw new ManagedPluginLifecycleError("A plugin inspection identity is required.", {
|
||||
kind: "invalid-request",
|
||||
});
|
||||
}
|
||||
const metadata = resolveManagedPluginMetadata(params.config, env);
|
||||
const pluginId = metadata.normalizePluginId(params.pluginId);
|
||||
const record = metadata.index.plugins.find((candidate) => candidate.pluginId === pluginId);
|
||||
|
|
|
|||
|
|
@ -175,7 +175,9 @@ describe("renderPluginConsentDialog", () => {
|
|||
});
|
||||
|
||||
it("highlights newly declared capability groups since the previous acceptance", () => {
|
||||
const reviewToken = "a".repeat(64);
|
||||
const inspection = createInspectResult({
|
||||
reviewToken,
|
||||
declared: {
|
||||
...createInspectResult().declared,
|
||||
tools: ["workboard_review"],
|
||||
|
|
@ -191,7 +193,7 @@ describe("renderPluginConsentDialog", () => {
|
|||
fallback: { name: "Workboard" },
|
||||
details: buildCapabilityConsentErrorDetails({
|
||||
pluginId: "workboard",
|
||||
reviewToken: inspection.reviewToken,
|
||||
reviewToken,
|
||||
widened: {
|
||||
tools: ["workboard_review"],
|
||||
contracts: ["gatewayMethodDispatch: workboard.dispatch"],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue