diff --git a/src/cli/plugins-cli.marketplace-entries.test.ts b/src/cli/plugins-cli.marketplace-entries.test.ts index cbc4f879430..bd86c43c822 100644 --- a/src/cli/plugins-cli.marketplace-entries.test.ts +++ b/src/cli/plugins-cli.marketplace-entries.test.ts @@ -1,5 +1,8 @@ // Covers the hosted OpenClaw marketplace feed entries command. -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtemp, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => { const defaultRuntime = { @@ -38,6 +41,19 @@ vi.mock("../plugins/official-external-plugin-catalog.js", async (importOriginal) }; }); +async function createTimelinePath(): Promise { + const dir = await mkdtemp(path.join(tmpdir(), "openclaw-marketplace-entries-")); + return path.join(dir, "timeline.jsonl"); +} + +async function readTimeline(pathname: string): Promise[]> { + const content = await readFile(pathname, "utf8"); + return content + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Record); +} + describe("plugins marketplace entries", () => { beforeEach(() => { mocks.defaultRuntime.error.mockClear(); @@ -46,6 +62,11 @@ describe("plugins marketplace entries", () => { mocks.defaultRuntime.writeJson.mockClear(); mocks.getRuntimeConfig.mockReset(); mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockReset(); + vi.unstubAllEnvs(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); }); it("lists entries from the configured marketplace feed as JSON", async () => { @@ -191,4 +212,66 @@ describe("plugins marketplace entries", () => { expect(output).toContain("hosted catalog feed offline mode"); expect(mocks.defaultRuntime.exit).not.toHaveBeenCalled(); }); + + it("emits bounded diagnostics for feed entry listing", async () => { + const timelinePath = await createTimelinePath(); + vi.stubEnv("OPENCLAW_DIAGNOSTICS", "1"); + vi.stubEnv("OPENCLAW_DIAGNOSTICS_TIMELINE_PATH", timelinePath); + mocks.getRuntimeConfig.mockReturnValue({}); + mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue({ + source: "hosted-snapshot", + entries: [ + { + name: "@acme/calendar", + openclaw: { plugin: { id: "acme-calendar", label: "Acme Calendar" } }, + }, + ], + feed: { + schemaVersion: 1, + id: "acme-marketplace", + generatedAt: "2026-06-23T00:00:00.000Z", + sequence: 7, + entries: [], + }, + metadata: { + url: "https://user:secret@packages.acme.example/openclaw/feed?token=leak#frag", + status: 200, + checksum: "feed-sha", + }, + snapshot: { + body: "{}", + metadata: { + url: "https://user:secret@packages.acme.example/openclaw/feed?token=leak#frag", + status: 200, + checksum: "feed-sha", + }, + savedAt: "2026-06-23T01:02:03.000Z", + }, + error: "hosted catalog feed offline mode", + }); + + const { runPluginMarketplaceEntriesCommand } = await import("./plugins-cli.runtime.js"); + await runPluginMarketplaceEntriesCommand({ feedProfile: "acme", offline: true }); + + const [event] = await readTimeline(timelinePath); + expect(event?.name).toBe("plugins.marketplace.feed.entries"); + expect(event?.phase).toBe("plugin-marketplace"); + expect(event?.attributes).toMatchObject({ + command: "entries", + entries: 1, + fallbackCategory: "offline", + feedIdPresent: true, + feedProfileProvided: true, + feedSequence: 7, + offline: true, + payloadChecksumPresent: true, + snapshotUsed: true, + source: "hosted-snapshot", + }); + 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("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 1dec9e11d81..173c6c73bf4 100644 --- a/src/cli/plugins-cli.marketplace-refresh.test.ts +++ b/src/cli/plugins-cli.marketplace-refresh.test.ts @@ -1,5 +1,8 @@ // Covers the hosted OpenClaw marketplace feed refresh command. -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtemp, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => { const defaultRuntime = { @@ -33,6 +36,19 @@ vi.mock("../plugins/official-external-plugin-catalog.js", () => ({ mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries, })); +async function createTimelinePath(): Promise { + const dir = await mkdtemp(path.join(tmpdir(), "openclaw-marketplace-refresh-")); + return path.join(dir, "timeline.jsonl"); +} + +async function readTimeline(pathname: string): Promise[]> { + const content = await readFile(pathname, "utf8"); + return content + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Record); +} + describe("plugins marketplace refresh", () => { beforeEach(() => { mocks.defaultRuntime.error.mockClear(); @@ -41,6 +57,11 @@ describe("plugins marketplace refresh", () => { mocks.defaultRuntime.writeJson.mockClear(); mocks.getRuntimeConfig.mockReset(); mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockReset(); + vi.unstubAllEnvs(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); }); it("refreshes the configured marketplace feed and prints JSON", async () => { @@ -240,4 +261,68 @@ describe("plugins marketplace refresh", () => { ); expect(mocks.defaultRuntime.exit).toHaveBeenCalledWith(1); }); + + it("emits bounded diagnostics for refresh without raw feed URLs", async () => { + const timelinePath = await createTimelinePath(); + vi.stubEnv("OPENCLAW_DIAGNOSTICS_TIMELINE_PATH", timelinePath); + const config = { + diagnostics: { flags: ["timeline"] }, + marketplaces: { + feeds: { acme: { url: "https://packages.acme.example/openclaw/feed" } }, + }, + }; + mocks.getRuntimeConfig.mockReturnValue(config); + mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue({ + source: "hosted", + entries: [{ name: "@acme/calendar" }, { name: "@acme/docs" }], + feed: { + schemaVersion: 1, + id: "acme-marketplace", + generatedAt: "2026-06-23T00:00:00.000Z", + sequence: 7, + entries: [], + }, + metadata: { + url: "https://user:secret@packages.acme.example/openclaw/feed?token=leak#frag", + status: 200, + checksum: "feed-sha", + etag: '"abc"', + }, + }); + + const { runPluginMarketplaceRefreshCommand } = await import("./plugins-cli.runtime.js"); + await runPluginMarketplaceRefreshCommand({ + expectedSha256: "feed-sha", + feedProfile: "acme", + feedUrl: "https://override.example/openclaw/feed?token=override-leak", + }); + + const [event] = await readTimeline(timelinePath); + expect(mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries).toHaveBeenCalledWith( + config, + expect.objectContaining({ + feedUrl: "https://override.example/openclaw/feed?token=override-leak", + }), + ); + expect(event?.name).toBe("plugins.marketplace.feed.refresh"); + expect(event?.phase).toBe("plugin-marketplace"); + expect(event?.attributes).toMatchObject({ + command: "refresh", + entries: 2, + expectedSha256Provided: true, + feedIdPresent: true, + feedProfileProvided: true, + feedSequence: 7, + feedUrlOverride: true, + hasEtag: true, + payloadChecksumPresent: true, + source: "hosted", + }); + 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("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 58f0f1f198c..9b2aab72d54 100644 --- a/src/cli/plugins-cli.runtime.ts +++ b/src/cli/plugins-cli.runtime.ts @@ -13,6 +13,7 @@ import { replaceConfigFile, } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { emitDiagnosticsTimelineEvent } from "../infra/diagnostics-timeline.js"; import { tracePluginLifecyclePhaseAsync } from "../plugins/plugin-lifecycle-trace.js"; import { defaultRuntime } from "../runtime.js"; import { shortenHomeInString } from "../utils.js"; @@ -485,6 +486,99 @@ type MarketplaceEntryPayload = { }; }; +type MarketplaceFeedTelemetryOptions = { + expectedSha256?: string; + feedProfile?: string; + feedUrl?: string; + offline?: boolean; +}; + +function classifyMarketplaceFeedFallback(error: string | undefined): string | undefined { + const text = error?.toLowerCase(); + if (!text) { + return undefined; + } + if (text.includes("offline mode")) { + return "offline"; + } + if (text.includes("checksum mismatch")) { + return "checksum_mismatch"; + } + if (text.includes("schema")) { + return "schema"; + } + if (/http\s+304/u.test(text)) { + return "not_modified"; + } + if (/http\s+\d{3}/u.test(text)) { + return "http_error"; + } + if (text.includes("timed out") || text.includes("timeout")) { + return "timeout"; + } + return "error"; +} + +function emitMarketplaceFeedTelemetry(params: { + command: "entries" | "refresh"; + entryCount?: number; + failedPinnedRefresh?: boolean; + opts: MarketplaceFeedTelemetryOptions; + config?: OpenClawConfig; + payload: MarketplaceRefreshPayload; +}): void { + const attributes: Record = { + command: params.command, + entries: params.entryCount ?? params.payload.entries, + source: params.payload.source, + }; + if (params.opts.feedProfile?.trim()) { + attributes.feedProfileProvided = true; + } + if (params.opts.feedUrl?.trim()) { + attributes.feedUrlOverride = true; + } + if (params.opts.offline === true) { + attributes.offline = true; + } + if (params.opts.expectedSha256?.trim()) { + attributes.expectedSha256Provided = true; + } + if (params.payload.feed) { + attributes.feedIdPresent = true; + attributes.feedSequence = params.payload.feed.sequence; + } + if (params.payload.metadata) { + attributes.httpStatus = params.payload.metadata.status; + if (params.payload.metadata.checksum) { + attributes.payloadChecksumPresent = true; + } + attributes.hasEtag = Boolean(params.payload.metadata.etag); + attributes.hasLastModified = Boolean(params.payload.metadata.lastModified); + } + if (params.payload.snapshot) { + attributes.snapshotUsed = true; + } + const fallbackCategory = classifyMarketplaceFeedFallback(params.payload.error); + if (fallbackCategory) { + attributes.fallbackCategory = fallbackCategory; + } + if (params.failedPinnedRefresh === true) { + attributes.pinnedRefreshFailed = true; + } + emitDiagnosticsTimelineEvent( + { + type: "mark", + name: `plugins.marketplace.feed.${params.command}`, + phase: "plugin-marketplace", + attributes, + }, + { + config: params.config, + }, + ); +} + function buildMarketplaceRefreshPayload( result: Awaited< ReturnType< @@ -653,6 +747,13 @@ export async function runPluginMarketplaceEntriesCommand( return payload; }); + emitMarketplaceFeedTelemetry({ + command: "entries", + entryCount: entries.length, + opts, + config: cfg, + payload: summary, + }); if (opts.json) { defaultRuntime.writeJson({ ...summary, entries, entryCount: entries.length }); return; @@ -709,6 +810,13 @@ export async function runPluginMarketplaceRefreshCommand( expectedSha256, source: payload.source, }); + emitMarketplaceFeedTelemetry({ + command: "refresh", + failedPinnedRefresh, + opts, + config: cfg, + payload, + }); if (opts.json) { defaultRuntime.writeJson(payload);