From 6bbf14119862e528fe1dfe25953d80fc41261e8f Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Tue, 8 Sep 2026 15:27:43 -0700 Subject: [PATCH 1/4] feat(search): record bounded plugin observations --- convex/_generated/api.d.ts | 4 + convex/crons.test.ts | 16 +++ convex/crons.ts | 7 + convex/httpApiV1.handlers.test.ts | 144 ++++++++++++++++++++ convex/httpApiV1/packagesV1.ts | 40 +++++- convex/lib/pluginSearchObservations.test.ts | 54 ++++++++ convex/lib/pluginSearchObservations.ts | 36 +++++ convex/lib/retentionPolicy.ts | 17 ++- convex/pluginSearchObservations.test.ts | 97 +++++++++++++ convex/pluginSearchObservations.ts | 57 ++++++++ convex/schema.ts | 12 ++ specs/plugin-search-intelligence.md | 45 ++++++ 12 files changed, 526 insertions(+), 3 deletions(-) create mode 100644 convex/lib/pluginSearchObservations.test.ts create mode 100644 convex/lib/pluginSearchObservations.ts create mode 100644 convex/pluginSearchObservations.test.ts create mode 100644 convex/pluginSearchObservations.ts create mode 100644 specs/plugin-search-intelligence.md diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 9364b03601..b75e8dff7d 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -117,6 +117,7 @@ import type * as lib_packageSearchDigest from "../lib/packageSearchDigest.js"; import type * as lib_packageSecurity from "../lib/packageSecurity.js"; import type * as lib_packageStatEvents from "../lib/packageStatEvents.js"; import type * as lib_pluginCategoryClassification from "../lib/pluginCategoryClassification.js"; +import type * as lib_pluginSearchObservations from "../lib/pluginSearchObservations.js"; import type * as lib_public from "../lib/public.js"; import type * as lib_publicBrowse from "../lib/publicBrowse.js"; import type * as lib_publicRouteReservations from "../lib/publicRouteReservations.js"; @@ -184,6 +185,7 @@ import type * as packagePublishRecovery from "../packagePublishRecovery.js"; import type * as packagePublishTokens from "../packagePublishTokens.js"; import type * as packages from "../packages.js"; import type * as pluginCategoryRefresh from "../pluginCategoryRefresh.js"; +import type * as pluginSearchObservations from "../pluginSearchObservations.js"; import type * as prepublicationObservability from "../prepublicationObservability.js"; import type * as promotions from "../promotions.js"; import type * as promotionsFeed from "../promotionsFeed.js"; @@ -347,6 +349,7 @@ declare const fullApi: ApiFromModules<{ "lib/packageSecurity": typeof lib_packageSecurity; "lib/packageStatEvents": typeof lib_packageStatEvents; "lib/pluginCategoryClassification": typeof lib_pluginCategoryClassification; + "lib/pluginSearchObservations": typeof lib_pluginSearchObservations; "lib/public": typeof lib_public; "lib/publicBrowse": typeof lib_publicBrowse; "lib/publicRouteReservations": typeof lib_publicRouteReservations; @@ -414,6 +417,7 @@ declare const fullApi: ApiFromModules<{ packagePublishTokens: typeof packagePublishTokens; packages: typeof packages; pluginCategoryRefresh: typeof pluginCategoryRefresh; + pluginSearchObservations: typeof pluginSearchObservations; prepublicationObservability: typeof prepublicationObservability; promotions: typeof promotions; promotionsFeed: typeof promotionsFeed; diff --git a/convex/crons.test.ts b/convex/crons.test.ts index abfe6dd019..de94d7db53 100644 --- a/convex/crons.test.ts +++ b/convex/crons.test.ts @@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => { const skillStatEventPruneRef = Symbol("skill-stat-event-prune"); const skillHourlyStatsPruneRef = Symbol("skill-hourly-stats-prune"); const packageStatEventPruneRef = Symbol("package-stat-event-prune"); + const pluginSearchObservationPruneRef = Symbol("plugin-search-observation-prune"); const authSessionsPruneRef = Symbol("auth-sessions-prune"); const authRefreshTokensPruneRef = Symbol("auth-refresh-tokens-prune"); const publisherInvitesPruneRef = Symbol("publisher-invites-prune"); @@ -36,6 +37,7 @@ const mocks = vi.hoisted(() => { skillStatEventPruneRef, skillHourlyStatsPruneRef, packageStatEventPruneRef, + pluginSearchObservationPruneRef, authSessionsPruneRef, authRefreshTokensPruneRef, publisherInvitesPruneRef, @@ -85,6 +87,9 @@ vi.mock("./_generated/api", () => ({ pruneProcessedPackageStatEventsInternal: mocks.packageStatEventPruneRef, backfillPackageReleaseScansInternal: Symbol("package-scan-backfill"), }, + pluginSearchObservations: { + pruneExpiredInternal: mocks.pluginSearchObservationPruneRef, + }, publisherAbuse: { runPublisherAbuseScoreRunInternal: mocks.publisherAbuseScoreRefreshRef, processPublisherAbuseAutobansInternal: mocks.publisherAbuseAutobanRef, @@ -369,6 +374,17 @@ describe("crons", () => { ); }); + it("prunes plugin search observations daily with the standard batch size", async () => { + await import("./crons"); + + expect(mocks.interval).toHaveBeenCalledWith( + "plugin-search-observations-prune", + { hours: 24 }, + mocks.pluginSearchObservationPruneRef, + { batchSize: 500 }, + ); + }); + it("prunes processed skill stat events daily with a seven-day retention window", async () => { await import("./crons"); diff --git a/convex/crons.ts b/convex/crons.ts index 12971813cf..e170b43884 100644 --- a/convex/crons.ts +++ b/convex/crons.ts @@ -118,6 +118,13 @@ if (process.env.CLAWHUB_DISABLE_CRONS !== "1" && process.env.CLAWHUB_PREVIEW !== }, ); + crons.interval( + "plugin-search-observations-prune", + { hours: 24 }, + internal.pluginSearchObservations.pruneExpiredInternal, + { batchSize: RETENTION_STANDARD_BATCH_SIZE }, + ); + crons.interval( "global-stats-update", { hours: 24 }, diff --git a/convex/httpApiV1.handlers.test.ts b/convex/httpApiV1.handlers.test.ts index 80065e234e..c2f56ee2dd 100644 --- a/convex/httpApiV1.handlers.test.ts +++ b/convex/httpApiV1.handlers.test.ts @@ -12678,6 +12678,150 @@ describe("httpApiV1 handlers", () => { } }); + it.each(["clawhub-web", "openclaw-control-ui"] as const)( + "records one marked %s plugin search from the exact combined visible response", + async (source) => { + const observationWrites: Record[] = []; + const runQuery = vi.fn((_, args: Record) => { + if (args.family === "code-plugin") { + return [ + { + score: 10, + package: { + ...makeCatalogItem("weather-code", { family: "code-plugin", updatedAt: 100 }), + isOfficial: true, + }, + }, + ]; + } + if (args.family === "bundle-plugin") { + return [ + { + score: 8, + package: makeCatalogItem("weather-bundle", { + family: "bundle-plugin", + updatedAt: 80, + }), + }, + ]; + } + throw new Error(`unexpected family ${String(args.family)}`); + }); + const ctx = makeCtx({ + runQuery, + runMutation: (_mutation: unknown, args: Record) => { + if (isRateLimitArgs(args)) return okRate(); + observationWrites.push(args); + return null; + }, + }); + + const response = await __handlers.pluginsGetRouterV1Handler( + ctx, + new Request( + `https://example.com/api/v1/plugins/search?q=%20Weather%20%20API%20&category=tools&topic=automation&searchSource=${source}`, + ), + ); + + expect(response.status).toBe(200); + expect(observationWrites).toEqual([ + { + source, + artifactKind: "plugin", + normalizedQuery: "weather api", + category: "tools", + topic: "automation", + resultCount: 2, + officialResultCount: 1, + }, + ]); + }, + ); + + it.each([ + ["unmarked plugin request", "https://example.com/api/v1/plugins/search?q=weather", 200], + [ + "unknown plugin source", + "https://example.com/api/v1/plugins/search?q=weather&searchSource=crawler", + 200, + ], + [ + "marked generic package request", + "https://example.com/api/v1/packages/search?q=weather&searchSource=clawhub-web", + 200, + ], + [ + "marked empty plugin query", + "https://example.com/api/v1/plugins/search?q=%20%20&searchSource=clawhub-web", + 400, + ], + ])("does not record %s", async (_case, requestUrl, expectedStatus) => { + const observationWrites: Record[] = []; + const ctx = makeCtx({ + runQuery: vi.fn().mockResolvedValue([]), + runMutation: (_mutation: unknown, args: Record) => { + if (isRateLimitArgs(args)) return okRate(); + observationWrites.push(args); + return null; + }, + }); + const handler = requestUrl.includes("/plugins/") + ? __handlers.pluginsGetRouterV1Handler + : __handlers.packagesGetRouterV1Handler; + + const response = await handler(ctx, new Request(requestUrl)); + + expect(response.status).toBe(expectedStatus); + expect(observationWrites).toEqual([]); + }); + + it("does not record a marked plugin search when result assembly fails", async () => { + const observationWrites: Record[] = []; + const ctx = makeCtx({ + runQuery: vi.fn().mockRejectedValue(new Error("search unavailable")), + runMutation: (_mutation: unknown, args: Record) => { + if (isRateLimitArgs(args)) return okRate(); + observationWrites.push(args); + return null; + }, + }); + + await expect( + __handlers.pluginsGetRouterV1Handler( + ctx, + new Request( + "https://example.com/api/v1/plugins/search?q=weather&searchSource=openclaw-control-ui", + ), + ), + ).rejects.toThrow("search unavailable"); + expect(observationWrites).toEqual([]); + }); + + it("keeps search available and logs no query when observation storage fails", async () => { + const log = vi.spyOn(console, "error").mockImplementation(() => {}); + const ctx = makeCtx({ + runQuery: vi.fn().mockResolvedValue([]), + runMutation: (_mutation: unknown, args: Record) => { + if (isRateLimitArgs(args)) return okRate(); + throw new Error("storage failed for sensitive query text"); + }, + }); + + const response = await __handlers.pluginsGetRouterV1Handler( + ctx, + new Request( + "https://example.com/api/v1/plugins/search?q=private-query&searchSource=openclaw-control-ui", + ), + ); + + expect(response.status).toBe(200); + expect(log).toHaveBeenCalledWith( + "[plugin-search-observations] failed to record marked search", + { source: "openclaw-control-ui" }, + ); + expect(JSON.stringify(log.mock.calls)).not.toContain("private-query"); + }); + it("plugins search forwards New eligibility to both plugin families", async () => { const runQuery = vi.fn().mockResolvedValue([]); const runMutation = vi.fn().mockResolvedValue(okRate()); diff --git a/convex/httpApiV1/packagesV1.ts b/convex/httpApiV1/packagesV1.ts index 1d742843b5..aaf913c37f 100644 --- a/convex/httpApiV1/packagesV1.ts +++ b/convex/httpApiV1/packagesV1.ts @@ -64,6 +64,10 @@ import { resolvePackageReleaseScanStatus, } from "../lib/packageSecurity"; import type { PublicPublisher } from "../lib/public"; +import { + buildPluginSearchObservation, + parsePluginSearchSource, +} from "../lib/pluginSearchObservations"; import { getClawPackSizeError, getPackageMultipartSizeError, @@ -195,6 +199,9 @@ const internalRefs = internal as unknown as { enqueueBulkPackageRescanBatchForAdminInternal: unknown; getBulkPackageRescanBatchStatusForAdminInternal: unknown; }; + pluginSearchObservations: { + recordInternal: unknown; + }; publishAttempts: { getPackagePublishAttemptStatusInternal: unknown; }; @@ -3985,7 +3992,11 @@ async function getSkillVersionForRequest( async function searchPackages( ctx: ActionCtx, request: Request, - options?: { includeSkills?: boolean; pluginFamilies?: Array<"code-plugin" | "bundle-plugin"> }, + options?: { + includeSkills?: boolean; + pluginFamilies?: Array<"code-plugin" | "bundle-plugin">; + recordPluginSearch?: boolean; + }, ) { const rate = await applyRateLimit(ctx, request, "read"); if (!rate.ok) return rate.response; @@ -4137,7 +4148,31 @@ async function searchPackages( .sort(compareCatalogSearchEntries) .slice(0, limit); } - return json({ results: results.map(toPublicCatalogSearchEntry) }, 200, rate.headers); + const publicResults = results.map(toPublicCatalogSearchEntry); + if (options?.recordPluginSearch) { + const observation = buildPluginSearchObservation({ + source: parsePluginSearchSource(url.searchParams.get("searchSource")), + query: queryText, + category, + topic, + results: publicResults, + }); + if (observation) { + try { + await runMutationRef( + ctx, + internalRefs.pluginSearchObservations.recordInternal, + observation, + ); + } catch { + // Search demand is optional product analytics. Never expose or log the raw query on failure. + console.error("[plugin-search-observations] failed to record marked search", { + source: observation.source, + }); + } + } + } + return json({ results: publicResults }, 200, rate.headers); } export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Request) { @@ -4939,6 +4974,7 @@ export async function pluginsGetRouterV1Handler(ctx: ActionCtx, request: Request return await searchPackages(ctx, request, { includeSkills: false, pluginFamilies: ["code-plugin", "bundle-plugin"], + recordPluginSearch: true, }); } return text("Not found", 404); diff --git a/convex/lib/pluginSearchObservations.test.ts b/convex/lib/pluginSearchObservations.test.ts new file mode 100644 index 0000000000..6896a7d0b2 --- /dev/null +++ b/convex/lib/pluginSearchObservations.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { + buildPluginSearchObservation, + normalizePluginSearchQuery, + parsePluginSearchSource, +} from "./pluginSearchObservations"; + +describe("plugin search observation contract", () => { + it("accepts only the closed source enum", () => { + expect(parsePluginSearchSource("clawhub-web")).toBe("clawhub-web"); + expect(parsePluginSearchSource("openclaw-control-ui")).toBe("openclaw-control-ui"); + expect(parsePluginSearchSource("crawler")).toBeUndefined(); + expect(parsePluginSearchSource(null)).toBeUndefined(); + }); + + it("normalizes only casing and whitespace", () => { + expect(normalizePluginSearchQuery(" Weather\t API ")).toBe("weather api"); + expect(normalizePluginSearchQuery("weather-api")).toBe("weather-api"); + }); + + it("derives exact visible and official counts without identity metadata", () => { + expect( + buildPluginSearchObservation({ + source: "clawhub-web", + query: " Weather API ", + category: "tools", + topic: "automation", + results: [ + { package: { isOfficial: true } }, + { package: { isOfficial: false } }, + { package: { isOfficial: false } }, + ], + }), + ).toEqual({ + source: "clawhub-web", + artifactKind: "plugin", + normalizedQuery: "weather api", + category: "tools", + topic: "automation", + resultCount: 3, + officialResultCount: 1, + }); + }); + + it("builds no observation for unmarked traffic", () => { + expect( + buildPluginSearchObservation({ + source: undefined, + query: "weather", + results: [{ package: { isOfficial: false } }], + }), + ).toBeNull(); + }); +}); diff --git a/convex/lib/pluginSearchObservations.ts b/convex/lib/pluginSearchObservations.ts new file mode 100644 index 0000000000..457efe19c2 --- /dev/null +++ b/convex/lib/pluginSearchObservations.ts @@ -0,0 +1,36 @@ +export const PLUGIN_SEARCH_SOURCES = ["clawhub-web", "openclaw-control-ui"] as const; + +export type PluginSearchSource = (typeof PLUGIN_SEARCH_SOURCES)[number]; + +type PluginSearchResult = { + package: { + isOfficial: boolean; + }; +}; + +export function parsePluginSearchSource(value: string | null): PluginSearchSource | undefined { + return PLUGIN_SEARCH_SOURCES.find((source) => source === value); +} + +export function normalizePluginSearchQuery(value: string) { + return value.trim().replace(/\s+/gu, " ").toLowerCase(); +} + +export function buildPluginSearchObservation(params: { + source: PluginSearchSource | undefined; + query: string; + category?: string; + topic?: string; + results: PluginSearchResult[]; +}) { + if (!params.source) return null; + return { + source: params.source, + artifactKind: "plugin" as const, + normalizedQuery: normalizePluginSearchQuery(params.query), + category: params.category, + topic: params.topic, + resultCount: params.results.length, + officialResultCount: params.results.filter((entry) => entry.package.isOfficial).length, + }; +} diff --git a/convex/lib/retentionPolicy.ts b/convex/lib/retentionPolicy.ts index ef1a31ea2e..61b345cbd3 100644 --- a/convex/lib/retentionPolicy.ts +++ b/convex/lib/retentionPolicy.ts @@ -25,7 +25,13 @@ type EphemeralRetentionPolicy = BaseRetentionPolicy & { classification: "ephemeral"; standardBatchSize: typeof RETENTION_STANDARD_BATCH_SIZE; prune: string; - expirationField?: "expiresAt" | "expirationTime" | "dayStart" | "processedAt" | "createdAt"; + expirationField?: + | "expiresAt" + | "expirationTime" + | "dayStart" + | "processedAt" + | "createdAt" + | "observedAt"; expirationIndex?: string; retention: string; }; @@ -176,6 +182,15 @@ export const RETENTION_POLICIES = { prune: "packages.pruneProcessedPackageStatEventsInternal", retention: "Processed and older than 7 days.", }), + pluginSearchObservations: ephemeral( + "Raw plugin search observations are retained only to build privacy-preserving aggregates.", + { + expirationField: "observedAt", + expirationIndex: "by_observed_at", + prune: "pluginSearchObservations.pruneExpiredInternal", + retention: "30 days after observation.", + }, + ), packageDailyStats: permanent("Daily aggregate package stats are product analytics."), packageLeaderboards: derived( "Package trending snapshots can be rebuilt from packageDailyStats.", diff --git a/convex/pluginSearchObservations.test.ts b/convex/pluginSearchObservations.test.ts new file mode 100644 index 0000000000..2e30490f8b --- /dev/null +++ b/convex/pluginSearchObservations.test.ts @@ -0,0 +1,97 @@ +/// + +import { convexTest } from "convex-test"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { internal } from "./_generated/api"; +import schema from "./schema"; + +const modules = import.meta.glob("./**/*.ts"); + +const observation = { + source: "clawhub-web" as const, + artifactKind: "plugin" as const, + normalizedQuery: "weather api", + category: "tools", + topic: "automation", + resultCount: 3, + officialResultCount: 1, +}; + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe("plugin search observations", () => { + it("persists only the bounded search facts with the server observation time", async () => { + const now = Date.UTC(2026, 8, 8, 22); + vi.spyOn(Date, "now").mockReturnValue(now); + const t = convexTest(schema, modules); + + const result = await t.mutation(internal.pluginSearchObservations.recordInternal, observation); + const rows = await t.run( + async (ctx) => await ctx.db.query("pluginSearchObservations").collect(), + ); + + expect(result.observedAt).toBe(now); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ ...observation, observedAt: now }); + expect(Object.keys(rows[0] ?? {}).sort()).toEqual( + [ + "_creationTime", + "_id", + "artifactKind", + "category", + "normalizedQuery", + "observedAt", + "officialResultCount", + "resultCount", + "source", + "topic", + ].sort(), + ); + }); + + it("prunes observations at the 30-day cutoff and keeps newer rows", async () => { + const cutoff = Date.UTC(2026, 7, 9, 22); + const t = convexTest(schema, modules); + await t.run(async (ctx) => { + for (const observedAt of [cutoff - 1, cutoff, cutoff + 1]) { + await ctx.db.insert("pluginSearchObservations", { ...observation, observedAt }); + } + }); + + const result = await t.mutation(internal.pluginSearchObservations.pruneExpiredInternal, { + batchSize: 10, + cutoff, + }); + const rows = await t.run( + async (ctx) => await ctx.db.query("pluginSearchObservations").collect(), + ); + + expect(result).toEqual({ deleted: 2, hasMore: false }); + expect(rows.map((row) => row.observedAt)).toEqual([cutoff + 1]); + }); + + it("continues a full bounded prune batch with the same cutoff", async () => { + vi.useFakeTimers(); + const cutoff = Date.UTC(2026, 7, 9, 22); + const t = convexTest(schema, modules); + await t.run(async (ctx) => { + await ctx.db.insert("pluginSearchObservations", { ...observation, observedAt: cutoff - 2 }); + await ctx.db.insert("pluginSearchObservations", { ...observation, observedAt: cutoff - 1 }); + }); + + const result = await t.mutation(internal.pluginSearchObservations.pruneExpiredInternal, { + batchSize: 1, + cutoff, + }); + expect(result).toEqual({ deleted: 1, hasMore: true }); + + await t.finishAllScheduledFunctions(vi.runAllTimers); + const rows = await t.run( + async (ctx) => await ctx.db.query("pluginSearchObservations").collect(), + ); + expect(rows).toEqual([]); + }); +}); diff --git a/convex/pluginSearchObservations.ts b/convex/pluginSearchObservations.ts new file mode 100644 index 0000000000..754ce19182 --- /dev/null +++ b/convex/pluginSearchObservations.ts @@ -0,0 +1,57 @@ +import { v } from "convex/values"; +import { internal } from "./_generated/api"; +import { internalMutation } from "./functions"; +import { RETENTION_STANDARD_BATCH_SIZE } from "./lib/retentionPolicy"; + +const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1_000; +const MAX_PRUNE_BATCH_SIZE = 1_000; + +function normalizeBatchSize(value: number | undefined) { + if (!Number.isFinite(value)) return RETENTION_STANDARD_BATCH_SIZE; + return Math.max( + 1, + Math.min(Math.trunc(value ?? RETENTION_STANDARD_BATCH_SIZE), MAX_PRUNE_BATCH_SIZE), + ); +} + +export const recordInternal = internalMutation({ + args: { + source: v.union(v.literal("clawhub-web"), v.literal("openclaw-control-ui")), + artifactKind: v.literal("plugin"), + normalizedQuery: v.string(), + category: v.optional(v.string()), + topic: v.optional(v.string()), + resultCount: v.number(), + officialResultCount: v.number(), + }, + handler: async (ctx, args) => { + const observedAt = Date.now(); + const id = await ctx.db.insert("pluginSearchObservations", { ...args, observedAt }); + return { id, observedAt }; + }, +}); + +export const pruneExpiredInternal = internalMutation({ + args: { + batchSize: v.optional(v.number()), + cutoff: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const batchSize = normalizeBatchSize(args.batchSize); + const cutoff = args.cutoff ?? Date.now() - THIRTY_DAYS_MS; + const rows = await ctx.db + .query("pluginSearchObservations") + .withIndex("by_observed_at", (q) => q.lte("observedAt", cutoff)) + .take(batchSize); + for (const row of rows) await ctx.db.delete(row._id); + + const hasMore = rows.length === batchSize; + if (hasMore) { + await ctx.scheduler.runAfter(0, internal.pluginSearchObservations.pruneExpiredInternal, { + batchSize, + cutoff, + }); + } + return { deleted: rows.length, hasMore }; + }, +}); diff --git a/convex/schema.ts b/convex/schema.ts index 81ac6ea2b9..98f07d22a2 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -2323,6 +2323,17 @@ const packageStatEvents = defineTable({ .index("by_unprocessed", ["processedAt"]) .index("by_package", ["packageId"]); +const pluginSearchObservations = defineTable({ + normalizedQuery: v.string(), + observedAt: v.number(), + source: v.union(v.literal("clawhub-web"), v.literal("openclaw-control-ui")), + artifactKind: v.literal("plugin"), + category: v.optional(v.string()), + topic: v.optional(v.string()), + resultCount: v.number(), + officialResultCount: v.number(), +}).index("by_observed_at", ["observedAt"]); + const packageDailyStats = defineTable({ packageId: v.id("packages"), day: v.number(), @@ -4543,6 +4554,7 @@ export default defineSchema({ skillScanRequestFileChunks, skillCardGenerationJobs, packageStatEvents, + pluginSearchObservations, packageDailyStats, packageLeaderboards, packageTrustedPublishers, diff --git a/specs/plugin-search-intelligence.md b/specs/plugin-search-intelligence.md new file mode 100644 index 0000000000..ca76e53989 --- /dev/null +++ b/specs/plugin-search-intelligence.md @@ -0,0 +1,45 @@ +# Plugin search intelligence + +ClawHub owns the canonical product-data stream for manually initiated plugin +searches. Generic request logs, Axiom, and Vercel page analytics are not sources +of plugin-demand truth. + +## Capture boundary + +Only completed requests to the combined plugin search boundary may produce a +raw observation. The request must carry one recognized analytics-attribution +marker: + +- `clawhub-web` +- `openclaw-control-ui` + +The marker is not authorization and grants no access or behavior. Missing or +unknown markers are ignored. Package-family, Skills, CLI, crawler, URL-load, +and generic API requests remain unobserved unless a later product decision adds +an explicit source. + +The observation is written after the visible response has been assembled, so +`resultCount` and `officialResultCount` describe that exact response. Official +means company- or publisher-supported provenance (`package.isOfficial`), not +popularity, ranking, or relevance. + +## Privacy boundary + +Raw observations contain only: + +- normalized query text (lowercase; surrounding and repeated whitespace only) +- observation timestamp +- bounded source +- `artifactKind: "plugin"` +- selected category and topic/intent filters +- visible result and official-result counts + +The search-intelligence path must not read, derive, pass through, or persist IP, +User-Agent, authentication identity, user, device, installation, session, +browser, operating system, geography, or OpenClaw version metadata. Existing +HTTP rate limiting remains an independent security boundary and does not feed +product analytics. + +Raw observations expire after 30 days through indexed, bounded, resumable +cleanup. Longer-lived daily aggregates are owned by a later layer and must not +add identity or request metadata. From fb0eba14f095ead1a743d0283a88e84b09f878fe Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Tue, 8 Sep 2026 15:34:00 -0700 Subject: [PATCH 2/4] fix: enforce bounded authoritative plugin search observations --- convex/httpApiV1.handlers.test.ts | 44 +++++++++++++++++++++++++ convex/httpApiV1/packagesV1.ts | 6 +++- convex/lib/pluginSearchObservations.ts | 15 +++++++-- convex/pluginSearchObservations.test.ts | 16 +++++++++ convex/pluginSearchObservations.ts | 16 +++++++++ specs/plugin-search-intelligence.md | 19 ++++++++--- 6 files changed, 108 insertions(+), 8 deletions(-) diff --git a/convex/httpApiV1.handlers.test.ts b/convex/httpApiV1.handlers.test.ts index c2f56ee2dd..3fe86ddd01 100644 --- a/convex/httpApiV1.handlers.test.ts +++ b/convex/httpApiV1.handlers.test.ts @@ -12739,6 +12739,26 @@ describe("httpApiV1 handlers", () => { ); it.each([ + [ + "oversized marked query", + `https://example.com/api/v1/plugins/search?q=${"x".repeat(257)}&searchSource=clawhub-web`, + 200, + ], + [ + "oversized marked topic", + `https://example.com/api/v1/plugins/search?q=weather&topic=${"x".repeat(121)}&searchSource=clawhub-web`, + 200, + ], + [ + "marked skill family", + "https://example.com/api/v1/plugins/search?q=weather&family=skill&searchSource=clawhub-web", + 200, + ], + [ + "marked claw family", + "https://example.com/api/v1/plugins/search?q=weather&family=claw&searchSource=clawhub-web", + 200, + ], ["unmarked plugin request", "https://example.com/api/v1/plugins/search?q=weather", 200], [ "unknown plugin source", @@ -12756,6 +12776,7 @@ describe("httpApiV1 handlers", () => { 400, ], ])("does not record %s", async (_case, requestUrl, expectedStatus) => { + if (_case === "marked claw family") vi.stubEnv("CLAWHUB_EXPERIMENTAL_CLAWS", "1"); const observationWrites: Record[] = []; const ctx = makeCtx({ runQuery: vi.fn().mockResolvedValue([]), @@ -12797,6 +12818,29 @@ describe("httpApiV1 handlers", () => { expect(observationWrites).toEqual([]); }); + it("excludes a request aborted before the completed result is recorded", async () => { + const controller = new AbortController(); + const observationWrites: unknown[] = []; + const ctx = makeCtx({ + runQuery: async () => { + controller.abort(); + return []; + }, + runMutation: (_mutation: unknown, args: Record) => { + if (isRateLimitArgs(args)) return okRate(); + observationWrites.push(args); + return null; + }, + }); + await __handlers.pluginsGetRouterV1Handler( + ctx, + new Request("https://example.com/api/v1/plugins/search?q=weather&searchSource=clawhub-web", { + signal: controller.signal, + }), + ); + expect(observationWrites).toEqual([]); + }); + it("keeps search available and logs no query when observation storage fails", async () => { const log = vi.spyOn(console, "error").mockImplementation(() => {}); const ctx = makeCtx({ diff --git a/convex/httpApiV1/packagesV1.ts b/convex/httpApiV1/packagesV1.ts index aaf913c37f..ec9ca44db8 100644 --- a/convex/httpApiV1/packagesV1.ts +++ b/convex/httpApiV1/packagesV1.ts @@ -4149,7 +4149,11 @@ async function searchPackages( .slice(0, limit); } const publicResults = results.map(toPublicCatalogSearchEntry); - if (options?.recordPluginSearch) { + if ( + options?.recordPluginSearch && + !request.signal.aborted && + (!family || family === "code-plugin" || family === "bundle-plugin") + ) { const observation = buildPluginSearchObservation({ source: parsePluginSearchSource(url.searchParams.get("searchSource")), query: queryText, diff --git a/convex/lib/pluginSearchObservations.ts b/convex/lib/pluginSearchObservations.ts index 457efe19c2..89a79a245f 100644 --- a/convex/lib/pluginSearchObservations.ts +++ b/convex/lib/pluginSearchObservations.ts @@ -16,6 +16,15 @@ export function normalizePluginSearchQuery(value: string) { return value.trim().replace(/\s+/gu, " ").toLowerCase(); } +export function isBoundedPluginSearchText(query: string, category?: string, topic?: string) { + return ( + query.length > 0 && + query.length <= 256 && + (category?.length ?? 0) <= 120 && + (topic?.length ?? 0) <= 120 + ); +} + export function buildPluginSearchObservation(params: { source: PluginSearchSource | undefined; query: string; @@ -24,13 +33,15 @@ export function buildPluginSearchObservation(params: { results: PluginSearchResult[]; }) { if (!params.source) return null; + const normalizedQuery = normalizePluginSearchQuery(params.query); + if (!isBoundedPluginSearchText(normalizedQuery, params.category, params.topic)) return null; return { source: params.source, artifactKind: "plugin" as const, - normalizedQuery: normalizePluginSearchQuery(params.query), + normalizedQuery, category: params.category, topic: params.topic, resultCount: params.results.length, - officialResultCount: params.results.filter((entry) => entry.package.isOfficial).length, + officialResultCount: params.results.filter((entry) => entry.package.isOfficial === true).length, }; } diff --git a/convex/pluginSearchObservations.test.ts b/convex/pluginSearchObservations.test.ts index 2e30490f8b..4d962bc356 100644 --- a/convex/pluginSearchObservations.test.ts +++ b/convex/pluginSearchObservations.test.ts @@ -23,6 +23,22 @@ afterEach(() => { }); describe("plugin search observations", () => { + it.each([ + { normalizedQuery: "" }, + { normalizedQuery: " Not normalized " }, + { normalizedQuery: "x".repeat(257) }, + { category: "x".repeat(121) }, + { topic: "x".repeat(121) }, + { resultCount: -1 }, + { resultCount: 1.5 }, + { officialResultCount: 4 }, + ])("rejects invalid bounded facts before persistence: %j", async (override) => { + const t = convexTest(schema, modules); + await expect( + t.mutation(internal.pluginSearchObservations.recordInternal, { ...observation, ...override }), + ).rejects.toThrow(); + expect(await t.run((ctx) => ctx.db.query("pluginSearchObservations").collect())).toEqual([]); + }); it("persists only the bounded search facts with the server observation time", async () => { const now = Date.UTC(2026, 8, 8, 22); vi.spyOn(Date, "now").mockReturnValue(now); diff --git a/convex/pluginSearchObservations.ts b/convex/pluginSearchObservations.ts index 754ce19182..ff703c7f07 100644 --- a/convex/pluginSearchObservations.ts +++ b/convex/pluginSearchObservations.ts @@ -1,6 +1,10 @@ import { v } from "convex/values"; import { internal } from "./_generated/api"; import { internalMutation } from "./functions"; +import { + isBoundedPluginSearchText, + normalizePluginSearchQuery, +} from "./lib/pluginSearchObservations"; import { RETENTION_STANDARD_BATCH_SIZE } from "./lib/retentionPolicy"; const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1_000; @@ -25,6 +29,18 @@ export const recordInternal = internalMutation({ officialResultCount: v.number(), }, handler: async (ctx, args) => { + if ( + !isBoundedPluginSearchText(args.normalizedQuery, args.category, args.topic) || + normalizePluginSearchQuery(args.normalizedQuery) !== args.normalizedQuery || + !Number.isSafeInteger(args.resultCount) || + args.resultCount < 0 || + args.resultCount > 100 || + !Number.isSafeInteger(args.officialResultCount) || + args.officialResultCount < 0 || + args.officialResultCount > args.resultCount + ) { + throw new Error("Invalid bounded plugin search observation"); + } const observedAt = Date.now(); const id = await ctx.db.insert("pluginSearchObservations", { ...args, observedAt }); return { id, observedAt }; diff --git a/specs/plugin-search-intelligence.md b/specs/plugin-search-intelligence.md index ca76e53989..a1a93ad937 100644 --- a/specs/plugin-search-intelligence.md +++ b/specs/plugin-search-intelligence.md @@ -14,24 +14,28 @@ marker: - `openclaw-control-ui` The marker is not authorization and grants no access or behavior. Missing or -unknown markers are ignored. Package-family, Skills, CLI, crawler, URL-load, +unknown markers are ignored. Generic package API, Skills, CLI, crawler, URL-load, and generic API requests remain unobserved unless a later product decision adds an explicit source. The observation is written after the visible response has been assembled, so `resultCount` and `officialResultCount` describe that exact response. Official -means company- or publisher-supported provenance (`package.isOfficial`), not -popularity, ranking, or relevance. +means returned authoritative `package.isOfficial === true`, never truthiness, +names, publisher guesses, popularity, ranking, relevance, or model judgment. +Explicit plugin-family filtering can count; Skills and Claw families cannot. +A request visibly aborted before persistence is excluded. A cancellation after +server completion cannot retract an observation; no receipt or request tracking +is introduced. ## Privacy boundary Raw observations contain only: -- normalized query text (lowercase; surrounding and repeated whitespace only) +- normalized query text (lowercase; surrounding and repeated whitespace only), at most256 characters - observation timestamp - bounded source - `artifactKind: "plugin"` -- selected category and topic/intent filters +- selected category and topic/intent filters, at most120 characters each - visible result and official-result counts The search-intelligence path must not read, derive, pass through, or persist IP, @@ -40,6 +44,11 @@ browser, operating system, geography, or OpenClaw version metadata. Existing HTTP rate limiting remains an independent security boundary and does not feed product analytics. +Oversized observations are skipped, never truncated into another query. Counts +are integers between0 and100 with official count no greater than total. Ordinary +search authorization/private visibility is preserved but never copied into +the observation. + Raw observations expire after 30 days through indexed, bounded, resumable cleanup. Longer-lived daily aggregates are owned by a later layer and must not add identity or request metadata. From 5b41ae039f6c97fa4ba88d26676d3272dd7f54e8 Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Tue, 15 Sep 2026 12:07:18 -0700 Subject: [PATCH 3/4] style: normalize capture imports after main rebase --- convex/httpApiV1/packagesV1.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/convex/httpApiV1/packagesV1.ts b/convex/httpApiV1/packagesV1.ts index ec9ca44db8..b35bcc1169 100644 --- a/convex/httpApiV1/packagesV1.ts +++ b/convex/httpApiV1/packagesV1.ts @@ -63,11 +63,11 @@ import { getPackageTrustReasons, resolvePackageReleaseScanStatus, } from "../lib/packageSecurity"; -import type { PublicPublisher } from "../lib/public"; import { buildPluginSearchObservation, parsePluginSearchSource, } from "../lib/pluginSearchObservations"; +import type { PublicPublisher } from "../lib/public"; import { getClawPackSizeError, getPackageMultipartSizeError, From fcc9099e303484db35204ff88f02164ff8eeeeef Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Tue, 15 Sep 2026 12:33:21 -0700 Subject: [PATCH 4/4] test: wait for editable publish metadata before clearing topics --- src/__tests__/plugins-publish-route.test.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/__tests__/plugins-publish-route.test.tsx b/src/__tests__/plugins-publish-route.test.tsx index c5d40eaf34..5ac4e2d3e5 100644 --- a/src/__tests__/plugins-publish-route.test.tsx +++ b/src/__tests__/plugins-publish-route.test.tsx @@ -820,7 +820,13 @@ describe("plugins publish route", () => { fireEvent.change(getFileInput(), { target: { files: [packageJson, manifest] } }); await waitFor(() => { - expect(screen.getByRole("button", { name: "Remove GPU development keyword" })).toBeTruthy(); + expect( + ( + screen.getByRole("button", { + name: "Remove GPU development keyword", + }) as HTMLButtonElement + ).disabled, + ).toBe(false); }); expect(screen.queryByRole("button", { name: "Categories" })).toBeNull(); fireEvent.click(screen.getByRole("button", { name: "Remove GPU development keyword" }));