diff --git a/CHANGELOG.md b/CHANGELOG.md index 78f661815fc..9f2eeb777eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai - Plugins/catalog: pin the official external WeCom channel source to an exact npm release plus dist integrity, with a guard that official external sources stay integrity-pinned. (#70997) Thanks @vincentkoc. - Plugins/source metadata: warn when `openclaw.install.defaultChoice` is invalid or points at a missing source, keeping catalog diagnostics explicit without breaking existing plugins. Thanks @vincentkoc. - Plugins/source metadata: warn when `openclaw.install.expectedIntegrity` is present without a valid npm source, keeping orphaned integrity metadata visible without rejecting existing plugins. Thanks @vincentkoc. +- Plugins/source metadata: warn when provider or channel catalog package identity drifts from `openclaw.install.npmSpec`, keeping diagnostics visible without rejecting compatible external catalogs. Thanks @vincentkoc. - Diagnostics/OTEL: add a lightweight diagnostic trace-context carrier for future span correlation without adding OTEL SDK state to core. Thanks @vincentkoc. - Dependencies/SBOM: add an ownership-backed dependency risk report for root closure size, native/build-risk packages, and missing owner records. Thanks @vincentkoc. - Diagnostics/OTEL: attach diagnostic trace context to exported OTEL logs so log records can correlate with future spans without adding retained process state. Thanks @vincentkoc. diff --git a/docs/plugins/architecture-internals.md b/docs/plugins/architecture-internals.md index 2b0747593e1..ec52352002f 100644 --- a/docs/plugins/architecture-internals.md +++ b/docs/plugins/architecture-internals.md @@ -888,12 +888,14 @@ 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. They also warn when `defaultChoice` is invalid -or points at a source that is not available, and when npm integrity metadata is -present without a valid npm source. 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. +source path is also available. When the catalog/package identity is known, the +normalized facts warn if the parsed npm package name drifts from that identity. +They also warn when `defaultChoice` is invalid or points at a source that is +not available, and when npm integrity metadata is present without a valid npm +source. 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 diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index a707b58be5b..8c2050d6faf 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -596,9 +596,10 @@ 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, missing-integrity, and invalid -default-choice sources. They also warn when `expectedIntegrity` is present but -there is no valid npm source it can pin. When `expectedIntegrity` is present, +distinguish exact, floating, integrity-pinned, missing-integrity, package-name +mismatch, and invalid default-choice sources. They also warn when +`expectedIntegrity` is present but there is no valid npm source it can pin. +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 1040daaeb9e..ce5310ff145 100644 --- a/src/channels/plugins/catalog.ts +++ b/src/channels/plugins/catalog.ts @@ -269,7 +269,9 @@ function buildCatalogEntryFromManifest(params: { ...(params.origin ? { origin: params.origin } : {}), meta, install, - installSource: describePluginInstallSource(install), + installSource: describePluginInstallSource(install, { + expectedPackageName: params.packageName, + }), }; } diff --git a/src/plugins/install-source-info.test.ts b/src/plugins/install-source-info.test.ts index 34ad5b73025..cbf5ad2c7ec 100644 --- a/src/plugins/install-source-info.test.ts +++ b/src/plugins/install-source-info.test.ts @@ -202,4 +202,28 @@ describe("describePluginInstallSource", () => { warnings: ["invalid-npm-spec", "npm-integrity-without-source"], }); }); + + it("warns when the npm spec package name drifts from catalog package identity", () => { + expect( + describePluginInstallSource( + { + npmSpec: "@vendor/other@1.2.3", + expectedIntegrity: "sha512-demo", + }, + { expectedPackageName: "@vendor/demo" }, + ), + ).toEqual({ + npm: { + spec: "@vendor/other@1.2.3", + packageName: "@vendor/other", + expectedPackageName: "@vendor/demo", + selector: "1.2.3", + selectorKind: "exact-version", + exactVersion: true, + expectedIntegrity: "sha512-demo", + pinState: "exact-with-integrity", + }, + warnings: ["npm-spec-package-name-mismatch"], + }); + }); }); diff --git a/src/plugins/install-source-info.ts b/src/plugins/install-source-info.ts index be5cb913965..c81cc61c62d 100644 --- a/src/plugins/install-source-info.ts +++ b/src/plugins/install-source-info.ts @@ -8,7 +8,8 @@ export type PluginInstallSourceWarning = | "default-choice-missing-source" | "npm-integrity-without-source" | "npm-spec-floating" - | "npm-spec-missing-integrity"; + | "npm-spec-missing-integrity" + | "npm-spec-package-name-mismatch"; export type PluginInstallNpmPinState = | "exact-with-integrity" @@ -19,6 +20,7 @@ export type PluginInstallNpmPinState = export type PluginInstallNpmSourceInfo = { spec: string; packageName: string; + expectedPackageName?: string; selector?: string; selectorKind: ParsedRegistryNpmSpec["selectorKind"]; exactVersion: boolean; @@ -37,6 +39,10 @@ export type PluginInstallSourceInfo = { warnings: readonly PluginInstallSourceWarning[]; }; +export type DescribePluginInstallSourceOptions = { + expectedPackageName?: string | null; +}; + function resolveNpmPinState(params: { exactVersion: boolean; hasIntegrity: boolean; @@ -51,13 +57,23 @@ function resolveDefaultChoice(value: unknown): PluginPackageInstall["defaultChoi return value === "npm" || value === "local" ? value : undefined; } +function normalizeExpectedPackageName(value: string | null | undefined): string | undefined { + const expected = normalizeOptionalString(value); + if (!expected) { + return undefined; + } + return parseRegistryNpmSpec(expected)?.name ?? expected; +} + export function describePluginInstallSource( install: PluginPackageInstall, + options?: DescribePluginInstallSourceOptions, ): PluginInstallSourceInfo { const npmSpec = normalizeOptionalString(install.npmSpec); const localPath = normalizeOptionalString(install.localPath); const defaultChoice = resolveDefaultChoice(install.defaultChoice); const expectedIntegrity = normalizeOptionalString(install.expectedIntegrity); + const expectedPackageName = normalizeExpectedPackageName(options?.expectedPackageName); const warnings: PluginInstallSourceWarning[] = []; let npm: PluginInstallNpmSourceInfo | undefined; @@ -76,9 +92,15 @@ export function describePluginInstallSource( if (!hasIntegrity) { warnings.push("npm-spec-missing-integrity"); } + if (expectedPackageName && parsed.name !== expectedPackageName) { + warnings.push("npm-spec-package-name-mismatch"); + } npm = { spec: parsed.raw, packageName: parsed.name, + ...(expectedPackageName && parsed.name !== expectedPackageName + ? { expectedPackageName } + : {}), selectorKind: parsed.selectorKind, exactVersion, pinState: resolveNpmPinState({ exactVersion, hasIntegrity }), diff --git a/src/plugins/provider-install-catalog.test.ts b/src/plugins/provider-install-catalog.test.ts index e493d84229f..a2619a10908 100644 --- a/src/plugins/provider-install-catalog.test.ts +++ b/src/plugins/provider-install-catalog.test.ts @@ -320,6 +320,62 @@ describe("provider install catalog", () => { }); }); + it("warns when provider install npmSpec drifts from package identity", () => { + discoverOpenClawPlugins.mockReturnValue({ + candidates: [ + { + idHint: "vllm", + origin: "config", + rootDir: "/Users/test/.openclaw/extensions/vllm", + source: "/Users/test/.openclaw/extensions/vllm/index.js", + packageName: "@openclaw/vllm", + packageDir: "/Users/test/.openclaw/extensions/vllm", + packageManifest: { + install: { + npmSpec: "@openclaw/vllm-fork@2.0.0", + expectedIntegrity: "sha512-vllm", + }, + }, + }, + ], + diagnostics: [], + }); + loadPluginManifest.mockReturnValue({ + ok: true, + manifestPath: "/Users/test/.openclaw/extensions/vllm/openclaw.plugin.json", + manifest: { + id: "vllm", + configSchema: { + type: "object", + }, + }, + }); + resolveManifestProviderAuthChoices.mockReturnValue([ + { + pluginId: "vllm", + providerId: "vllm", + methodId: "server", + choiceId: "vllm", + choiceLabel: "vLLM", + }, + ]); + + expect(resolveProviderInstallCatalogEntry("vllm")?.installSource).toEqual({ + defaultChoice: "npm", + npm: { + spec: "@openclaw/vllm-fork@2.0.0", + packageName: "@openclaw/vllm-fork", + expectedPackageName: "@openclaw/vllm", + selector: "2.0.0", + selectorKind: "exact-version", + exactVersion: true, + expectedIntegrity: "sha512-vllm", + pinState: "exact-with-integrity", + }, + warnings: ["npm-spec-package-name-mismatch"], + }); + }); + it("does not expose npm install specs from untrusted package metadata", () => { discoverOpenClawPlugins.mockReturnValue({ candidates: [ diff --git a/src/plugins/provider-install-catalog.ts b/src/plugins/provider-install-catalog.ts index 5e1759fc678..002d12c01cf 100644 --- a/src/plugins/provider-install-catalog.ts +++ b/src/plugins/provider-install-catalog.ts @@ -34,6 +34,7 @@ type ProviderInstallCatalogParams = { type PreferredInstallSource = { origin: PluginOrigin; install: PluginPackageInstall; + packageName?: string; }; const INSTALL_ORIGIN_PRIORITY: Readonly> = { @@ -162,6 +163,7 @@ function resolvePreferredInstallsByPluginId( preferredByPluginId.set(manifest.manifest.id, { origin: candidate.origin, install, + ...(candidate.packageName ? { packageName: candidate.packageName } : {}), }); } } @@ -184,7 +186,9 @@ export function resolveProviderInstallCatalogEntries( label: choice.groupLabel ?? choice.choiceLabel, origin: install.origin, install: install.install, - installSource: describePluginInstallSource(install.install), + installSource: describePluginInstallSource(install.install, { + expectedPackageName: install.packageName, + }), } satisfies ProviderInstallCatalogEntry, ]; }) diff --git a/test/helpers/channels/channel-catalog-contract.ts b/test/helpers/channels/channel-catalog-contract.ts index 0221cf4f707..fce81fc4cc2 100644 --- a/test/helpers/channels/channel-catalog-contract.ts +++ b/test/helpers/channels/channel-catalog-contract.ts @@ -209,5 +209,43 @@ export function describeOfficialFallbackChannelCatalogContract(params: { expect(entry?.meta.label).toBe(params.externalLabel); expect(entry?.pluginId).toBeUndefined(); }); + + it("surfaces package-name drift in external channel catalog install metadata", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-drifted-catalog-")); + const catalogPath = path.join(dir, "catalog.json"); + fs.writeFileSync( + catalogPath, + JSON.stringify({ + entries: [ + { + name: params.packageName, + openclaw: { + channel: params.meta, + install: { + npmSpec: `${params.packageName}-fork@1.2.3`, + expectedIntegrity: "sha512-drifted", + }, + }, + }, + ], + }), + "utf8", + ); + + const entry = listChannelPluginCatalogEntries({ + catalogPaths: [catalogPath], + officialCatalogPaths: [], + env: { + ...process.env, + OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins", + }, + }).find((item) => item.id === params.channelId); + + expect(entry?.installSource?.npm).toMatchObject({ + packageName: `${params.packageName}-fork`, + expectedPackageName: params.packageName, + }); + expect(entry?.installSource?.warnings).toEqual(["npm-spec-package-name-mismatch"]); + }); }); }