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:
Jacob Tomlinson 2026-10-02 15:35:11 +01:00 • committed by GitHub
parent 3932cbe4c9
commit 259033fe97
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1252 additions and 133 deletions

View file

@ -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

View file

@ -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.

View file

@ -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"),

View 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")]),
});

View file

@ -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. */

View 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(),
]),
),
});

View file

@ -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) => {

View file

@ -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 } : {}),

View file

@ -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({

View file

@ -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,

View file

@ -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) => {

View file

@ -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);

View file

@ -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([

View file

@ -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";

View file

@ -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/);
},
);
});

View file

@ -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,

View 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 };
}

View file

@ -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,
};
}

View file

@ -237,6 +237,7 @@ export type PluginCapabilityConsentReview = Omit<
"ok" | "plugin" | "components" | "catalog"
> & {
pluginId: string;
reviewToken: string;
name: string;
version?: string;
widened?: Partial<PluginAcceptedDeclaredSurface>;

View file

@ -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,
};
}

View file

@ -553,7 +553,7 @@ describe("managed plugin capability consent", () => {
config,
env,
pluginId: inspection.plugin.id,
acknowledge: { reviewToken: inspection.reviewToken },
acknowledge: { reviewToken },
}),
).resolves.toBeUndefined();
}

View file

@ -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 = [

View file

@ -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);

View file

@ -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"],