diff --git a/docs/gateway/telemetry.md b/docs/gateway/telemetry.md index 7a1b3297fc3f..301f1aba341d 100644 --- a/docs/gateway/telemetry.md +++ b/docs/gateway/telemetry.md @@ -94,7 +94,8 @@ When you explicitly enable feature statistics, the same daily request becomes a "features": { "channels": ["discord", "telegram"], "providerFamilies": ["anthropic", "openai"], - "pluginsEnabled": 7, + "plugins": ["codex", "diagnostics-otel"], + "pluginsEnabled": 9, "sessionsLast24h": 14 } } @@ -107,11 +108,18 @@ When you explicitly enable feature statistics, the same daily request becomes a | `platform` | Operating system and CPU architecture. | | `node` | Running Node.js version. | | `surface` | Request origin: `gateway` or `cli`. | -| `features.channels` | Enabled channel plugin names, sorted alphabetically. | -| `features.providerFamilies` | Configured provider names, sorted alphabetically; never model names. | -| `features.pluginsEnabled` | Number of enabled plugins, without plugin names or configuration details. | +| `features.channels` | Publicly known enabled channel plugin names, sorted alphabetically. | +| `features.providerFamilies` | Publicly known configured provider names; never model names. | +| `features.plugins` | Publicly known enabled plugin names, sorted alphabetically. | +| `features.pluginsEnabled` | Total enabled plugins, including privately developed plugins never named. | | `features.sessionsLast24h` | Number of sessions observed during the preceding 24 hours. | +OpenClaw names only plugins and channels that are bundled with OpenClaw or +already appear in its official plugin catalog. Privately developed plugins are +counted but never named because a private plugin name could identify its +organization. Subtract `features.plugins.length` from `features.pluginsEnabled` +to find the number of unnamed private plugins. + The sender and `openclaw telemetry show` use the same payload builder, so the JSON displayed by the CLI is the same payload the sender would use at that moment. diff --git a/src/config/model-provider-config.ts b/src/config/model-provider-config.ts index 868c96de9cfb..267358f9317b 100644 --- a/src/config/model-provider-config.ts +++ b/src/config/model-provider-config.ts @@ -9,6 +9,93 @@ type MergedModelProviderEntry = { providerConfig: ModelProviderConfig; }; +const BUILT_IN_MODEL_PROVIDER_OVERLAY_IDS = new Set([ + "amazon-bedrock", + "amazon-bedrock-mantle", + "anthropic", + "anthropic-vertex", + "arcee", + "azure-openai-responses", + "byteplus", + "byteplus-plan", + "cerebras", + "chutes", + "claude-cli", + "clawrouter", + "cloudflare-ai-gateway", + "codex", + "comfy", + "copilot-proxy", + "dashscope", + "deepinfra", + "deepseek", + "fal", + "fireworks", + "github-copilot", + "gmi", + "gmi-cloud", + "gmicloud", + "google", + "google-antigravity", + "google-gemini-cli", + "google-vertex", + "groq", + "huggingface", + "kilocode", + "kimi", + "kimi-coding", + "litellm", + "lmstudio", + "meta", + "microsoft-foundry", + "minimax", + "minimax-portal", + "mistral", + "modelstudio", + "moonshot", + "moonshot-ai", + "moonshotai", + "nvidia", + "novita", + "novita-ai", + "novitaai", + "ollama", + "ollama-cloud", + "openai", + "opencode", + "opencode-go", + "openrouter", + "qianfan", + "qwen", + "qwen-token-plan", + "qwencloud", + "sglang", + "stepfun", + "stepfun-plan", + "synthetic", + "tencent-tokenhub", + "tencent-tokenplan", + "together", + "venice", + "vercel-ai-gateway", + "vllm", + "volcengine", + "volcengine-plan", + "vydra", + "x-ai", + "xai", + "xiaomi", + "xiaomi-token-plan", + "z.ai", + "z-ai", + "zai", +]); + +/** Identifies provider overlays already known to the bundled config contract. */ +export function isBuiltInModelProviderOverlayId(providerId: string): boolean { + return BUILT_IN_MODEL_PROVIDER_OVERLAY_IDS.has(normalizeProviderId(providerId)); +} + /** Indexes configured model rows after caller-owned model-id normalization. */ export function resolveMergedModelProviderModels(params: { models: readonly ModelDefinitionConfig[] | undefined; diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 4afbf7d2f380..345a9e12cb7a 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -1,6 +1,5 @@ // Defines core Zod schema fragments for canonical config parsing. import path from "node:path"; -import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization"; import { z } from "zod"; import { isSafeExecutableValue } from "../infra/exec-safety.js"; @@ -10,12 +9,15 @@ import { isValidFileSecretRefId, SECRET_PROVIDER_ALIAS_PATTERN, } from "../secrets/ref-contract.js"; +import { isBuiltInModelProviderOverlayId } from "./model-provider-config.js"; import type { ModelCompatConfig } from "./types.models.js"; import { MODEL_APIS, MODEL_THINKING_FORMATS } from "./types.models.js"; import { ENV_SECRET_REF_ID_RE } from "./types.secrets.js"; import { createAllowDenyChannelRulesSchema } from "./zod-schema.allowdeny.js"; import { sensitive } from "./zod-schema.sensitive.js"; +export { isBuiltInModelProviderOverlayId } from "./model-provider-config.js"; + const WINDOWS_ABS_PATH_PATTERN = /^[A-Za-z]:[\\/]/; const WINDOWS_UNC_PATH_PATTERN = /^\\\\[^\\]+\\[^\\]+/; @@ -451,92 +453,6 @@ const ModelProviderLocalServiceSchema = z .strict() .optional(); -const BUILT_IN_MODEL_PROVIDER_OVERLAY_IDS = new Set([ - "amazon-bedrock", - "amazon-bedrock-mantle", - "anthropic", - "anthropic-vertex", - "arcee", - "azure-openai-responses", - "byteplus", - "byteplus-plan", - "cerebras", - "chutes", - "claude-cli", - "clawrouter", - "cloudflare-ai-gateway", - "codex", - "comfy", - "copilot-proxy", - "dashscope", - "deepinfra", - "deepseek", - "fal", - "fireworks", - "github-copilot", - "gmi", - "gmi-cloud", - "gmicloud", - "google", - "google-antigravity", - "google-gemini-cli", - "google-vertex", - "groq", - "huggingface", - "kilocode", - "kimi", - "kimi-coding", - "litellm", - "lmstudio", - "meta", - "microsoft-foundry", - "minimax", - "minimax-portal", - "mistral", - "modelstudio", - "moonshot", - "moonshot-ai", - "moonshotai", - "nvidia", - "novita", - "novita-ai", - "novitaai", - "ollama", - "ollama-cloud", - "openai", - "opencode", - "opencode-go", - "openrouter", - "qianfan", - "qwen", - "qwen-token-plan", - "qwencloud", - "sglang", - "stepfun", - "stepfun-plan", - "synthetic", - "tencent-tokenhub", - "tencent-tokenplan", - "together", - "venice", - "vercel-ai-gateway", - "vllm", - "volcengine", - "volcengine-plan", - "vydra", - "x-ai", - "xai", - "xiaomi", - "xiaomi-token-plan", - "z.ai", - "z-ai", - "zai", -]); - -export function isBuiltInModelProviderOverlayId(providerId: string): boolean { - return BUILT_IN_MODEL_PROVIDER_OVERLAY_IDS.has(normalizeProviderId(providerId)); -} - const ModelProviderSchema = z .object({ // Bundled provider overlays are materialized with an empty-string sentinel. diff --git a/src/infra/telemetry.test.ts b/src/infra/telemetry.test.ts index c3591c7f0125..c48a8d13a596 100644 --- a/src/infra/telemetry.test.ts +++ b/src/infra/telemetry.test.ts @@ -1,5 +1,8 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; +import * as pluginRuntime from "../plugins/runtime.js"; +import { createPluginRecord } from "../plugins/status.test-helpers.js"; import { readConfigMachineState } from "../state/config-machine-state.js"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { setTestEnvValue } from "../test-utils/env.js"; @@ -17,6 +20,12 @@ const TELEMETRY_URL = "https://telemetry.openclaw.ai/api/latest-version"; const TELEMETRY_STATE_KEY = "telemetry.updateCheck"; const mockHttp = useMockHttp(); +function installPluginRegistry(...plugins: Parameters[0][]): void { + const registry = createEmptyPluginRegistry(); + registry.plugins.push(...plugins.map((plugin) => createPluginRecord(plugin))); + pluginRuntime.setActivePluginRegistry(registry); +} + function createFeatureConfig(enabled = true): OpenClawConfig { return { telemetry: { enabled }, @@ -32,6 +41,7 @@ function createFeatureConfig(enabled = true): OpenClawConfig { channels: { telegram: { enabled: true, botToken: "private-telegram-token" }, discord: { enabled: true, token: "private-discord-token" }, + "acme-internal-crm": { enabled: true }, slack: { enabled: false, botToken: "private-slack-token" }, defaults: { groupPolicy: "allowlist" }, modelByChannel: { telegram: { "private-account-id": "openai/private-model" } }, @@ -48,6 +58,10 @@ function createFeatureConfig(enabled = true): OpenClawConfig { apiKey: "private-anthropic-api-key", models: [], }, + "acme-llm": { + baseUrl: "https://private-llm.example.invalid/v1", + models: [], + }, }, }, plugins: { @@ -55,6 +69,8 @@ function createFeatureConfig(enabled = true): OpenClawConfig { telegram: { enabled: true }, discord: { enabled: true }, memory: { enabled: true }, + "acme-internal-crm": { enabled: true }, + "acme-internal-workflows": { enabled: true }, disabled: { enabled: false }, }, }, @@ -76,6 +92,16 @@ describe("anonymous telemetry", () => { OPENCLAW_TELEMETRY_ENDPOINT: undefined, }, }); + installPluginRegistry( + { id: "telegram", origin: "bundled", channelIds: ["telegram"] }, + { id: "discord", origin: "bundled", channelIds: ["discord"] }, + { id: "memory", origin: "bundled" }, + { id: "acme-internal-crm", channelIds: ["acme-internal-crm"] }, + { id: "acme-internal-workflows" }, + { id: "disabled", origin: "bundled", enabled: false, status: "disabled" }, + { id: "load-error", origin: "bundled", status: "error" }, + { id: "deferred", origin: "bundled", imported: false }, + ); }); afterEach(async () => { @@ -96,7 +122,8 @@ describe("anonymous telemetry", () => { features: { channels: ["discord", "telegram"], providerFamilies: ["anthropic", "openai"], - pluginsEnabled: 3, + plugins: ["discord", "memory", "telegram"], + pluginsEnabled: 5, sessionsLast24h: expect.any(Number), }, }); @@ -104,20 +131,27 @@ describe("anonymous telemetry", () => { /"(?:id|accountId|userId|machineId|installId|token|apiKey|secret|password|prompt|message|host|hostname|baseUrl|path|email|models)"\s*:/iu, ); expect(serialized).not.toContain("private-"); + expect(serialized).not.toContain("acme-internal-crm"); + expect(serialized).not.toContain("acme-internal-workflows"); + expect(serialized).not.toContain("acme-llm"); expect(serialized).not.toContain("example.invalid"); expect(serialized).not.toContain("@"); expect(serialized).not.toContain(testState.stateDir); expect(payload.features.sessionsLast24h).toBeGreaterThanOrEqual(0); }); - it("counts auto-enabled channel plugins and provider families configured through agent models", () => { + it("counts loaded default plugins instead of unloaded config entries and accepts official provider families", () => { + installPluginRegistry( + { id: "whatsapp", origin: "bundled", channelIds: ["whatsapp"] }, + { id: "diagnostics-otel", origin: "bundled" }, + ); const payload = buildTelemetryPayload( { agents: { defaults: { model: { primary: "anthropic/private-model", - fallbacks: ["openai/private-fallback"], + fallbacks: ["openai/private-fallback", "cohere/private-official-model"], }, }, entries: { @@ -125,19 +159,57 @@ describe("anonymous telemetry", () => { }, }, channels: { whatsapp: { allowFrom: ["+15555550123"] } }, + plugins: { entries: { "never-loaded": { enabled: true } } }, }, { surface: "gateway" }, ); expect(payload.features).toMatchObject({ channels: ["whatsapp"], - providerFamilies: ["anthropic", "google", "openai"], - pluginsEnabled: 1, + providerFamilies: ["anthropic", "cohere", "google", "openai"], + plugins: ["diagnostics-otel", "whatsapp"], + pluginsEnabled: 2, }); expect(JSON.stringify(payload)).not.toContain("private-"); expect(JSON.stringify(payload)).not.toContain("+15555550123"); }); + it("classifies configured channels by their loaded plugin owner", () => { + installPluginRegistry( + { id: "public-channel-owner", origin: "bundled", channelIds: ["public-alias"] }, + { id: "acme-internal-crm", channelIds: ["telegram"] }, + ); + + const payload = buildTelemetryPayload( + { channels: { "public-alias": { enabled: true }, telegram: { enabled: true } } }, + { surface: "gateway" }, + ); + + expect(payload.features.channels).toEqual(["public-alias"]); + expect(payload.features.plugins).toEqual(["public-channel-owner"]); + expect(payload.features.pluginsEnabled).toBe(2); + expect(JSON.stringify(payload)).not.toContain("acme-internal-crm"); + }); + + it("uses manifest-owned plugin activation when a CLI has no active runtime registry", () => { + const activeRegistry = vi.spyOn(pluginRuntime, "getActivePluginRegistry").mockReturnValue(null); + try { + const payload = buildTelemetryPayload( + { + channels: { telegram: { enabled: true } }, + plugins: { allow: ["telegram"] }, + }, + { surface: "cli" }, + ); + + expect(payload.features.channels).toEqual(["telegram"]); + expect(payload.features.plugins).toContain("telegram"); + expect(payload.features.pluginsEnabled).toBe(payload.features.plugins.length); + } finally { + activeRegistry.mockRestore(); + } + }); + it("counts only session creation events from the previous 24 hours", async () => { const { recordSessionStateEvent } = await import("../sessions/session-state-events.js"); const now = Date.now(); diff --git a/src/infra/telemetry.ts b/src/infra/telemetry.ts index dcfe8d43c638..fb799a9ff330 100644 --- a/src/infra/telemetry.ts +++ b/src/infra/telemetry.ts @@ -3,8 +3,12 @@ import { parseModelCatalogRef } from "@openclaw/model-catalog-core/model-catalog import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { z } from "zod"; import { isChannelConfigMetadataKey } from "../channels/config-metadata.js"; +import { isBuiltInModelProviderOverlayId } from "../config/model-provider-config.js"; import { resolveIsNixMode } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { resolveOfficialExternalProviderPluginIds } from "../plugins/official-external-plugin-catalog.js"; +import { isPubliclyKnownPluginId } from "../plugins/plugin-public-identity.js"; +import { listEnabledPluginRecords } from "../plugins/plugin-runtime-inventory.js"; import { readConfigMachineState, writeConfigMachineState } from "../state/config-machine-state.js"; import { withExistingOpenClawStateDatabaseReadOnly } from "../state/openclaw-state-db-readonly.js"; import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js"; @@ -42,6 +46,7 @@ type TelemetryPayload = { features: { channels: string[]; providerFamilies: string[]; + plugins: string[]; pluginsEnabled: number; sessionsLast24h: number; }; @@ -81,17 +86,6 @@ function isDoNotTrackEnabled(): boolean { return value === "1" || value === "true"; } -function isEnabledPlugin(config: OpenClawConfig, pluginId: string): boolean { - const plugins = config.plugins; - if (plugins?.enabled === false || plugins?.deny?.includes(pluginId)) { - return false; - } - if (plugins?.allow && plugins.allow.length > 0 && !plugins.allow.includes(pluginId)) { - return false; - } - return plugins?.entries?.[pluginId]?.enabled !== false; -} - function countRecentSessions(nowMs: number): number { try { return ( @@ -163,6 +157,9 @@ export function buildTelemetryPayload( config: OpenClawConfig, options: { surface: TelemetrySurface }, ): TelemetryPayload { + const enabledPlugins = listEnabledPluginRecords(config); + const publicPlugins = enabledPlugins.filter(isPubliclyKnownPluginId); + const publicChannelIds = new Set(publicPlugins.flatMap((plugin) => plugin.channelIds)); const channels = Object.entries(config.channels ?? {}) .filter( ([channelId, channelConfig]) => @@ -170,7 +167,7 @@ export function buildTelemetryPayload( !isChannelConfigMetadataKey(channelId) && isRecord(channelConfig) && channelConfig.enabled !== false && - isEnabledPlugin(config, channelId), + publicChannelIds.has(channelId), ) .map(([channelId]) => channelId) .toSorted(); @@ -185,12 +182,17 @@ export function buildTelemetryPayload( ), ]; const providerFamilies = [...new Set(configuredProviders)] - .filter((providerId) => SAFE_FEATURE_NAME.test(providerId)) + .filter( + (providerId) => + SAFE_FEATURE_NAME.test(providerId) && + (isBuiltInModelProviderOverlayId(providerId) || + resolveOfficialExternalProviderPluginIds({ providerIds: new Set([providerId]) }).length > + 0), + ) + .toSorted(); + const plugins = [...new Set(publicPlugins.map((plugin) => plugin.id))] + .filter((pluginId) => SAFE_FEATURE_NAME.test(pluginId)) .toSorted(); - const enabledPluginEntries = Object.keys(config.plugins?.entries ?? {}).filter( - (pluginId) => SAFE_FEATURE_NAME.test(pluginId) && isEnabledPlugin(config, pluginId), - ); - const pluginsEnabled = new Set([...channels, ...enabledPluginEntries]).size; return { schema: 1, @@ -201,7 +203,8 @@ export function buildTelemetryPayload( features: { channels, providerFamilies, - pluginsEnabled, + plugins, + pluginsEnabled: enabledPlugins.length, sessionsLast24h: countRecentSessions(Date.now()), }, }; diff --git a/src/plugins/plugin-public-identity.test.ts b/src/plugins/plugin-public-identity.test.ts new file mode 100644 index 000000000000..e4d95a9b107b --- /dev/null +++ b/src/plugins/plugin-public-identity.test.ts @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import * as officialCatalog from "./official-external-plugin-catalog.js"; +import { isPubliclyKnownPluginId } from "./plugin-public-identity.js"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("isPubliclyKnownPluginId", () => { + it.each([ + { + name: "bundled plugins", + plugin: { id: "bundled-plugin", origin: "bundled" as const }, + expected: true, + }, + { + name: "verified official installs", + plugin: { id: "official-plugin", origin: "global" as const, trustedOfficialInstall: true }, + expected: true, + }, + { + name: "official source checkouts with their catalog package", + plugin: { + id: "opencode", + origin: "workspace" as const, + packageName: "@openclaw/opencode-provider", + }, + expected: true, + }, + { + name: "private packages squatting on an official plugin id", + plugin: { id: "opencode", origin: "workspace" as const, packageName: "@acme/private" }, + expected: false, + }, + { + name: "unknown private plugin ids", + plugin: { id: "acme-internal-crm", origin: "workspace" as const }, + expected: false, + }, + ])("classifies $name", ({ plugin, expected }) => { + expect(isPubliclyKnownPluginId(plugin)).toBe(expected); + }); + + it("never consults the hosted catalog when classifying private plugin identities", () => { + const hostedCatalog = vi + .spyOn(officialCatalog, "loadConfiguredHostedOfficialExternalPluginCatalogEntries") + .mockResolvedValue({ + source: "bundled-fallback", + entries: [{ id: "acme-internal-crm", name: "@acme/internal-crm" }], + error: "hosted fixture", + }); + + expect( + isPubliclyKnownPluginId({ + id: "acme-internal-crm", + origin: "global", + packageName: "@acme/internal-crm", + }), + ).toBe(false); + expect(hostedCatalog).not.toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/plugin-public-identity.ts b/src/plugins/plugin-public-identity.ts new file mode 100644 index 000000000000..c18aa2fd610b --- /dev/null +++ b/src/plugins/plugin-public-identity.ts @@ -0,0 +1,23 @@ +import { + getOfficialExternalPluginCatalogEntryForPackage, + isOfficialExternalPluginId, + resolveOfficialExternalPluginId, +} from "./official-external-plugin-catalog.js"; +import type { PluginRecord } from "./registry-types.js"; + +type PluginPublicIdentityInput = Pick< + PluginRecord, + "id" | "origin" | "packageName" | "trustedOfficialInstall" +>; + +/** True when a plugin identity is already public and safe to report. */ +export function isPubliclyKnownPluginId(plugin: PluginPublicIdentityInput): boolean { + if (plugin.origin === "bundled" || plugin.trustedOfficialInstall === true) { + return true; + } + if (!isOfficialExternalPluginId(plugin.id)) { + return false; + } + const catalogEntry = getOfficialExternalPluginCatalogEntryForPackage(plugin.packageName); + return catalogEntry !== undefined && resolveOfficialExternalPluginId(catalogEntry) === plugin.id; +} diff --git a/src/plugins/plugin-runtime-inventory.ts b/src/plugins/plugin-runtime-inventory.ts new file mode 100644 index 000000000000..4b390ac15116 --- /dev/null +++ b/src/plugins/plugin-runtime-inventory.ts @@ -0,0 +1,44 @@ +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { normalizePluginsConfig, resolveEffectivePluginActivationState } from "./config-state.js"; +import { isPluginEnabledByDefaultForPlatform } from "./default-enablement.js"; +import { loadPluginManifestRegistryCore } from "./manifest-registry.js"; +import type { PluginRecord } from "./registry-types.js"; +import { getActivePluginRegistry } from "./runtime.js"; + +type EnabledPluginRecord = Pick< + PluginRecord, + "id" | "origin" | "packageName" | "trustedOfficialInstall" | "channelIds" +>; + +/** Lists loaded plugin identities, or configured manifest identities before runtime activation. */ +export function listEnabledPluginRecords(config: OpenClawConfig): EnabledPluginRecord[] { + const registry = getActivePluginRegistry(); + if (registry) { + return registry.plugins.filter( + (plugin) => + plugin.enabled && + plugin.status === "loaded" && + (plugin.format === "bundle" || plugin.imported !== false), + ); + } + + const normalizedConfig = normalizePluginsConfig(config.plugins); + return loadPluginManifestRegistryCore({ config }) + .plugins.filter( + (plugin) => + resolveEffectivePluginActivationState({ + id: plugin.id, + origin: plugin.origin, + config: normalizedConfig, + rootConfig: config, + enabledByDefault: isPluginEnabledByDefaultForPlatform(plugin), + }).enabled, + ) + .map((plugin) => ({ + id: plugin.id, + origin: plugin.origin, + packageName: plugin.packageName, + trustedOfficialInstall: plugin.trustedOfficialInstall, + channelIds: plugin.channels, + })); +}