mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:30:42 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -269,7 +269,9 @@ function buildCatalogEntryFromManifest(params: {
|
||||
...(params.origin ? { origin: params.origin } : {}),
|
||||
meta,
|
||||
install,
|
||||
installSource: describePluginInstallSource(install),
|
||||
installSource: describePluginInstallSource(install, {
|
||||
expectedPackageName: params.packageName,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
})
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user