kumarabhirup eaef8df20b
fix(telemetry): prevent anonymousId from leaking via npm package
The root layout called getOrCreateAnonymousId() in a Server Component
without marking the route as dynamic. Next.js treated it as static,
pre-rendering the developer's UUID into the standalone build shipped
via npm — so every `npx denchclaw` user shared the same PostHog identity.

- Add `export const dynamic = "force-dynamic"` to root layout
- Replace `process.env.HOME || "~"` fallback with `homedir()` in web
  telemetry and posthog-analytics plugin (Node.js path.join doesn't
  expand "~", creating a relative path under cwd instead)
2026-03-06 23:30:31 -08:00

258 lines
7.7 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { homedir } from "node:os";
import { join, dirname } from "node:path";
const SECRETS_PATTERN =
/(?:sk-[a-zA-Z0-9]{20,}|ghp_[a-zA-Z0-9]{36}|xoxb-[a-zA-Z0-9-]+|AKIA[A-Z0-9]{16}|eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,})/g;
const REDACTED = "[REDACTED]";
function resolveConfigPath(openclawConfig?: any): string {
const stateDir =
openclawConfig?.stateDir ??
join(process.env.HOME || homedir(), ".openclaw-dench");
return join(stateDir, "telemetry.json");
}
/**
* Read privacy mode from DenchClaw's telemetry config.
* Default is true (privacy on) when the file is missing or unreadable.
*/
export function readPrivacyMode(openclawConfig?: any): boolean {
try {
const configPath = resolveConfigPath(openclawConfig);
if (!existsSync(configPath)) return true;
const raw = JSON.parse(readFileSync(configPath, "utf-8"));
return raw.privacyMode !== false;
} catch {
return true;
}
}
let _cachedAnonymousId: string | null = null;
/**
* Read the persisted install-scoped anonymous ID from telemetry.json,
* generating and writing one if absent.
*/
export function readOrCreateAnonymousId(openclawConfig?: any): string {
if (_cachedAnonymousId) return _cachedAnonymousId;
try {
const configPath = resolveConfigPath(openclawConfig);
let raw: Record<string, unknown> = {};
if (existsSync(configPath)) {
raw = JSON.parse(readFileSync(configPath, "utf-8"));
}
if (typeof raw.anonymousId === "string" && raw.anonymousId) {
_cachedAnonymousId = raw.anonymousId;
return raw.anonymousId;
}
const id = randomUUID();
raw.anonymousId = id;
mkdirSync(dirname(configPath), { recursive: true });
writeFileSync(configPath, JSON.stringify(raw, null, 2) + "\n", "utf-8");
_cachedAnonymousId = id;
return id;
} catch {
const id = randomUUID();
_cachedAnonymousId = id;
return id;
}
}
/** Strip known credential patterns from any string value. */
export function stripSecrets(value: unknown): unknown {
if (typeof value === "string") {
return value.replace(SECRETS_PATTERN, REDACTED);
}
if (Array.isArray(value)) {
return value.map(stripSecrets);
}
if (value && typeof value === "object") {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) {
const keyLower = k.toLowerCase();
if (
keyLower.includes("key") ||
keyLower.includes("token") ||
keyLower.includes("secret") ||
keyLower.includes("password") ||
keyLower.includes("credential")
) {
out[k] = REDACTED;
} else {
out[k] = stripSecrets(v);
}
}
return out;
}
return value;
}
/**
* Redact message content for privacy mode.
* Preserves structure (role, tool names) but removes actual text content.
*/
export function redactMessages(messages: unknown): unknown {
if (!Array.isArray(messages)) return messages;
return messages.map((msg: any) => {
if (!msg || typeof msg !== "object") return msg;
const redacted: Record<string, unknown> = { role: msg.role };
if (msg.name) redacted.name = msg.name;
if (msg.tool_call_id) redacted.tool_call_id = msg.tool_call_id;
redacted.content = REDACTED;
return redacted;
});
}
/**
* Sanitize a value based on privacy mode.
* When privacy is on: redacts content, always strips secrets.
* When privacy is off: only strips secrets.
*/
export function sanitizeForCapture(
value: unknown,
privacyMode: boolean,
): unknown {
if (privacyMode) return REDACTED;
return stripSecrets(value);
}
/**
* Redact a tool_calls array while preserving tool name, id, and type metadata.
* Only arguments are redacted.
*/
function redactToolCalls(toolCalls: unknown[]): unknown[] {
return toolCalls.map((tc: any) => {
if (!tc || typeof tc !== "object") return tc;
const out: Record<string, unknown> = {
id: tc.id,
type: tc.type ?? "function",
};
if (tc.function && typeof tc.function === "object") {
out.function = {
name: tc.function.name,
arguments: REDACTED,
};
}
if (tc.name) out.name = tc.name;
return out;
});
}
/**
* Redact Anthropic-format content blocks while preserving tool metadata.
* Text blocks get redacted; toolCall blocks keep their name but redact arguments.
*/
function redactContentBlocks(blocks: unknown[]): unknown[] {
return blocks.map((block: any) => {
if (!block || typeof block !== "object") return block;
if (block.type === "text") {
return { type: "text", text: REDACTED };
}
if (block.type === "toolCall") {
return {
type: "toolCall",
id: block.id ?? block.toolCallId,
name: block.name,
arguments: REDACTED,
};
}
if (block.type === "tool_use") {
return {
type: "tool_use",
id: block.id,
name: block.name,
input: REDACTED,
};
}
if (block.type === "thinking") {
return { type: "thinking", text: REDACTED };
}
return { type: block.type };
});
}
/**
* Structure-preserving message redaction for PostHog message-array fields.
* Preserves: role, tool names, tool_call IDs, message ordering, tool types.
* Redacts: text content, tool arguments, tool results.
*/
export function redactMessagesStructured(messages: unknown): unknown {
if (!Array.isArray(messages)) return messages;
return messages.map((msg: any) => {
if (!msg || typeof msg !== "object") return msg;
const out: Record<string, unknown> = { role: msg.role };
if (msg.name) out.name = msg.name;
if (msg.tool_call_id) out.tool_call_id = msg.tool_call_id;
if (msg.toolCallId) out.toolCallId = msg.toolCallId;
if (msg.toolName) out.toolName = msg.toolName;
if (msg.isError != null) out.isError = msg.isError;
if (msg.stopReason) out.stopReason = msg.stopReason;
if (msg.model) out.model = msg.model;
if (msg.provider) out.provider = msg.provider;
if (Array.isArray(msg.content)) {
out.content = redactContentBlocks(msg.content);
} else {
out.content = REDACTED;
}
if (Array.isArray(msg.tool_calls)) {
out.tool_calls = redactToolCalls(msg.tool_calls);
}
return out;
});
}
/**
* Structure-preserving redaction for normalized OpenAI-format output choices.
* Keeps role, tool_calls[].function.name, tool_calls[].type.
* Redacts text content and tool arguments.
*/
export function redactOutputChoicesStructured(choices: unknown): unknown {
if (!Array.isArray(choices)) return choices;
return choices.map((choice: any) => {
if (!choice || typeof choice !== "object") return choice;
const out: Record<string, unknown> = {
role: choice.role,
content: choice.content != null ? REDACTED : null,
};
if (Array.isArray(choice.tool_calls)) {
out.tool_calls = redactToolCalls(choice.tool_calls);
}
return out;
});
}
/**
* Sanitize messages for PostHog capture, preserving structure in privacy mode.
* Privacy on: redacts text content/arguments but keeps role, tool names, ordering.
* Privacy off: only strips credential patterns.
*/
export function sanitizeMessages(
messages: unknown,
privacyMode: boolean,
): unknown {
if (privacyMode) return redactMessagesStructured(messages);
return stripSecrets(messages);
}
/**
* Sanitize normalized output choices for PostHog capture, preserving structure.
* Privacy on: redacts text content/arguments but keeps role, tool names.
* Privacy off: only strips credential patterns.
*/
export function sanitizeOutputChoices(
choices: unknown,
privacyMode: boolean,
): unknown {
if (privacyMode) return redactOutputChoicesStructured(choices);
return stripSecrets(choices);
}