Skip to content

Commit 2a22eb6

Browse files
authored
fix(plugins): require provenance for official npm trust
Require OpenClaw-owned install provenance before granting official npm plugin scanner trust. Direct npm package names now scan normally; catalog, onboarding, and doctor paths pass explicit provenance.\n\nValidation:\n- pnpm test:serial src/plugins/install.npm-spec.test.ts src/cli/plugins-cli.install.test.ts src/commands/onboarding-plugin-install.test.ts src/commands/doctor/shared/missing-configured-plugin-install.test.ts src/channels/plugins/contracts/channel-catalog.contract.test.ts src/commands/auth-choice.apply.plugin-provider.test.ts\n- pnpm test:serial src/plugins/install.test.ts src/plugins/provider-auth-choices.test.ts src/plugins/provider-install-catalog.test.ts src/commands/channel-setup/plugin-install.test.ts\n- pnpm exec oxfmt --check --threads=1 ...\n- node scripts/run-oxlint.mjs ...\n- Crabbox cbx_6157440c9bbe / run_cbd813956eed: pnpm check:changed passed\n\nThanks @fede-kamel and @vincentkoc.
1 parent f249b1c commit 2a22eb6

13 files changed

Lines changed: 122 additions & 39 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai
2929
- Channels/WhatsApp: attach native outbound mention metadata for group text and media captions by resolving `@+<digits>` and `@<digits>` tokens against WhatsApp participant data, including LID groups. Fixes #39879; carries forward #56863. Thanks @kengi1437, @joe2643, and @fridayck.
3030
- Plugins/uninstall: remove empty managed git install parent directories after deleting cloned plugin repos and cover npm/git uninstall residue in Docker plugin lifecycle tests. Thanks @vincentkoc.
3131
- Plugins/install: resolve bare official external plugin IDs such as `brave` through the official catalog when no bundled source is available, so packaged installs fetch the intended scoped npm package instead of an unrelated unscoped package. Fixes #76373. Thanks @bek91 and @vincentkoc.
32+
- Plugins/install: require OpenClaw-owned install provenance before granting official npm plugin scanner trust, so direct npm package names no longer bypass launch-code scanning while catalog, onboarding, and doctor installs stay trusted. Thanks @fede-kamel and @vincentkoc.
3233
- Gateway/sessions: keep async `sessions.list` title and preview hydration bounded to transcript head/tail reads so Control UI polling cannot full-scan large session transcripts every refresh. Thanks @vincentkoc.
3334
- Gateway/sessions: keep agent runtime metadata on lightweight `sessions.list` rows so model-only session patches do not make Control UI lose runtime identity. Thanks @vincentkoc.
3435
- Gateway/sessions: keep bulk `sessions.list` rows lightweight by skipping per-row transcript usage fallback, display model inference, and plugin projection, avoiding event-loop stalls in large session stores. Thanks @Marvinthebored and @vincentkoc.

src/channels/plugins/catalog.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export type ChannelPluginCatalogEntry = {
3939
id: string;
4040
pluginId?: string;
4141
origin?: PluginOrigin;
42+
trustedSourceLinkedOfficialInstall?: boolean;
4243
meta: ChannelMeta;
4344
install: ChannelPluginCatalogInstall;
4445
installSource?: PluginInstallSourceInfo;
@@ -203,7 +204,7 @@ function loadOfficialCatalogEntries(options: CatalogOptions): ChannelPluginCatal
203204
? loadCatalogEntriesFromPaths(officialPaths)
204205
: loadOfficialCatalogEntriesFromPaths(officialPaths);
205206
return [...builtInEntries, ...fileEntries]
206-
.map((entry) => buildExternalCatalogEntry(entry))
207+
.map((entry) => buildExternalCatalogEntry(entry, { trustedSourceLinkedOfficialInstall: true }))
207208
.filter((entry): entry is ChannelPluginCatalogEntry => Boolean(entry));
208209
}
209210

@@ -297,6 +298,7 @@ function buildCatalogEntryFromManifest(params: {
297298
packageName?: string;
298299
packageDir?: string;
299300
origin?: PluginOrigin;
301+
trustedSourceLinkedOfficialInstall?: boolean;
300302
workspaceDir?: string;
301303
channel?: PluginPackageChannel;
302304
install?: PluginPackageInstall;
@@ -326,6 +328,9 @@ function buildCatalogEntryFromManifest(params: {
326328
id,
327329
...(pluginId ? { pluginId } : {}),
328330
...(params.origin ? { origin: params.origin } : {}),
331+
...(params.trustedSourceLinkedOfficialInstall
332+
? { trustedSourceLinkedOfficialInstall: true }
333+
: {}),
329334
meta,
330335
install,
331336
installSource: describePluginInstallSource(install, {
@@ -334,10 +339,16 @@ function buildCatalogEntryFromManifest(params: {
334339
};
335340
}
336341

337-
function buildExternalCatalogEntry(entry: ExternalCatalogEntry): ChannelPluginCatalogEntry | null {
342+
function buildExternalCatalogEntry(
343+
entry: ExternalCatalogEntry,
344+
options?: {
345+
trustedSourceLinkedOfficialInstall?: boolean;
346+
},
347+
): ChannelPluginCatalogEntry | null {
338348
const manifest = entry[MANIFEST_KEY];
339349
return buildCatalogEntryFromManifest({
340350
packageName: entry.name,
351+
trustedSourceLinkedOfficialInstall: options?.trustedSourceLinkedOfficialInstall,
341352
channel: manifest?.channel,
342353
install: manifest?.install,
343354
});

src/channels/plugins/contracts/test-helpers/channel-catalog-contract.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ export function describeOfficialFallbackChannelCatalogContract(params: {
141141

142142
expect(entry?.install.npmSpec).toBe(params.npmSpec);
143143
expect(entry?.pluginId).toBeUndefined();
144+
expect(entry?.trustedSourceLinkedOfficialInstall).toBe(true);
144145
});
145146

146147
it("lets external catalogs override shipped fallback channel metadata", () => {
@@ -217,6 +218,7 @@ export function describeOfficialFallbackChannelCatalogContract(params: {
217218
expect(entry?.install.npmSpec).toBe(params.externalNpmSpec);
218219
expect(entry?.meta.label).toBe(params.externalLabel);
219220
expect(entry?.pluginId).toBeUndefined();
221+
expect(entry?.trustedSourceLinkedOfficialInstall).toBeUndefined();
220222
});
221223

222224
it("surfaces package-name drift in external channel catalog install metadata", () => {

src/cli/plugins-cli.install.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -675,6 +675,7 @@ describe("plugins cli install", () => {
675675
expect.objectContaining({
676676
spec: "@openclaw/brave-plugin",
677677
expectedPluginId: "brave",
678+
trustedSourceLinkedOfficialInstall: true,
678679
}),
679680
);
680681
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({
@@ -708,6 +709,7 @@ describe("plugins cli install", () => {
708709
expectedPluginId: "wecom",
709710
expectedIntegrity:
710711
"sha512-bnzfdIEEu1/LFvcdyjaTkyxt27w6c7dqhkPezU62OWaqmcdFsUGR3T55USK/O9pIKsNcnL1Tnu1pqKYCWHFgWQ==",
712+
trustedSourceLinkedOfficialInstall: true,
711713
}),
712714
);
713715
});
@@ -728,6 +730,11 @@ describe("plugins cli install", () => {
728730

729731
await expect(runPluginsCommand(["plugins", "install", "wecom"])).rejects.toThrow("__exit__:1");
730732

733+
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
734+
expect.objectContaining({
735+
trustedSourceLinkedOfficialInstall: true,
736+
}),
737+
);
731738
expect(installHooksFromNpmSpec).toHaveBeenCalledWith(
732739
expect.objectContaining({
733740
spec: "@wecom/wecom-openclaw-plugin@2026.4.23",
@@ -845,6 +852,11 @@ describe("plugins cli install", () => {
845852
expectedPluginId: "brave",
846853
}),
847854
);
855+
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
856+
expect.not.objectContaining({
857+
trustedSourceLinkedOfficialInstall: true,
858+
}),
859+
);
848860
expect(installPluginFromClawHub).not.toHaveBeenCalled();
849861
});
850862

src/cli/plugins-install-command.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,7 @@ async function tryInstallPluginOrHookPackFromNpmSpec(params: {
279279
extensionsDir: string;
280280
expectedPluginId?: string;
281281
expectedIntegrity?: string;
282+
trustedSourceLinkedOfficialInstall?: boolean;
282283
runtime?: RuntimeEnv;
283284
}): Promise<{ ok: true } | { ok: false }> {
284285
const result = await installPluginFromNpmSpec({
@@ -287,6 +288,9 @@ async function tryInstallPluginOrHookPackFromNpmSpec(params: {
287288
spec: params.spec,
288289
...(params.expectedPluginId ? { expectedPluginId: params.expectedPluginId } : {}),
289290
...(params.expectedIntegrity ? { expectedIntegrity: params.expectedIntegrity } : {}),
291+
...(params.trustedSourceLinkedOfficialInstall
292+
? { trustedSourceLinkedOfficialInstall: true }
293+
: {}),
290294
extensionsDir: params.extensionsDir,
291295
logger: createPluginInstallLogger(params.runtime),
292296
});
@@ -787,6 +791,7 @@ export async function runPluginInstallCommand(params: {
787791
extensionsDir,
788792
expectedPluginId: officialExternalPlan.pluginId,
789793
expectedIntegrity: officialExternalPlan.expectedIntegrity,
794+
trustedSourceLinkedOfficialInstall: true,
790795
runtime,
791796
});
792797
if (!npmResult.ok) {

src/commands/channel-setup/plugin-install.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ function toOnboardingPluginInstallEntry(
3333
pluginId: entry.pluginId ?? entry.id,
3434
label: entry.meta.label,
3535
install: entry.install,
36+
...(entry.trustedSourceLinkedOfficialInstall
37+
? { trustedSourceLinkedOfficialInstall: true }
38+
: {}),
3639
};
3740
}
3841

src/commands/doctor/shared/missing-configured-plugin-install.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
125125
npmSpec: "@openclaw/plugin-matrix@1.2.3",
126126
expectedIntegrity: "sha512-test",
127127
},
128+
trustedSourceLinkedOfficialInstall: true,
128129
},
129130
]);
130131

@@ -146,6 +147,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
146147
extensionsDir: "/tmp/openclaw-plugins",
147148
expectedPluginId: "matrix",
148149
expectedIntegrity: "sha512-test",
150+
trustedSourceLinkedOfficialInstall: true,
149151
}),
150152
);
151153
expect(mocks.writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(
@@ -224,6 +226,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
224226
install: {
225227
npmSpec: "@openclaw/plugin-matrix@1.2.3",
226228
},
229+
trustedSourceLinkedOfficialInstall: true,
227230
},
228231
]);
229232

@@ -240,6 +243,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
240243
spec: "@openclaw/plugin-matrix@1.2.3",
241244
extensionsDir: "/tmp/openclaw-plugins",
242245
expectedPluginId: "matrix",
246+
trustedSourceLinkedOfficialInstall: true,
243247
}),
244248
);
245249
expect(mocks.writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(
@@ -273,6 +277,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
273277
clawhubSpec: "clawhub:@openclaw/plugin-matrix@stable",
274278
npmSpec: "@openclaw/plugin-matrix@1.2.3",
275279
},
280+
trustedSourceLinkedOfficialInstall: true,
276281
},
277282
]);
278283

@@ -289,6 +294,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
289294
expect.objectContaining({
290295
spec: "@openclaw/plugin-matrix@1.2.3",
291296
expectedPluginId: "matrix",
297+
trustedSourceLinkedOfficialInstall: true,
292298
}),
293299
);
294300
expect(result.changes).toEqual([
@@ -321,6 +327,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
321327
npmSpec: "@openclaw/twitch",
322328
defaultChoice: "npm",
323329
},
330+
trustedSourceLinkedOfficialInstall: true,
324331
},
325332
]);
326333

@@ -338,6 +345,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
338345
expect.objectContaining({
339346
spec: "@openclaw/twitch",
340347
expectedPluginId: "twitch",
348+
trustedSourceLinkedOfficialInstall: true,
341349
}),
342350
);
343351
expect(result.changes).toEqual([
@@ -813,6 +821,11 @@ describe("repairMissingConfiguredPluginInstalls", () => {
813821
expectedPluginId: "wecom",
814822
}),
815823
);
824+
expect(mocks.installPluginFromNpmSpec).toHaveBeenCalledWith(
825+
expect.not.objectContaining({
826+
trustedSourceLinkedOfficialInstall: true,
827+
}),
828+
);
816829
expect(result.changes).toEqual([
817830
'Installed missing configured plugin "wecom" from @wecom/wecom-openclaw-plugin@2026.4.23.',
818831
]);
@@ -863,6 +876,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
863876
expect.objectContaining({
864877
spec: "@openclaw/codex",
865878
expectedPluginId: "codex",
879+
trustedSourceLinkedOfficialInstall: true,
866880
}),
867881
);
868882
expect(mocks.writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(
@@ -940,6 +954,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
940954
expect.objectContaining({
941955
spec: "@openclaw/codex",
942956
expectedPluginId: "codex",
957+
trustedSourceLinkedOfficialInstall: true,
943958
}),
944959
);
945960
expect(mocks.writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(
@@ -1073,6 +1088,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
10731088
install: {
10741089
npmSpec: "@openclaw/discord",
10751090
},
1091+
trustedSourceLinkedOfficialInstall: true,
10761092
},
10771093
]);
10781094
mocks.installPluginFromNpmSpec.mockResolvedValueOnce({
@@ -1132,6 +1148,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
11321148
expect.objectContaining({
11331149
spec: "@openclaw/discord",
11341150
expectedPluginId: "discord",
1151+
trustedSourceLinkedOfficialInstall: true,
11351152
}),
11361153
);
11371154
expect(mocks.writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(
@@ -1476,6 +1493,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
14761493
expect.objectContaining({
14771494
spec: "@openclaw/brave-plugin",
14781495
expectedPluginId: "brave",
1496+
trustedSourceLinkedOfficialInstall: true,
14791497
}),
14801498
);
14811499
expect(result.changes).toEqual([

src/commands/doctor/shared/missing-configured-plugin-install.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ type DownloadableInstallCandidate = {
3636
npmSpec?: string;
3737
clawhubSpec?: string;
3838
expectedIntegrity?: string;
39+
trustedSourceLinkedOfficialInstall?: boolean;
3940
defaultChoice?: PluginPackageInstall["defaultChoice"];
4041
};
4142

@@ -44,12 +45,14 @@ const RUNTIME_PLUGIN_INSTALL_CANDIDATES: readonly DownloadableInstallCandidate[]
4445
pluginId: "acpx",
4546
label: "ACPX Runtime",
4647
npmSpec: "@openclaw/acpx",
48+
trustedSourceLinkedOfficialInstall: true,
4749
},
4850
// Runtime-only configs do not have a provider/channel integration catalog entry.
4951
{
5052
pluginId: "codex",
5153
label: "Codex",
5254
npmSpec: "@openclaw/codex",
55+
trustedSourceLinkedOfficialInstall: true,
5356
},
5457
];
5558

@@ -201,6 +204,9 @@ function collectDownloadableInstallCandidates(params: {
201204
...(entry.install.expectedIntegrity
202205
? { expectedIntegrity: entry.install.expectedIntegrity }
203206
: {}),
207+
...(entry.trustedSourceLinkedOfficialInstall
208+
? { trustedSourceLinkedOfficialInstall: true }
209+
: {}),
204210
...(entry.install.defaultChoice ? { defaultChoice: entry.install.defaultChoice } : {}),
205211
});
206212
}
@@ -229,6 +235,7 @@ function collectDownloadableInstallCandidates(params: {
229235
...(entry.install.expectedIntegrity
230236
? { expectedIntegrity: entry.install.expectedIntegrity }
231237
: {}),
238+
...(entry.origin === "bundled" ? { trustedSourceLinkedOfficialInstall: true } : {}),
232239
...(entry.install.defaultChoice ? { defaultChoice: entry.install.defaultChoice } : {}),
233240
});
234241
}
@@ -256,6 +263,7 @@ function collectDownloadableInstallCandidates(params: {
256263
...(npmSpec ? { npmSpec } : {}),
257264
...(clawhubSpec ? { clawhubSpec } : {}),
258265
...(install.expectedIntegrity ? { expectedIntegrity: install.expectedIntegrity } : {}),
266+
trustedSourceLinkedOfficialInstall: true,
259267
...(install.defaultChoice ? { defaultChoice: install.defaultChoice } : {}),
260268
});
261269
}
@@ -419,6 +427,9 @@ async function installCandidate(params: {
419427
extensionsDir,
420428
expectedPluginId: candidate.pluginId,
421429
expectedIntegrity: candidate.expectedIntegrity,
430+
...(candidate.trustedSourceLinkedOfficialInstall
431+
? { trustedSourceLinkedOfficialInstall: true }
432+
: {}),
422433
mode: "install",
423434
});
424435
if (!result.ok) {

src/commands/onboarding-plugin-install.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ describe("ensureOnboardingPluginInstalled", () => {
200200
npmSpec: "@wecom/wecom-openclaw-plugin@1.2.3",
201201
expectedIntegrity: "sha512-wecom",
202202
},
203+
trustedSourceLinkedOfficialInstall: true,
203204
},
204205
prompter: {
205206
select: vi.fn(async () => "npm"),
@@ -211,7 +212,9 @@ describe("ensureOnboardingPluginInstalled", () => {
211212
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
212213
expect.objectContaining({
213214
spec: "@wecom/wecom-openclaw-plugin@1.2.3",
215+
expectedPluginId: "demo-plugin",
214216
expectedIntegrity: "sha512-wecom",
217+
trustedSourceLinkedOfficialInstall: true,
215218
timeoutMs: 300_000,
216219
}),
217220
);

src/commands/onboarding-plugin-install.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export type OnboardingPluginInstallEntry = {
3030
pluginId: string;
3131
label: string;
3232
install: PluginPackageInstall;
33+
trustedSourceLinkedOfficialInstall?: boolean;
3334
};
3435

3536
export type OnboardingPluginInstallStatus = "installed" | "skipped" | "failed" | "timed_out";
@@ -585,7 +586,11 @@ async function installPluginFromNpmSpecWithProgress(params: {
585586
installPluginFromNpmSpec({
586587
spec: params.npmSpec,
587588
timeoutMs: ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS,
589+
expectedPluginId: params.entry.pluginId,
588590
expectedIntegrity: params.entry.install.expectedIntegrity,
591+
...(params.entry.trustedSourceLinkedOfficialInstall
592+
? { trustedSourceLinkedOfficialInstall: true }
593+
: {}),
589594
extensionsDir: resolveDefaultPluginExtensionsDir(),
590595
logger: {
591596
info: updateProgress,

0 commit comments

Comments
 (0)