fix(plugins): warn on install source package drift

Warn when provider or channel catalog package identity drifts from openclaw.install.npmSpec while keeping compatible catalogs non-fatal.
This commit is contained in:
Vincent Koc
2026-04-24 09:31:40 -07:00
committed by GitHub
parent 90877e0d42
commit 4d1ee3a73e
9 changed files with 162 additions and 12 deletions

View File

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

View File

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

View File

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

View File

@@ -269,7 +269,9 @@ function buildCatalogEntryFromManifest(params: {
...(params.origin ? { origin: params.origin } : {}),
meta,
install,
installSource: describePluginInstallSource(install),
installSource: describePluginInstallSource(install, {
expectedPackageName: params.packageName,
}),
};
}

View File

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

View File

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

View File

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

View File

@@ -34,6 +34,7 @@ type ProviderInstallCatalogParams = {
type PreferredInstallSource = {
origin: PluginOrigin;
install: PluginPackageInstall;
packageName?: string;
};
const INSTALL_ORIGIN_PRIORITY: Readonly<Record<PluginOrigin, number>> = {
@@ -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,
];
})

View File

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