From 37c37eecfbe4bef413c33b8606db62cf51c2d1d4 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 23 Apr 2026 23:21:43 -0700 Subject: [PATCH] feat(plugins): expose install source facts * feat(plugins): expose install source facts * fix(plugins): normalize install integrity facts * fix(plugins): guard install source string fields * fix(plugins): keep install source facts additive --- docs/plugins/architecture-internals.md | 14 ++ docs/plugins/manifest.md | 10 +- src/channels/plugins/catalog.ts | 6 + src/plugins/install-source-info.test.ts | 134 ++++++++++++++++++ src/plugins/install-source-info.ts | 91 ++++++++++++ src/plugins/provider-install-catalog.test.ts | 47 ++++++ src/plugins/provider-install-catalog.ts | 6 + .../channel-plugin-catalog-contract-suites.ts | 13 ++ 8 files changed, 317 insertions(+), 4 deletions(-) create mode 100644 src/plugins/install-source-info.test.ts create mode 100644 src/plugins/install-source-info.ts diff --git a/docs/plugins/architecture-internals.md b/docs/plugins/architecture-internals.md index e1422b68c16..b874b77fe00 100644 --- a/docs/plugins/architecture-internals.md +++ b/docs/plugins/architecture-internals.md @@ -884,6 +884,20 @@ Or point `OPENCLAW_PLUGIN_CATALOG_PATHS` (or `OPENCLAW_MPM_CATALOG_PATHS`) at one or more JSON files (comma/semicolon/`PATH`-delimited). Each file should contain `{ "entries": [ { "name": "@scope/pkg", "openclaw": { "channel": {...}, "install": {...} } } ] }`. The parser also accepts `"packages"` or `"plugins"` as legacy aliases for the `"entries"` key. +Generated channel catalog entries and provider install catalog entries expose +normalized install-source facts next to the raw `openclaw.install` block. The +normalized facts identify whether the npm spec is an exact version or floating +selector, whether expected integrity metadata is present, and whether a local +source path is also available. Consumers should treat `installSource` as an +additive optional field so older hand-built entries and compatibility shims do +not have to synthesize it. This lets onboarding and diagnostics explain +source-plane state without importing plugin runtime. + +Official external npm entries should prefer an exact `npmSpec` plus +`expectedIntegrity`. Bare package names and dist-tags still work for +compatibility, but they surface source-plane warnings so the catalog can move +toward pinned, integrity-checked installs without breaking existing plugins. + ## Context engine plugins Context engine plugins own session context orchestration for ingest, assembly, diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index def28efcfeb..6e633dfece1 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -591,10 +591,12 @@ registry loading. Invalid values are rejected; newer-but-valid values skip the plugin on older hosts. Exact npm version pinning already lives in `npmSpec`, for example -`"npmSpec": "@wecom/wecom-openclaw-plugin@1.2.3"`. Pair that with -`expectedIntegrity` when you want update flows to fail closed if the fetched -npm artifact no longer matches the pinned release. Interactive onboarding -offers trusted registry npm specs, including bare package names and dist-tags. +`"npmSpec": "@wecom/wecom-openclaw-plugin@1.2.3"`. Official external catalog +entries should pair exact specs with `expectedIntegrity` so update flows fail +closed if the fetched npm artifact no longer matches the pinned release. +Interactive onboarding still offers trusted registry npm specs, including bare +package names and dist-tags, for compatibility. Catalog diagnostics can +distinguish exact, floating, integrity-pinned, and missing-integrity sources. When `expectedIntegrity` is present, install/update flows enforce it; when it is omitted, the registry resolution is recorded without an integrity pin. diff --git a/src/channels/plugins/catalog.ts b/src/channels/plugins/catalog.ts index 50fbc9606d5..1040daaeb9e 100644 --- a/src/channels/plugins/catalog.ts +++ b/src/channels/plugins/catalog.ts @@ -4,6 +4,10 @@ import officialExternalChannelCatalog from "../../../scripts/lib/official-extern import { MANIFEST_KEY } from "../../compat/legacy-names.js"; import { resolveOpenClawPackageRootSync } from "../../infra/openclaw-root.js"; import { listChannelCatalogEntries } from "../../plugins/channel-catalog-registry.js"; +import { + describePluginInstallSource, + type PluginInstallSourceInfo, +} from "../../plugins/install-source-info.js"; import type { OpenClawPackageManifest } from "../../plugins/manifest.js"; import type { PluginPackageChannel, PluginPackageInstall } from "../../plugins/manifest.js"; import type { PluginOrigin } from "../../plugins/plugin-origin.types.js"; @@ -36,6 +40,7 @@ export type ChannelPluginCatalogEntry = { install: PluginPackageInstall & { npmSpec: string; }; + installSource?: PluginInstallSourceInfo; }; type CatalogOptions = { @@ -264,6 +269,7 @@ function buildCatalogEntryFromManifest(params: { ...(params.origin ? { origin: params.origin } : {}), meta, install, + installSource: describePluginInstallSource(install), }; } diff --git a/src/plugins/install-source-info.test.ts b/src/plugins/install-source-info.test.ts new file mode 100644 index 00000000000..6eceee53892 --- /dev/null +++ b/src/plugins/install-source-info.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "vitest"; +import { describePluginInstallSource } from "./install-source-info.js"; + +describe("describePluginInstallSource", () => { + it("marks exact npm specs with integrity as fully pinned", () => { + expect( + describePluginInstallSource({ + npmSpec: "@vendor/demo@1.2.3", + expectedIntegrity: " sha512-demo ", + defaultChoice: "npm", + }), + ).toEqual({ + defaultChoice: "npm", + npm: { + spec: "@vendor/demo@1.2.3", + packageName: "@vendor/demo", + selector: "1.2.3", + selectorKind: "exact-version", + exactVersion: true, + expectedIntegrity: "sha512-demo", + pinState: "exact-with-integrity", + }, + warnings: [], + }); + }); + + it("marks exact npm specs without integrity as version-pinned only", () => { + expect( + describePluginInstallSource({ + npmSpec: "@vendor/demo@1.2.3", + }), + ).toEqual({ + npm: { + spec: "@vendor/demo@1.2.3", + packageName: "@vendor/demo", + selector: "1.2.3", + selectorKind: "exact-version", + exactVersion: true, + pinState: "exact-without-integrity", + }, + warnings: ["npm-spec-missing-integrity"], + }); + }); + + it("omits whitespace-only integrity from npm source facts", () => { + expect( + describePluginInstallSource({ + npmSpec: "@vendor/demo@1.2.3", + expectedIntegrity: " ", + }), + ).toEqual({ + npm: { + spec: "@vendor/demo@1.2.3", + packageName: "@vendor/demo", + selector: "1.2.3", + selectorKind: "exact-version", + exactVersion: true, + pinState: "exact-without-integrity", + }, + warnings: ["npm-spec-missing-integrity"], + }); + }); + + it("treats non-string integrity metadata as missing", () => { + expect( + describePluginInstallSource({ + npmSpec: "@vendor/demo@1.2.3", + expectedIntegrity: 123, + } as never), + ).toEqual({ + npm: { + spec: "@vendor/demo@1.2.3", + packageName: "@vendor/demo", + selector: "1.2.3", + selectorKind: "exact-version", + exactVersion: true, + pinState: "exact-without-integrity", + }, + warnings: ["npm-spec-missing-integrity"], + }); + }); + + it("surfaces floating specs with integrity without rejecting them", () => { + expect( + describePluginInstallSource({ + npmSpec: "@vendor/demo@beta", + expectedIntegrity: "sha512-demo", + }), + ).toEqual({ + npm: { + spec: "@vendor/demo@beta", + packageName: "@vendor/demo", + selector: "beta", + selectorKind: "tag", + exactVersion: false, + expectedIntegrity: "sha512-demo", + pinState: "floating-with-integrity", + }, + warnings: ["npm-spec-floating"], + }); + }); + + it("surfaces floating specs without integrity without rejecting them", () => { + expect( + describePluginInstallSource({ + npmSpec: "@vendor/demo@beta", + }), + ).toEqual({ + npm: { + spec: "@vendor/demo@beta", + packageName: "@vendor/demo", + selector: "beta", + selectorKind: "tag", + exactVersion: false, + pinState: "floating-without-integrity", + }, + warnings: ["npm-spec-floating", "npm-spec-missing-integrity"], + }); + }); + + it("reports invalid npm specs while preserving local source metadata", () => { + expect( + describePluginInstallSource({ + npmSpec: "github:vendor/demo", + localPath: "extensions/demo", + }), + ).toEqual({ + local: { + path: "extensions/demo", + }, + warnings: ["invalid-npm-spec"], + }); + }); +}); diff --git a/src/plugins/install-source-info.ts b/src/plugins/install-source-info.ts new file mode 100644 index 00000000000..d729e28b83b --- /dev/null +++ b/src/plugins/install-source-info.ts @@ -0,0 +1,91 @@ +import { parseRegistryNpmSpec, type ParsedRegistryNpmSpec } from "../infra/npm-registry-spec.js"; +import { normalizeOptionalString } from "../shared/string-coerce.js"; +import type { PluginPackageInstall } from "./manifest.js"; + +export type PluginInstallSourceWarning = + | "invalid-npm-spec" + | "npm-spec-floating" + | "npm-spec-missing-integrity"; + +export type PluginInstallNpmPinState = + | "exact-with-integrity" + | "exact-without-integrity" + | "floating-with-integrity" + | "floating-without-integrity"; + +export type PluginInstallNpmSourceInfo = { + spec: string; + packageName: string; + selector?: string; + selectorKind: ParsedRegistryNpmSpec["selectorKind"]; + exactVersion: boolean; + expectedIntegrity?: string; + pinState: PluginInstallNpmPinState; +}; + +export type PluginInstallLocalSourceInfo = { + path: string; +}; + +export type PluginInstallSourceInfo = { + defaultChoice?: PluginPackageInstall["defaultChoice"]; + npm?: PluginInstallNpmSourceInfo; + local?: PluginInstallLocalSourceInfo; + warnings: readonly PluginInstallSourceWarning[]; +}; + +function resolveNpmPinState(params: { + exactVersion: boolean; + hasIntegrity: boolean; +}): PluginInstallNpmPinState { + if (params.exactVersion) { + return params.hasIntegrity ? "exact-with-integrity" : "exact-without-integrity"; + } + return params.hasIntegrity ? "floating-with-integrity" : "floating-without-integrity"; +} + +export function describePluginInstallSource( + install: PluginPackageInstall, +): PluginInstallSourceInfo { + const npmSpec = normalizeOptionalString(install.npmSpec); + const localPath = normalizeOptionalString(install.localPath); + const defaultChoice = + install.defaultChoice === "npm" || install.defaultChoice === "local" + ? install.defaultChoice + : undefined; + const warnings: PluginInstallSourceWarning[] = []; + let npm: PluginInstallNpmSourceInfo | undefined; + + if (npmSpec) { + const parsed = parseRegistryNpmSpec(npmSpec); + if (parsed) { + const exactVersion = parsed.selectorKind === "exact-version"; + const expectedIntegrity = normalizeOptionalString(install.expectedIntegrity); + const hasIntegrity = Boolean(expectedIntegrity); + if (!exactVersion) { + warnings.push("npm-spec-floating"); + } + if (!hasIntegrity) { + warnings.push("npm-spec-missing-integrity"); + } + npm = { + spec: parsed.raw, + packageName: parsed.name, + selectorKind: parsed.selectorKind, + exactVersion, + pinState: resolveNpmPinState({ exactVersion, hasIntegrity }), + ...(parsed.selector ? { selector: parsed.selector } : {}), + ...(expectedIntegrity ? { expectedIntegrity } : {}), + }; + } else { + warnings.push("invalid-npm-spec"); + } + } + + return { + ...(defaultChoice ? { defaultChoice } : {}), + ...(npm ? { npm } : {}), + ...(localPath ? { local: { path: localPath } } : {}), + warnings, + }; +} diff --git a/src/plugins/provider-install-catalog.test.ts b/src/plugins/provider-install-catalog.test.ts index 25658e9a96b..e493d84229f 100644 --- a/src/plugins/provider-install-catalog.test.ts +++ b/src/plugins/provider-install-catalog.test.ts @@ -104,6 +104,22 @@ describe("provider install catalog", () => { defaultChoice: "npm", expectedIntegrity: "sha512-openai", }, + installSource: { + defaultChoice: "npm", + npm: { + spec: "@openclaw/openai@1.2.3", + packageName: "@openclaw/openai", + selector: "1.2.3", + selectorKind: "exact-version", + exactVersion: true, + expectedIntegrity: "sha512-openai", + pinState: "exact-with-integrity", + }, + local: { + path: "extensions/openai", + }, + warnings: [], + }, }, ]); }); @@ -157,6 +173,13 @@ describe("provider install catalog", () => { localPath: "extensions/demo-provider", defaultChoice: "local", }, + installSource: { + defaultChoice: "local", + local: { + path: "extensions/demo-provider", + }, + warnings: [], + }, }, ]); }); @@ -216,6 +239,19 @@ describe("provider install catalog", () => { expectedIntegrity: "sha512-vllm", defaultChoice: "npm", }, + installSource: { + defaultChoice: "npm", + npm: { + spec: "@openclaw/vllm@2.0.0", + packageName: "@openclaw/vllm", + selector: "2.0.0", + selectorKind: "exact-version", + exactVersion: true, + expectedIntegrity: "sha512-vllm", + pinState: "exact-with-integrity", + }, + warnings: [], + }, }); }); @@ -270,6 +306,17 @@ describe("provider install catalog", () => { npmSpec: "@openclaw/vllm", defaultChoice: "npm", }, + installSource: { + defaultChoice: "npm", + npm: { + spec: "@openclaw/vllm", + packageName: "@openclaw/vllm", + selectorKind: "none", + exactVersion: false, + pinState: "floating-without-integrity", + }, + warnings: ["npm-spec-floating", "npm-spec-missing-integrity"], + }, }); }); diff --git a/src/plugins/provider-install-catalog.ts b/src/plugins/provider-install-catalog.ts index 41211ab3b4f..5e1759fc678 100644 --- a/src/plugins/provider-install-catalog.ts +++ b/src/plugins/provider-install-catalog.ts @@ -2,6 +2,10 @@ import path from "node:path"; import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js"; import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; import { discoverOpenClawPlugins } from "./discovery.js"; +import { + describePluginInstallSource, + type PluginInstallSourceInfo, +} from "./install-source-info.js"; import { loadPluginManifest, type PluginPackageInstall, @@ -17,6 +21,7 @@ export type ProviderInstallCatalogEntry = ProviderAuthChoiceMetadata & { label: string; origin: PluginOrigin; install: PluginPackageInstall; + installSource?: PluginInstallSourceInfo; }; type ProviderInstallCatalogParams = { @@ -179,6 +184,7 @@ export function resolveProviderInstallCatalogEntries( label: choice.groupLabel ?? choice.choiceLabel, origin: install.origin, install: install.install, + installSource: describePluginInstallSource(install.install), } satisfies ProviderInstallCatalogEntry, ]; }) diff --git a/test/helpers/channels/channel-plugin-catalog-contract-suites.ts b/test/helpers/channels/channel-plugin-catalog-contract-suites.ts index 9fb21c1a77f..73823833b3c 100644 --- a/test/helpers/channels/channel-plugin-catalog-contract-suites.ts +++ b/test/helpers/channels/channel-plugin-catalog-contract-suites.ts @@ -264,6 +264,19 @@ export function describeChannelPluginCatalogEntriesContract() { minHostVersion: ">=2026.4.10", expectedIntegrity: "sha512-wecom", }, + installSource: { + defaultChoice: "npm", + npm: { + spec: "@wecom/wecom-openclaw-plugin@1.2.3", + packageName: "@wecom/wecom-openclaw-plugin", + selector: "1.2.3", + selectorKind: "exact-version", + exactVersion: true, + expectedIntegrity: "sha512-wecom", + pinState: "exact-with-integrity", + }, + warnings: [], + }, }, }; },