fix(telemetry): report only publicly known plugin identities (#128603)

This commit is contained in:
Peter Steinberger
2026-08-24 01:06:39 -07:00
committed by GitHub
parent af384662f7
commit 4ac8cd3dad
8 changed files with 330 additions and 115 deletions
+12 -4
View File
@@ -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.
+87
View File
@@ -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;
+3 -87
View File
@@ -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.
+78 -6
View File
@@ -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<typeof createPluginRecord>[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();
+21 -18
View File
@@ -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()),
},
};
@@ -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();
});
});
+23
View File
@@ -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;
}
+44
View File
@@ -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,
}));
}