diff --git a/src/cli/plugins-cli.marketplace-entries.test.ts b/src/cli/plugins-cli.marketplace-entries.test.ts index bd86c43c822e..cfa5530fb8a3 100644 --- a/src/cli/plugins-cli.marketplace-entries.test.ts +++ b/src/cli/plugins-cli.marketplace-entries.test.ts @@ -115,6 +115,13 @@ describe("plugins marketplace entries", () => { }, savedAt: "2026-06-23T01:02:03.000Z", }, + trust: { + mode: "signed", + signedBy: "acme-root-2026", + signatureCount: 1, + threshold: 1, + verifiedAt: "2026-06-23T01:02:03.000Z", + }, error: "hosted catalog feed offline mode", }); @@ -129,6 +136,13 @@ describe("plugins marketplace entries", () => { expect.objectContaining({ source: "hosted-snapshot", entryCount: 1, + trust: { + mode: "signed", + signedBy: "acme-root-2026", + signatureCount: 1, + threshold: 1, + verifiedAt: "2026-06-23T01:02:03.000Z", + }, entries: [ expect.objectContaining({ id: "acme-calendar", @@ -213,6 +227,53 @@ describe("plugins marketplace entries", () => { expect(mocks.defaultRuntime.exit).not.toHaveBeenCalled(); }); + it("prints bounded signed feed trust state in text output", async () => { + mocks.getRuntimeConfig.mockReturnValue({}); + mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue({ + source: "hosted-snapshot", + entries: [], + feed: { + schemaVersion: 1, + id: "acme-marketplace", + generatedAt: "2026-06-23T00:00:00.000Z", + sequence: 7, + entries: [], + }, + metadata: { + url: "https://packages.acme.example/openclaw/feed", + status: 200, + checksum: "feed-sha", + }, + snapshot: { + body: "{}", + metadata: { + url: "https://packages.acme.example/openclaw/feed", + status: 200, + checksum: "feed-sha", + }, + savedAt: "2026-06-23T01:02:03.000Z", + }, + trust: { + mode: "signed", + signedBy: "acme-root-2026", + signatureCount: 1, + threshold: 1, + verifiedAt: "2026-06-23T01:02:03.000Z", + }, + error: "hosted catalog feed offline mode", + }); + + const { runPluginMarketplaceEntriesCommand } = await import("./plugins-cli.runtime.js"); + await runPluginMarketplaceEntriesCommand({ offline: true }); + + const output = mocks.defaultRuntime.log.mock.calls.map(([value]) => String(value)).join("\n"); + expect(output).toContain("Trust:"); + expect(output).toContain("signed by acme-root-2026 (1/1)"); + expect(output).toContain("2026-06-23T01:02:03.000Z"); + expect(output).not.toContain("publicKey"); + expect(output).not.toContain("signature:"); + }); + it("emits bounded diagnostics for feed entry listing", async () => { const timelinePath = await createTimelinePath(); vi.stubEnv("OPENCLAW_DIAGNOSTICS", "1"); @@ -247,6 +308,13 @@ describe("plugins marketplace entries", () => { }, savedAt: "2026-06-23T01:02:03.000Z", }, + trust: { + mode: "signed", + signedBy: "acme-root-2026", + signatureCount: 1, + threshold: 1, + verifiedAt: "2026-06-23T01:02:03.000Z", + }, error: "hosted catalog feed offline mode", }); @@ -263,6 +331,10 @@ describe("plugins marketplace entries", () => { feedIdPresent: true, feedProfileProvided: true, feedSequence: 7, + feedTrustMode: "signed", + feedTrustSignatureCount: 1, + feedTrustThreshold: 1, + feedTrustVerified: true, offline: true, payloadChecksumPresent: true, snapshotUsed: true, @@ -271,6 +343,7 @@ describe("plugins marketplace entries", () => { expect(JSON.stringify(event)).not.toContain("packages.acme.example"); expect(JSON.stringify(event)).not.toContain("acme-marketplace"); expect(JSON.stringify(event)).not.toContain("feed-sha"); + expect(JSON.stringify(event)).not.toContain("acme-root-2026"); expect(JSON.stringify(event)).not.toContain("secret"); expect(JSON.stringify(event)).not.toContain("token=leak"); }); diff --git a/src/cli/plugins-cli.marketplace-refresh.test.ts b/src/cli/plugins-cli.marketplace-refresh.test.ts index 173c6c73bf4c..eccfa0e4df9c 100644 --- a/src/cli/plugins-cli.marketplace-refresh.test.ts +++ b/src/cli/plugins-cli.marketplace-refresh.test.ts @@ -87,6 +87,13 @@ describe("plugins marketplace refresh", () => { checksum: "feed-sha", etag: '"abc"', }, + trust: { + mode: "signed", + signedBy: "acme-root-2026", + signatureCount: 1, + threshold: 1, + verifiedAt: "2026-06-23T00:01:02.000Z", + }, }); const { runPluginMarketplaceRefreshCommand } = await import("./plugins-cli.runtime.js"); @@ -114,9 +121,53 @@ describe("plugins marketplace refresh", () => { checksum: "feed-sha", etag: '"abc"', }, + trust: { + mode: "signed", + signedBy: "acme-root-2026", + signatureCount: 1, + threshold: 1, + verifiedAt: "2026-06-23T00:01:02.000Z", + }, }); }); + it("prints bounded signed feed trust state in text output", async () => { + mocks.getRuntimeConfig.mockReturnValue({}); + mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue({ + source: "hosted", + entries: [{ name: "@acme/calendar" }], + feed: { + schemaVersion: 1, + id: "acme-marketplace", + generatedAt: "2026-06-23T00:00:00.000Z", + sequence: 7, + entries: [], + }, + metadata: { + url: "https://packages.acme.example/openclaw/feed", + status: 200, + checksum: "feed-sha", + }, + trust: { + mode: "signed", + signedBy: "acme-root-2026", + signatureCount: 1, + threshold: 1, + verifiedAt: "2026-06-23T00:01:02.000Z", + }, + }); + + const { runPluginMarketplaceRefreshCommand } = await import("./plugins-cli.runtime.js"); + await runPluginMarketplaceRefreshCommand({}); + + const output = mocks.defaultRuntime.log.mock.calls.map(([value]) => String(value)).join("\n"); + expect(output).toContain("Trust:"); + expect(output).toContain("signed by acme-root-2026 (1/1)"); + expect(output).toContain("2026-06-23T00:01:02.000Z"); + expect(output).not.toContain("publicKey"); + expect(output).not.toContain("signature:"); + }); + it("normalizes bare SHA-256 pins before refreshing", async () => { const config = { marketplaces: { @@ -288,6 +339,13 @@ describe("plugins marketplace refresh", () => { checksum: "feed-sha", etag: '"abc"', }, + trust: { + mode: "signed", + signedBy: "acme-root-2026", + signatureCount: 1, + threshold: 1, + verifiedAt: "2026-06-23T00:01:02.000Z", + }, }); const { runPluginMarketplaceRefreshCommand } = await import("./plugins-cli.runtime.js"); @@ -313,6 +371,10 @@ describe("plugins marketplace refresh", () => { feedIdPresent: true, feedProfileProvided: true, feedSequence: 7, + feedTrustMode: "signed", + feedTrustSignatureCount: 1, + feedTrustThreshold: 1, + feedTrustVerified: true, feedUrlOverride: true, hasEtag: true, payloadChecksumPresent: true, @@ -321,6 +383,7 @@ describe("plugins marketplace refresh", () => { expect(JSON.stringify(event)).not.toContain("packages.acme.example"); expect(JSON.stringify(event)).not.toContain("acme-marketplace"); expect(JSON.stringify(event)).not.toContain("feed-sha"); + expect(JSON.stringify(event)).not.toContain("acme-root-2026"); expect(JSON.stringify(event)).not.toContain("secret"); expect(JSON.stringify(event)).not.toContain("token=leak"); expect(JSON.stringify(event)).not.toContain("override-leak"); diff --git a/src/cli/plugins-cli.runtime.ts b/src/cli/plugins-cli.runtime.ts index 9b2aab72d547..e40f21969f3e 100644 --- a/src/cli/plugins-cli.runtime.ts +++ b/src/cli/plugins-cli.runtime.ts @@ -467,9 +467,18 @@ type MarketplaceRefreshPayload = { snapshot?: { savedAt: string; }; + trust?: MarketplaceFeedTrustPayload; error?: string; }; +type MarketplaceFeedTrustPayload = { + mode: "signed"; + signedBy: string; + signatureCount: number; + threshold: number; + verifiedAt: string; +}; + type MarketplaceEntryPayload = { id?: string; label: string; @@ -559,6 +568,12 @@ function emitMarketplaceFeedTelemetry(params: { if (params.payload.snapshot) { attributes.snapshotUsed = true; } + if (params.payload.trust) { + attributes.feedTrustVerified = true; + attributes.feedTrustMode = params.payload.trust.mode; + attributes.feedTrustSignatureCount = params.payload.trust.signatureCount; + attributes.feedTrustThreshold = params.payload.trust.threshold; + } const fallbackCategory = classifyMarketplaceFeedFallback(params.payload.error); if (fallbackCategory) { attributes.fallbackCategory = fallbackCategory; @@ -597,6 +612,15 @@ function buildMarketplaceRefreshPayload( generatedAt: result.feed.generatedAt, sequence: result.feed.sequence, }; + if (result.trust) { + payload.trust = { + mode: result.trust.mode, + signedBy: result.trust.signedBy, + signatureCount: result.trust.signatureCount, + threshold: result.trust.threshold, + verifiedAt: result.trust.verifiedAt, + }; + } } if (result.source === "hosted-snapshot") { payload.snapshot = { savedAt: result.snapshot.savedAt }; @@ -681,6 +705,10 @@ function formatMarketplaceRefreshSource(source: MarketplaceRefreshPayload["sourc return theme.warn("bundled fallback"); } +function formatMarketplaceFeedTrust(trust: MarketplaceFeedTrustPayload): string { + return `${trust.mode} by ${trust.signedBy} (${trust.signatureCount}/${trust.threshold}) verified ${trust.verifiedAt}`; +} + function shouldFailPinnedMarketplaceRefresh(params: { expectedSha256?: string; source: MarketplaceRefreshPayload["source"]; @@ -778,6 +806,9 @@ export async function runPluginMarketplaceEntriesCommand( if (summary.snapshot?.savedAt) { lines.push(theme.muted("Snapshot:") + " " + summary.snapshot.savedAt); } + if (summary.trust) { + lines.push(theme.muted("Trust:") + " " + formatMarketplaceFeedTrust(summary.trust)); + } if (summary.error) { lines.push(theme.muted("Fallback reason:") + " " + summary.error); } @@ -845,6 +876,9 @@ export async function runPluginMarketplaceRefreshCommand( if (payload.snapshot?.savedAt) { lines.push(`${theme.muted("Snapshot:")} ${payload.snapshot.savedAt}`); } + if (payload.trust) { + lines.push(`${theme.muted("Trust:")} ${formatMarketplaceFeedTrust(payload.trust)}`); + } if (payload.error) { lines.push(`${theme.muted("Fallback reason:")} ${payload.error}`); }