mirror of
https://fastgit.cc/github.com/openclaw/openclaw
synced 2026-05-01 06:36:23 +08:00
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
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
134
src/plugins/install-source-info.test.ts
Normal file
134
src/plugins/install-source-info.test.ts
Normal file
@@ -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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
91
src/plugins/install-source-info.ts
Normal file
91
src/plugins/install-source-info.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
})
|
||||
|
||||
@@ -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: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user