Skip to content

Commit 37c37ee

Browse files
authored
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
1 parent b588b5a commit 37c37ee

8 files changed

Lines changed: 317 additions & 4 deletions

File tree

docs/plugins/architecture-internals.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -884,6 +884,20 @@ Or point `OPENCLAW_PLUGIN_CATALOG_PATHS` (or `OPENCLAW_MPM_CATALOG_PATHS`) at
884884
one or more JSON files (comma/semicolon/`PATH`-delimited). Each file should
885885
contain `{ "entries": [ { "name": "@scope/pkg", "openclaw": { "channel": {...}, "install": {...} } } ] }`. The parser also accepts `"packages"` or `"plugins"` as legacy aliases for the `"entries"` key.
886886

887+
Generated channel catalog entries and provider install catalog entries expose
888+
normalized install-source facts next to the raw `openclaw.install` block. The
889+
normalized facts identify whether the npm spec is an exact version or floating
890+
selector, whether expected integrity metadata is present, and whether a local
891+
source path is also available. Consumers should treat `installSource` as an
892+
additive optional field so older hand-built entries and compatibility shims do
893+
not have to synthesize it. This lets onboarding and diagnostics explain
894+
source-plane state without importing plugin runtime.
895+
896+
Official external npm entries should prefer an exact `npmSpec` plus
897+
`expectedIntegrity`. Bare package names and dist-tags still work for
898+
compatibility, but they surface source-plane warnings so the catalog can move
899+
toward pinned, integrity-checked installs without breaking existing plugins.
900+
887901
## Context engine plugins
888902

889903
Context engine plugins own session context orchestration for ingest, assembly,

docs/plugins/manifest.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -591,10 +591,12 @@ registry loading. Invalid values are rejected; newer-but-valid values skip the
591591
plugin on older hosts.
592592

593593
Exact npm version pinning already lives in `npmSpec`, for example
594-
`"npmSpec": "@wecom/wecom-openclaw-plugin@1.2.3"`. Pair that with
595-
`expectedIntegrity` when you want update flows to fail closed if the fetched
596-
npm artifact no longer matches the pinned release. Interactive onboarding
597-
offers trusted registry npm specs, including bare package names and dist-tags.
594+
`"npmSpec": "@wecom/wecom-openclaw-plugin@1.2.3"`. Official external catalog
595+
entries should pair exact specs with `expectedIntegrity` so update flows fail
596+
closed if the fetched npm artifact no longer matches the pinned release.
597+
Interactive onboarding still offers trusted registry npm specs, including bare
598+
package names and dist-tags, for compatibility. Catalog diagnostics can
599+
distinguish exact, floating, integrity-pinned, and missing-integrity sources.
598600
When `expectedIntegrity` is present, install/update flows enforce it; when it
599601
is omitted, the registry resolution is recorded without an integrity pin.
600602

src/channels/plugins/catalog.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import officialExternalChannelCatalog from "../../../scripts/lib/official-extern
44
import { MANIFEST_KEY } from "../../compat/legacy-names.js";
55
import { resolveOpenClawPackageRootSync } from "../../infra/openclaw-root.js";
66
import { listChannelCatalogEntries } from "../../plugins/channel-catalog-registry.js";
7+
import {
8+
describePluginInstallSource,
9+
type PluginInstallSourceInfo,
10+
} from "../../plugins/install-source-info.js";
711
import type { OpenClawPackageManifest } from "../../plugins/manifest.js";
812
import type { PluginPackageChannel, PluginPackageInstall } from "../../plugins/manifest.js";
913
import type { PluginOrigin } from "../../plugins/plugin-origin.types.js";
@@ -36,6 +40,7 @@ export type ChannelPluginCatalogEntry = {
3640
install: PluginPackageInstall & {
3741
npmSpec: string;
3842
};
43+
installSource?: PluginInstallSourceInfo;
3944
};
4045

4146
type CatalogOptions = {
@@ -264,6 +269,7 @@ function buildCatalogEntryFromManifest(params: {
264269
...(params.origin ? { origin: params.origin } : {}),
265270
meta,
266271
install,
272+
installSource: describePluginInstallSource(install),
267273
};
268274
}
269275

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { describe, expect, it } from "vitest";
2+
import { describePluginInstallSource } from "./install-source-info.js";
3+
4+
describe("describePluginInstallSource", () => {
5+
it("marks exact npm specs with integrity as fully pinned", () => {
6+
expect(
7+
describePluginInstallSource({
8+
npmSpec: "@vendor/demo@1.2.3",
9+
expectedIntegrity: " sha512-demo ",
10+
defaultChoice: "npm",
11+
}),
12+
).toEqual({
13+
defaultChoice: "npm",
14+
npm: {
15+
spec: "@vendor/demo@1.2.3",
16+
packageName: "@vendor/demo",
17+
selector: "1.2.3",
18+
selectorKind: "exact-version",
19+
exactVersion: true,
20+
expectedIntegrity: "sha512-demo",
21+
pinState: "exact-with-integrity",
22+
},
23+
warnings: [],
24+
});
25+
});
26+
27+
it("marks exact npm specs without integrity as version-pinned only", () => {
28+
expect(
29+
describePluginInstallSource({
30+
npmSpec: "@vendor/demo@1.2.3",
31+
}),
32+
).toEqual({
33+
npm: {
34+
spec: "@vendor/demo@1.2.3",
35+
packageName: "@vendor/demo",
36+
selector: "1.2.3",
37+
selectorKind: "exact-version",
38+
exactVersion: true,
39+
pinState: "exact-without-integrity",
40+
},
41+
warnings: ["npm-spec-missing-integrity"],
42+
});
43+
});
44+
45+
it("omits whitespace-only integrity from npm source facts", () => {
46+
expect(
47+
describePluginInstallSource({
48+
npmSpec: "@vendor/demo@1.2.3",
49+
expectedIntegrity: " ",
50+
}),
51+
).toEqual({
52+
npm: {
53+
spec: "@vendor/demo@1.2.3",
54+
packageName: "@vendor/demo",
55+
selector: "1.2.3",
56+
selectorKind: "exact-version",
57+
exactVersion: true,
58+
pinState: "exact-without-integrity",
59+
},
60+
warnings: ["npm-spec-missing-integrity"],
61+
});
62+
});
63+
64+
it("treats non-string integrity metadata as missing", () => {
65+
expect(
66+
describePluginInstallSource({
67+
npmSpec: "@vendor/demo@1.2.3",
68+
expectedIntegrity: 123,
69+
} as never),
70+
).toEqual({
71+
npm: {
72+
spec: "@vendor/demo@1.2.3",
73+
packageName: "@vendor/demo",
74+
selector: "1.2.3",
75+
selectorKind: "exact-version",
76+
exactVersion: true,
77+
pinState: "exact-without-integrity",
78+
},
79+
warnings: ["npm-spec-missing-integrity"],
80+
});
81+
});
82+
83+
it("surfaces floating specs with integrity without rejecting them", () => {
84+
expect(
85+
describePluginInstallSource({
86+
npmSpec: "@vendor/demo@beta",
87+
expectedIntegrity: "sha512-demo",
88+
}),
89+
).toEqual({
90+
npm: {
91+
spec: "@vendor/demo@beta",
92+
packageName: "@vendor/demo",
93+
selector: "beta",
94+
selectorKind: "tag",
95+
exactVersion: false,
96+
expectedIntegrity: "sha512-demo",
97+
pinState: "floating-with-integrity",
98+
},
99+
warnings: ["npm-spec-floating"],
100+
});
101+
});
102+
103+
it("surfaces floating specs without integrity without rejecting them", () => {
104+
expect(
105+
describePluginInstallSource({
106+
npmSpec: "@vendor/demo@beta",
107+
}),
108+
).toEqual({
109+
npm: {
110+
spec: "@vendor/demo@beta",
111+
packageName: "@vendor/demo",
112+
selector: "beta",
113+
selectorKind: "tag",
114+
exactVersion: false,
115+
pinState: "floating-without-integrity",
116+
},
117+
warnings: ["npm-spec-floating", "npm-spec-missing-integrity"],
118+
});
119+
});
120+
121+
it("reports invalid npm specs while preserving local source metadata", () => {
122+
expect(
123+
describePluginInstallSource({
124+
npmSpec: "github:vendor/demo",
125+
localPath: "extensions/demo",
126+
}),
127+
).toEqual({
128+
local: {
129+
path: "extensions/demo",
130+
},
131+
warnings: ["invalid-npm-spec"],
132+
});
133+
});
134+
});

src/plugins/install-source-info.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { parseRegistryNpmSpec, type ParsedRegistryNpmSpec } from "../infra/npm-registry-spec.js";
2+
import { normalizeOptionalString } from "../shared/string-coerce.js";
3+
import type { PluginPackageInstall } from "./manifest.js";
4+
5+
export type PluginInstallSourceWarning =
6+
| "invalid-npm-spec"
7+
| "npm-spec-floating"
8+
| "npm-spec-missing-integrity";
9+
10+
export type PluginInstallNpmPinState =
11+
| "exact-with-integrity"
12+
| "exact-without-integrity"
13+
| "floating-with-integrity"
14+
| "floating-without-integrity";
15+
16+
export type PluginInstallNpmSourceInfo = {
17+
spec: string;
18+
packageName: string;
19+
selector?: string;
20+
selectorKind: ParsedRegistryNpmSpec["selectorKind"];
21+
exactVersion: boolean;
22+
expectedIntegrity?: string;
23+
pinState: PluginInstallNpmPinState;
24+
};
25+
26+
export type PluginInstallLocalSourceInfo = {
27+
path: string;
28+
};
29+
30+
export type PluginInstallSourceInfo = {
31+
defaultChoice?: PluginPackageInstall["defaultChoice"];
32+
npm?: PluginInstallNpmSourceInfo;
33+
local?: PluginInstallLocalSourceInfo;
34+
warnings: readonly PluginInstallSourceWarning[];
35+
};
36+
37+
function resolveNpmPinState(params: {
38+
exactVersion: boolean;
39+
hasIntegrity: boolean;
40+
}): PluginInstallNpmPinState {
41+
if (params.exactVersion) {
42+
return params.hasIntegrity ? "exact-with-integrity" : "exact-without-integrity";
43+
}
44+
return params.hasIntegrity ? "floating-with-integrity" : "floating-without-integrity";
45+
}
46+
47+
export function describePluginInstallSource(
48+
install: PluginPackageInstall,
49+
): PluginInstallSourceInfo {
50+
const npmSpec = normalizeOptionalString(install.npmSpec);
51+
const localPath = normalizeOptionalString(install.localPath);
52+
const defaultChoice =
53+
install.defaultChoice === "npm" || install.defaultChoice === "local"
54+
? install.defaultChoice
55+
: undefined;
56+
const warnings: PluginInstallSourceWarning[] = [];
57+
let npm: PluginInstallNpmSourceInfo | undefined;
58+
59+
if (npmSpec) {
60+
const parsed = parseRegistryNpmSpec(npmSpec);
61+
if (parsed) {
62+
const exactVersion = parsed.selectorKind === "exact-version";
63+
const expectedIntegrity = normalizeOptionalString(install.expectedIntegrity);
64+
const hasIntegrity = Boolean(expectedIntegrity);
65+
if (!exactVersion) {
66+
warnings.push("npm-spec-floating");
67+
}
68+
if (!hasIntegrity) {
69+
warnings.push("npm-spec-missing-integrity");
70+
}
71+
npm = {
72+
spec: parsed.raw,
73+
packageName: parsed.name,
74+
selectorKind: parsed.selectorKind,
75+
exactVersion,
76+
pinState: resolveNpmPinState({ exactVersion, hasIntegrity }),
77+
...(parsed.selector ? { selector: parsed.selector } : {}),
78+
...(expectedIntegrity ? { expectedIntegrity } : {}),
79+
};
80+
} else {
81+
warnings.push("invalid-npm-spec");
82+
}
83+
}
84+
85+
return {
86+
...(defaultChoice ? { defaultChoice } : {}),
87+
...(npm ? { npm } : {}),
88+
...(localPath ? { local: { path: localPath } } : {}),
89+
warnings,
90+
};
91+
}

src/plugins/provider-install-catalog.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,22 @@ describe("provider install catalog", () => {
104104
defaultChoice: "npm",
105105
expectedIntegrity: "sha512-openai",
106106
},
107+
installSource: {
108+
defaultChoice: "npm",
109+
npm: {
110+
spec: "@openclaw/openai@1.2.3",
111+
packageName: "@openclaw/openai",
112+
selector: "1.2.3",
113+
selectorKind: "exact-version",
114+
exactVersion: true,
115+
expectedIntegrity: "sha512-openai",
116+
pinState: "exact-with-integrity",
117+
},
118+
local: {
119+
path: "extensions/openai",
120+
},
121+
warnings: [],
122+
},
107123
},
108124
]);
109125
});
@@ -157,6 +173,13 @@ describe("provider install catalog", () => {
157173
localPath: "extensions/demo-provider",
158174
defaultChoice: "local",
159175
},
176+
installSource: {
177+
defaultChoice: "local",
178+
local: {
179+
path: "extensions/demo-provider",
180+
},
181+
warnings: [],
182+
},
160183
},
161184
]);
162185
});
@@ -216,6 +239,19 @@ describe("provider install catalog", () => {
216239
expectedIntegrity: "sha512-vllm",
217240
defaultChoice: "npm",
218241
},
242+
installSource: {
243+
defaultChoice: "npm",
244+
npm: {
245+
spec: "@openclaw/vllm@2.0.0",
246+
packageName: "@openclaw/vllm",
247+
selector: "2.0.0",
248+
selectorKind: "exact-version",
249+
exactVersion: true,
250+
expectedIntegrity: "sha512-vllm",
251+
pinState: "exact-with-integrity",
252+
},
253+
warnings: [],
254+
},
219255
});
220256
});
221257

@@ -270,6 +306,17 @@ describe("provider install catalog", () => {
270306
npmSpec: "@openclaw/vllm",
271307
defaultChoice: "npm",
272308
},
309+
installSource: {
310+
defaultChoice: "npm",
311+
npm: {
312+
spec: "@openclaw/vllm",
313+
packageName: "@openclaw/vllm",
314+
selectorKind: "none",
315+
exactVersion: false,
316+
pinState: "floating-without-integrity",
317+
},
318+
warnings: ["npm-spec-floating", "npm-spec-missing-integrity"],
319+
},
273320
});
274321
});
275322

src/plugins/provider-install-catalog.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ import path from "node:path";
22
import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js";
33
import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js";
44
import { discoverOpenClawPlugins } from "./discovery.js";
5+
import {
6+
describePluginInstallSource,
7+
type PluginInstallSourceInfo,
8+
} from "./install-source-info.js";
59
import {
610
loadPluginManifest,
711
type PluginPackageInstall,
@@ -17,6 +21,7 @@ export type ProviderInstallCatalogEntry = ProviderAuthChoiceMetadata & {
1721
label: string;
1822
origin: PluginOrigin;
1923
install: PluginPackageInstall;
24+
installSource?: PluginInstallSourceInfo;
2025
};
2126

2227
type ProviderInstallCatalogParams = {
@@ -179,6 +184,7 @@ export function resolveProviderInstallCatalogEntries(
179184
label: choice.groupLabel ?? choice.choiceLabel,
180185
origin: install.origin,
181186
install: install.install,
187+
installSource: describePluginInstallSource(install.install),
182188
} satisfies ProviderInstallCatalogEntry,
183189
];
184190
})

0 commit comments

Comments
 (0)