diff --git a/docs/module-graph.md b/docs/module-graph.md index c9439d5501..4cc2c36eeb 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -140,6 +140,7 @@ flowchart TD pkg_helper["helper"] pkg_plugin_fetch["plugin-fetch"] pkg_scripts["scripts"] + pkg_telemetry["telemetry"] end subgraph group_tasks["packages/tasks"] pkg_tasks["tasks"] @@ -155,6 +156,7 @@ flowchart TD pkg_helper --> pkg_brand pkg_plugin_fetch --> pkg_brand pkg_scripts --> pkg_app_boot + pkg_telemetry --> pkg_brand pkg_llm_deepseek --> pkg_llm pkg_llm_pi_ai --> pkg_llm pkg_session --> pkg_brand @@ -446,6 +448,7 @@ flowchart TD | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand) | | [`plugin-fetch`](../packages/sdk/plugin-fetch) | `sdk` | [`brand`](../packages/util/brand) | | [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot) | +| [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`llm`](../packages/llm/llm) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`llm`](../packages/llm/llm) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | diff --git a/packages/sdk/telemetry/README.md b/packages/sdk/telemetry/README.md new file mode 100644 index 0000000000..3a8b6ac4c2 --- /dev/null +++ b/packages/sdk/telemetry/README.md @@ -0,0 +1,24 @@ +# `@deepseek-ai/dsh-telemetry` + +Launcher-side telemetry primitives for the dsh-sdk toolchain. This is a plain library the launcher imports around each command; it is **not** a Cordis plugin, because `build` and first-init `create` never boot Cordis. Wiring the reporter into the launcher command dispatch and adding the telemetry consent feature to the `dsh-helper` catalog live in their owning packages, not here. + +| Export | Role | +|---|---| +| `SecretRedactor` | Conservative safety backstop: replaces secret-shaped values (secret-like keys, known token shapes, PEM blocks, URL credentials, high-entropy opaque tokens) with a placeholder in both parsed values (`redactValue`) and raw text (`redactText`). Never drops a field or line. | +| `ConsentResolver` | Parses (never boots) a project `cordis.yml` and reads the telemetry entry's enabled/disabled state as consent; `DO_NOT_TRACK`/CI env force a hard opt-out. | +| `buildTelemetryPayload` | Assembles `{command, durationMs, success, cordisYmlContent, packageJsonContent}`, running the redactor over the full `cordis.yml` and `package.json` text. Never reads `.env`. | +| `getOrCreateAnonymousId` | Random UUID persisted in a per-user GLOBAL config file (never in the project, never derived from git). | +| `TelemetryReporter` | Fire-and-forget send: `report()` never blocks or throws; delivery resolves on every path; `flush()` optionally drains in-flight sends within a cap. | + +Consent is carried by the telemetry entry in `cordis.yml`, so disabling telemetry is disabling that entry. When `cordis.yml` does not yet exist (first `create`) consent defaults to allowed; when it exists without a telemetry entry consent defaults to denied — both are configurable on `ConsentResolver`. + +The collection endpoint is a fixed constant (`DSH_TELEMETRY_ENDPOINT`); its `.invalid` placeholder must be replaced with the real endpoint before release. + +## Model Experience + +None, as the reporter sends developer-cycle telemetry from the launcher and never reaches a model request. + +## Known Limitations and Deferred Work + +- **Placeholder endpoint** — `DSH_TELEMETRY_ENDPOINT` points at `.invalid` until the real endpoint is set. +- **Redaction is heuristic** — a conservative backstop, not a guarantee; secrets belong in `.env`, which is never read or reported. diff --git a/packages/sdk/telemetry/package.json b/packages/sdk/telemetry/package.json new file mode 100644 index 0000000000..fcb6efb797 --- /dev/null +++ b/packages/sdk/telemetry/package.json @@ -0,0 +1,35 @@ +{ + "name": "@deepseek-ai/dsh-telemetry", + "description": "Launcher-side dsh-sdk telemetry: secret redaction, consent resolution, anonymous id, payload builder, and fire-and-forget reporter", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "dependencies": { + "yaml": "^2.9.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-brand": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/sdk/telemetry/src/anonymous-id.ts b/packages/sdk/telemetry/src/anonymous-id.ts new file mode 100644 index 0000000000..030fefa19f --- /dev/null +++ b/packages/sdk/telemetry/src/anonymous-id.ts @@ -0,0 +1,106 @@ +/** + * Per-machine anonymous telemetry id. + * + * The id is a random UUID persisted in a per-user GLOBAL config file — never in + * the project, and never derived from the git remote, repository URL, or any + * other identifying source (a derived id would make "anonymous" a fiction). The + * same id is reused across projects on one machine so telemetry counts machines, + * not repositories. + * + * @module @deepseek-ai/dsh-telemetry/anonymous-id + */ + +import { randomUUID } from 'node:crypto' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { homedir } from 'node:os' +import { dirname, join } from 'node:path' +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** A machine-scoped anonymous telemetry id (random UUID v4). */ +export type AnonymousId = Branded<'AnonymousId'> + +/** Config directory name owned by the DeepSeek Harness across tools. */ +const CONFIG_NAMESPACE = 'deepseek-harness' + +/** Default file, inside the global config dir, storing the anonymous id. */ +export const ANONYMOUS_ID_FILE_NAME = 'telemetry.json' + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +/** Ambient seams for locating and generating the id; every field has a default. */ +export interface AnonymousIdOptions { + /** Environment consulted for `DSH_CONFIG_HOME`/`XDG_CONFIG_HOME`/`APPDATA`; defaults to `process.env`. */ + env?: NodeJS.ProcessEnv + /** Platform string used to pick the Windows path; defaults to `process.platform`. */ + platform?: NodeJS.Platform + /** Home directory resolver; defaults to `os.homedir`. */ + homeDir?: () => string + /** UUID generator; defaults to `crypto.randomUUID` (test seam). */ + randomUUID?: () => string +} + +/** + * Resolve the per-user global config directory for harness tooling. + * Precedence: `DSH_CONFIG_HOME` (explicit override) > `XDG_CONFIG_HOME` > + * platform default (`%APPDATA%` on Windows, else `~/.config`). + * @param options - environment, platform, and home-directory seams. + * @returns absolute config directory path for the harness namespace. + */ +export function globalConfigDir(options: AnonymousIdOptions = {}): string { + const env = options.env ?? process.env + const platform = options.platform ?? process.platform + const home = options.homeDir ?? homedir + if (env.DSH_CONFIG_HOME !== undefined && env.DSH_CONFIG_HOME.length > 0) return env.DSH_CONFIG_HOME + if (env.XDG_CONFIG_HOME !== undefined && env.XDG_CONFIG_HOME.length > 0) { + return join(env.XDG_CONFIG_HOME, CONFIG_NAMESPACE) + } + if (platform === 'win32' && env.APPDATA !== undefined && env.APPDATA.length > 0) { + return join(env.APPDATA, CONFIG_NAMESPACE) + } + return join(home(), '.config', CONFIG_NAMESPACE) +} + +/** Read a valid persisted id from the store, or `undefined` when absent/corrupt. */ +async function readPersistedId(file: string): Promise { + let text: string + try { + text = await readFile(file, 'utf8') + } catch { + // Absent or unreadable: the caller mints and persists a fresh id. + return undefined + } + let parsed: unknown + try { + parsed = JSON.parse(text) + } catch { + // Corrupt JSON: the caller overwrites the store with a fresh id. + return undefined + } + if (parsed !== null && typeof parsed === 'object') { + const value = (parsed as Record).anonymousId + if (typeof value === 'string' && UUID_PATTERN.test(value)) return value as AnonymousId + } + return undefined +} + +/** + * Return the machine's anonymous id, creating and persisting one on first use. + * Persistence is best-effort: a write failure still returns a usable id for the + * current run so telemetry is never blocked by config-dir permissions. + * @param options - config-location and UUID-generation seams. + * @returns the stable per-machine anonymous id. + */ +export async function getOrCreateAnonymousId(options: AnonymousIdOptions = {}): Promise { + const file = join(globalConfigDir(options), ANONYMOUS_ID_FILE_NAME) + const existing = await readPersistedId(file) + if (existing !== undefined) return existing + const generate = options.randomUUID ?? randomUUID + const created = generate() as AnonymousId + try { + await mkdir(dirname(file), { recursive: true }) + await writeFile(file, `${JSON.stringify({ anonymousId: created }, null, 2)}\n`, 'utf8') + } catch { + // Best-effort persistence: return the fresh id even when the store is unwritable. + } + return created +} diff --git a/packages/sdk/telemetry/src/consent-resolver.ts b/packages/sdk/telemetry/src/consent-resolver.ts new file mode 100644 index 0000000000..1d50904f39 --- /dev/null +++ b/packages/sdk/telemetry/src/consent-resolver.ts @@ -0,0 +1,125 @@ +/** + * Consent resolution for dsh-sdk telemetry. + * + * Consent is carried by the telemetry plugin's enabled/disabled state in the + * project `cordis.yml`: an enabled entry means opt-in, a `disabled: true` entry + * means opt-out. The resolver PARSES `cordis.yml` — it never boots a Cordis + * application — because several launcher commands (`build`, `create`) never + * boot Cordis at all. `DO_NOT_TRACK` and CI environment signals force a denial + * regardless of file state. + * + * @module @deepseek-ai/dsh-telemetry/consent-resolver + */ + +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { parseDocument, type ScalarTag } from 'yaml' + +/** Default `cordis.yml` entry name that carries telemetry consent. */ +export const DEFAULT_TELEMETRY_PLUGIN_NAME = '@deepseek-ai/dsh-telemetry' + +/** + * Passthrough for Cordis' `!!js` expression tag so parsing consent never fails + * on projects that inline JavaScript expressions; the resolver only reads plain + * `name`/`disabled` scalars and does not evaluate expressions. + */ +const JS_EXPRESSION_TAG: ScalarTag = { + tag: 'tag:yaml.org,2002:js', + resolve: value => value, +} + +/** Why telemetry is or is not permitted for one command. */ +export type ConsentReason = + | 'enabled' + | 'disabled' + | 'absent' + | 'no-config' + | 'do-not-track' + | 'ci' + | 'unreadable' + +/** Resolved telemetry consent for one command invocation. */ +export interface ConsentDecision { + /** Whether telemetry may be sent. */ + allowed: boolean + /** The signal that determined {@link allowed}. */ + reason: ConsentReason +} + +/** Tuning for {@link ConsentResolver}; every field defaults to a documented value. */ +export interface ConsentResolverOptions { + /** `cordis.yml` entry name whose enabled state carries consent. */ + telemetryPluginName?: string + /** Environment used for `DO_NOT_TRACK`/CI checks; defaults to `process.env`. */ + env?: NodeJS.ProcessEnv + /** Honor `DO_NOT_TRACK`/CI env signals as a hard opt-out. Defaults to `true`. */ + honorEnvOptOut?: boolean + /** Consent when `cordis.yml` does not exist yet (first `create`). Defaults to `true` (telemetry is default-on). */ + allowWhenNoConfig?: boolean + /** Consent when `cordis.yml` exists but has no telemetry entry. Defaults to `false`. */ + allowWhenEntryAbsent?: boolean +} + +/** Whether an environment variable is set to a non-empty, non-"0"/"false" value. */ +function envEnabled(value: string | undefined): boolean { + if (value === undefined) return false + const normalized = value.trim().toLowerCase() + return normalized.length > 0 && normalized !== '0' && normalized !== 'false' +} + +/** Read a `cordis.yml` entry's `name`/`disabled` scalars, tolerating `!!js` tags. */ +function readTelemetryEntry(text: string, pluginName: string): { present: boolean; disabled: boolean } { + const document = parseDocument(text, { customTags: [JS_EXPRESSION_TAG] }) + const contents: unknown = document.toJS({ maxAliasCount: -1 }) + if (!Array.isArray(contents)) return { present: false, disabled: false } + for (const entry of contents) { + if (entry === null || typeof entry !== 'object') continue + const record = entry as Record + if (record.name === pluginName) return { present: true, disabled: record.disabled === true } + } + return { present: false, disabled: false } +} + +/** Resolve telemetry consent by parsing a project's `cordis.yml` and the environment. */ +export class ConsentResolver { + readonly #pluginName: string + readonly #env: NodeJS.ProcessEnv + readonly #honorEnvOptOut: boolean + readonly #allowWhenNoConfig: boolean + readonly #allowWhenEntryAbsent: boolean + + /** @param options - plugin name, environment, and default-decision knobs. */ + constructor(options: ConsentResolverOptions = {}) { + this.#pluginName = options.telemetryPluginName ?? DEFAULT_TELEMETRY_PLUGIN_NAME + this.#env = options.env ?? process.env + this.#honorEnvOptOut = options.honorEnvOptOut ?? true + this.#allowWhenNoConfig = options.allowWhenNoConfig ?? true + this.#allowWhenEntryAbsent = options.allowWhenEntryAbsent ?? false + } + + /** + * Resolve consent for a command run in the given project directory. + * @param projectDir - absolute or relative project root containing `cordis.yml`. + * @returns the consent decision and the signal that produced it. + */ + async resolve(projectDir: string): Promise { + if (this.#honorEnvOptOut) { + if (envEnabled(this.#env.DO_NOT_TRACK)) return { allowed: false, reason: 'do-not-track' } + if (envEnabled(this.#env.CI)) return { allowed: false, reason: 'ci' } + } + let text: string + try { + text = await readFile(join(projectDir, 'cordis.yml'), 'utf8') + } catch (error) { + // Missing cordis.yml is the first-init (`create`) path; any other read + // fault is treated conservatively as its own reason. + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { allowed: this.#allowWhenNoConfig, reason: 'no-config' } + } + return { allowed: false, reason: 'unreadable' } + } + const entry = readTelemetryEntry(text, this.#pluginName) + if (!entry.present) return { allowed: this.#allowWhenEntryAbsent, reason: 'absent' } + return entry.disabled ? { allowed: false, reason: 'disabled' } : { allowed: true, reason: 'enabled' } + } +} diff --git a/packages/sdk/telemetry/src/index.ts b/packages/sdk/telemetry/src/index.ts new file mode 100644 index 0000000000..107956fa39 --- /dev/null +++ b/packages/sdk/telemetry/src/index.ts @@ -0,0 +1,45 @@ +/** + * Launcher-side telemetry for the dsh-sdk toolchain: secret redaction, consent + * resolution, anonymous id, payload assembly, and a fire-and-forget reporter. + * + * This package is a plain library the launcher imports around each command — it + * is NOT a Cordis plugin (several commands never boot Cordis). Wiring it into + * the launcher command dispatch and the helper feature catalog lives outside + * this package. + * + * @module @deepseek-ai/dsh-telemetry + */ + +export { + DEFAULT_ENTROPY_THRESHOLD, + DEFAULT_MIN_TOKEN_LENGTH, + DEFAULT_REDACTION_PLACEHOLDER, + SecretRedactor, + keyLooksSecret, +} from './secret-redactor.ts' +export type { SecretRedactorOptions } from './secret-redactor.ts' +export { + ConsentResolver, + DEFAULT_TELEMETRY_PLUGIN_NAME, +} from './consent-resolver.ts' +export type { + ConsentDecision, + ConsentReason, + ConsentResolverOptions, +} from './consent-resolver.ts' +export { + ANONYMOUS_ID_FILE_NAME, + getOrCreateAnonymousId, + globalConfigDir, +} from './anonymous-id.ts' +export type { AnonymousId, AnonymousIdOptions } from './anonymous-id.ts' +export { buildTelemetryPayload } from './payload.ts' +export type { BuildTelemetryPayloadInput, TelemetryPayload } from './payload.ts' +export { + DEFAULT_FLUSH_TIMEOUT_MS, + DEFAULT_SEND_TIMEOUT_MS, + DSH_TELEMETRY_ENDPOINT, + TELEMETRY_SCHEMA_VERSION, + TelemetryReporter, +} from './reporter.ts' +export type { DeliveryOutcome, TelemetryReporterOptions } from './reporter.ts' diff --git a/packages/sdk/telemetry/src/payload.ts b/packages/sdk/telemetry/src/payload.ts new file mode 100644 index 0000000000..96a7b19d76 --- /dev/null +++ b/packages/sdk/telemetry/src/payload.ts @@ -0,0 +1,75 @@ +/** + * Telemetry payload assembly. + * + * The payload carries the command lifecycle plus the FULL redacted content of + * the project `cordis.yml` and `package.json`. It NEVER reads or includes `.env` + * — secrets live only in `.env`, and the redactor is the backstop for any that + * leak into the two reported files. A file that does not exist (the first + * `create` run) simply omits its field. + * + * @module @deepseek-ai/dsh-telemetry/payload + */ + +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { SecretRedactor } from './secret-redactor.ts' + +/** Project files whose full (redacted) content ships with the payload. */ +const REPORTED_FILES = ['cordis.yml', 'package.json'] as const + +/** One command's telemetry payload. */ +export interface TelemetryPayload { + /** The dsh-sdk command that ran (`start`/`dev`/`build`/`config`/`create`). */ + command: string + /** Wall-clock duration of the command in milliseconds. */ + durationMs: number + /** Whether the command completed without error. */ + success: boolean + /** Redacted full text of the project `cordis.yml`, absent when the file does not exist. */ + cordisYmlContent?: string + /** Redacted full text of the project `package.json`, absent when the file does not exist. */ + packageJsonContent?: string +} + +/** Inputs for {@link buildTelemetryPayload}. */ +export interface BuildTelemetryPayloadInput { + /** The dsh-sdk command that ran. */ + command: string + /** Wall-clock duration of the command in milliseconds. */ + durationMs: number + /** Whether the command completed without error. */ + success: boolean + /** Project root whose `cordis.yml` and `package.json` are read. */ + projectDir: string + /** Redactor applied to reported file content; defaults to a fresh {@link SecretRedactor}. */ + redactor?: SecretRedactor +} + +/** Read a project file's text, returning `undefined` when it cannot be read. */ +async function readReportedFile(projectDir: string, name: string): Promise { + try { + return await readFile(join(projectDir, name), 'utf8') + } catch { + // Missing/unreadable reported file: telemetry omits the field rather than fail. + return undefined + } +} + +/** + * Assemble a redacted telemetry payload for one command invocation. + * @param input - command lifecycle facts, project directory, and optional redactor. + * @returns the payload with redacted `cordis.yml`/`package.json` content. + */ +export async function buildTelemetryPayload(input: BuildTelemetryPayloadInput): Promise { + const redactor = input.redactor ?? new SecretRedactor() + const [cordisYml, packageJson] = await Promise.all( + REPORTED_FILES.map(name => readReportedFile(input.projectDir, name)), + ) + return { + command: input.command, + durationMs: input.durationMs, + success: input.success, + ...cordisYml !== undefined ? { cordisYmlContent: redactor.redactText(cordisYml) } : {}, + ...packageJson !== undefined ? { packageJsonContent: redactor.redactText(packageJson) } : {}, + } +} diff --git a/packages/sdk/telemetry/src/reporter.ts b/packages/sdk/telemetry/src/reporter.ts new file mode 100644 index 0000000000..d41c1db9b7 --- /dev/null +++ b/packages/sdk/telemetry/src/reporter.ts @@ -0,0 +1,149 @@ +/** + * Fire-and-forget telemetry reporter for the dsh-sdk launcher. + * + * The reporter must NEVER block or crash a command: {@link TelemetryReporter.report} + * schedules a detached send and returns immediately, and the underlying delivery + * resolves on every path (consent skip, network failure, non-OK status) instead + * of rejecting. {@link TelemetryReporter.flush} lets the launcher optionally + * drain in-flight sends within a cap before exit. + * + * @module @deepseek-ai/dsh-telemetry/reporter + */ + +import type { ConsentDecision } from './consent-resolver.ts' +import type { TelemetryPayload } from './payload.ts' +import { getOrCreateAnonymousId, type AnonymousId } from './anonymous-id.ts' +import { SecretRedactor } from './secret-redactor.ts' + +/** + * Placeholder collection endpoint. This is a fixed protocol constant, not a + * deployment tunable. + * + * FIXME(ccyu): replace with the real telemetry endpoint before release. The + * `.invalid` TLD guarantees delivery fails harmlessly until then. + */ +export const DSH_TELEMETRY_ENDPOINT = 'https://telemetry.example.invalid/v1/dsh-sdk' + +/** Wire-envelope schema version; bump on any incompatible body change. */ +export const TELEMETRY_SCHEMA_VERSION = 1 + +/** Default per-request send timeout in milliseconds. */ +export const DEFAULT_SEND_TIMEOUT_MS = 3000 + +/** Default cap for {@link TelemetryReporter.flush} in milliseconds. */ +export const DEFAULT_FLUSH_TIMEOUT_MS = 2000 + +/** Outcome of one delivery attempt; delivery never rejects. */ +export type DeliveryOutcome = + | { status: 'skipped'; reason: string } + | { status: 'sent' } + | { status: 'failed'; error: string } + +/** The JSON body posted to the telemetry endpoint. */ +interface TelemetryEnvelope extends TelemetryPayload { + schemaVersion: number + anonymousId: AnonymousId + sentAt: string +} + +/** Injectable seams for {@link TelemetryReporter}; every field has a default. */ +export interface TelemetryReporterOptions { + /** Collection endpoint; defaults to {@link DSH_TELEMETRY_ENDPOINT}. */ + endpoint?: string + /** `fetch` implementation; defaults to the global `fetch`. */ + fetch?: typeof globalThis.fetch + /** Anonymous-id provider; defaults to {@link getOrCreateAnonymousId}. */ + anonymousId?: () => Promise + /** Redactor applied to the assembled envelope as a final backstop; defaults to a fresh {@link SecretRedactor}. */ + redactor?: SecretRedactor + /** Per-request send timeout in milliseconds. */ + timeoutMs?: number + /** Clock for the envelope timestamp; defaults to `Date.now`. */ + now?: () => number +} + +/** Sends telemetry payloads fire-and-forget, swallowing every failure. */ +export class TelemetryReporter { + readonly #endpoint: string + readonly #fetch: typeof globalThis.fetch + readonly #anonymousId: () => Promise + readonly #redactor: SecretRedactor + readonly #timeoutMs: number + readonly #now: () => number + readonly #inflight = new Set>() + + /** @param options - endpoint, transport, id provider, and timing seams. */ + constructor(options: TelemetryReporterOptions = {}) { + this.#endpoint = options.endpoint ?? DSH_TELEMETRY_ENDPOINT + this.#fetch = options.fetch ?? globalThis.fetch + this.#anonymousId = options.anonymousId ?? getOrCreateAnonymousId + this.#redactor = options.redactor ?? new SecretRedactor() + this.#timeoutMs = options.timeoutMs ?? DEFAULT_SEND_TIMEOUT_MS + this.#now = options.now ?? Date.now + } + + /** + * Schedule a detached, non-blocking send. Returns immediately and never + * throws; the send's outcome is observable only through {@link flush}. + * @param payload - the command payload to report. + * @param consent - resolved consent; a denial short-circuits to a skip. + */ + report(payload: TelemetryPayload, consent: ConsentDecision): void { + const pending = this.#deliver(payload, consent) + this.#inflight.add(pending) + void pending.finally(() => this.#inflight.delete(pending)) + } + + /** + * Await in-flight sends up to a timeout so a caller can drain before exit. + * Resolves on the cap regardless of send progress; never rejects. + * @param timeoutMs - maximum time to wait; defaults to {@link DEFAULT_FLUSH_TIMEOUT_MS}. + */ + async flush(timeoutMs: number = DEFAULT_FLUSH_TIMEOUT_MS): Promise { + if (this.#inflight.size === 0) return + const drained = Promise.allSettled([...this.#inflight]).then(() => undefined) + let timer!: ReturnType + const capped = new Promise((resolve) => { + timer = setTimeout(resolve, timeoutMs) + }) + try { + await Promise.race([drained, capped]) + } finally { + clearTimeout(timer) + } + } + + /** Deliver one payload, resolving to an outcome on every path (never rejects). */ + async #deliver(payload: TelemetryPayload, consent: ConsentDecision): Promise { + if (!consent.allowed) return { status: 'skipped', reason: consent.reason } + try { + const envelope: TelemetryEnvelope = { + schemaVersion: TELEMETRY_SCHEMA_VERSION, + anonymousId: await this.#anonymousId(), + sentAt: new Date(this.#now()).toISOString(), + ...payload, + // Idempotent backstop over the only free-form fields, in case a caller + // built the payload without buildTelemetryPayload. Applied to content + // text only so the anonymous id and metadata are never disturbed. + ...payload.cordisYmlContent !== undefined + ? { cordisYmlContent: this.#redactor.redactText(payload.cordisYmlContent) } + : {}, + ...payload.packageJsonContent !== undefined + ? { packageJsonContent: this.#redactor.redactText(payload.packageJsonContent) } + : {}, + } + const response = await this.#fetch(this.#endpoint, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(envelope), + signal: AbortSignal.timeout(this.#timeoutMs), + }) + if (!response.ok) return { status: 'failed', error: `HTTP ${response.status}` } + return { status: 'sent' } + } catch (error) { + // Telemetry is best-effort: network faults, aborts, and id/redaction + // errors are swallowed so the command is never affected. + return { status: 'failed', error: error instanceof Error ? error.message : String(error) } + } + } +} diff --git a/packages/sdk/telemetry/src/secret-redactor.ts b/packages/sdk/telemetry/src/secret-redactor.ts new file mode 100644 index 0000000000..5b9af5f169 --- /dev/null +++ b/packages/sdk/telemetry/src/secret-redactor.ts @@ -0,0 +1,203 @@ +/** + * Conservative secret redactor: the safety backstop that scrubs credential-like + * values from telemetry content before it leaves the machine. + * + * The redactor never drops a field or line — it only replaces the secret-shaped + * VALUE with a fixed placeholder, so the surrounding structure (keys, package + * names, base URLs, dependency pins) stays intact for the maintainer. It leans + * toward redaction on strong signals (secret-like key names, known token + * shapes, PEM blocks, URL credentials, high-entropy opaque tokens) while + * deliberately leaving low-signal values (package names, versions, git SHAs, + * plain URLs, kebab identifiers) untouched, because those are exactly the + * signal telemetry exists to capture. + * + * @module @deepseek-ai/dsh-telemetry/secret-redactor + */ + +/** Default text substituted for a detected secret. */ +export const DEFAULT_REDACTION_PLACEHOLDER = '[REDACTED]' + +/** Default minimum length for the high-entropy opaque-token heuristic. */ +export const DEFAULT_MIN_TOKEN_LENGTH = 24 + +/** Default Shannon-entropy threshold (bits/char) that marks an opaque token secret. */ +export const DEFAULT_ENTROPY_THRESHOLD = 4 + +/** Tuning for {@link SecretRedactor}; every field defaults to a documented constant. */ +export interface SecretRedactorOptions { + /** Replacement text for a detected secret. */ + placeholder?: string + /** Minimum length before the high-entropy heuristic considers an opaque token. */ + minTokenLength?: number + /** Shannon entropy (bits/char) at or above which an opaque token is treated as secret. */ + entropyThreshold?: number +} + +/** + * Regexes for well-known credential shapes. A match anywhere in a candidate + * token marks it secret regardless of length, so short-but-recognizable tokens + * are caught even when the entropy heuristic would not fire. + */ +const KNOWN_SECRET_PATTERNS: readonly RegExp[] = [ + /sk-(?:ant-)?[A-Za-z0-9_-]{10,}/, // OpenAI / DeepSeek / Anthropic style + /gh[pousr]_[A-Za-z0-9]{16,}/, // GitHub personal/oauth/server/refresh tokens + /github_pat_[A-Za-z0-9_]{20,}/, // GitHub fine-grained PAT + /xox[baprs]-[A-Za-z0-9-]{10,}/, // Slack tokens + /AKIA[0-9A-Z]{16}/, // AWS access key id + /AIza[0-9A-Za-z_-]{35}/, // Google API key + /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/, // JWT +] + +/** + * Key names (normalized to lowercase, separators stripped) whose value is a + * secret. Split by match strategy so short/ambiguous words do not over-match: + * `author` must not trip the `auth` rule. + */ +const KEY_SUBSTRING_INDICATORS: readonly string[] = [ + 'password', 'passwd', 'passphrase', 'secret', 'apikey', 'apisecret', + 'clientsecret', 'privatekey', 'secretkey', 'accesskey', 'credential', + 'connectionstring', 'sastoken', 'xapikey', 'authtoken', 'accesstoken', + 'refreshtoken', 'idtoken', 'sessiontoken', 'bearertoken', +] +const KEY_SUFFIX_INDICATORS: readonly string[] = ['token'] +const KEY_EXACT_INDICATORS: readonly string[] = [ + 'auth', 'authorization', 'cookie', 'bearer', 'dsn', 'signature', +] + +/** + * Whether a key name marks its value as a secret. + * @param key - raw object key or assignment name. + * @returns whether the value under this key must be redacted. + */ +export function keyLooksSecret(key: string): boolean { + const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, '') + if (normalized.length === 0) return false + if (KEY_SUBSTRING_INDICATORS.some(indicator => normalized.includes(indicator))) return true + if (KEY_SUFFIX_INDICATORS.some(indicator => normalized.endsWith(indicator))) return true + return KEY_EXACT_INDICATORS.includes(normalized) +} + +/** Shannon entropy in bits per character. */ +function shannonEntropy(value: string): number { + const counts = new Map() + for (const char of value) counts.set(char, (counts.get(char) ?? 0) + 1) + let entropy = 0 + for (const count of counts.values()) { + const probability = count / value.length + entropy -= probability * Math.log2(probability) + } + return entropy +} + +/** Opaque-token character set (base64/base64url plus common token punctuation). */ +const OPAQUE_TOKEN = /^[A-Za-z0-9+/=_.-]+$/ +/** Version-like leader kept visible (dependency pins, semver). */ +const VERSION_LIKE = /^v?\d+(?:\.\d+)+/ + +/** + * Conservative secret detector and redactor for telemetry content. + * Detection is a pure function of the input; construction only fixes tunables. + */ +export class SecretRedactor { + readonly #placeholder: string + readonly #minTokenLength: number + readonly #entropyThreshold: number + + /** @param options - placeholder text and heuristic thresholds. */ + constructor(options: SecretRedactorOptions = {}) { + this.#placeholder = options.placeholder ?? DEFAULT_REDACTION_PLACEHOLDER + this.#minTokenLength = options.minTokenLength ?? DEFAULT_MIN_TOKEN_LENGTH + this.#entropyThreshold = options.entropyThreshold ?? DEFAULT_ENTROPY_THRESHOLD + } + + /** + * Whether a standalone token value looks like a secret. + * @param value - candidate token, already trimmed of surrounding quotes. + * @returns whether the value should be redacted on its own merits. + */ + isSecretValue(value: string): boolean { + if (KNOWN_SECRET_PATTERNS.some(pattern => pattern.test(value))) return true + if (value.length < this.#minTokenLength) return false + if (!OPAQUE_TOKEN.test(value)) return false + // Git SHAs and integrity digests are hex and public — never a secret we hide. + if (/^[0-9a-fA-F]+$/.test(value)) return false + if (VERSION_LIKE.test(value)) return false + const classes = (/[a-z]/.test(value) ? 1 : 0) + (/[A-Z]/.test(value) ? 1 : 0) + (/[0-9]/.test(value) ? 1 : 0) + return classes >= 3 || shannonEntropy(value) >= this.#entropyThreshold + } + + /** + * Deep-redact a parsed value in place-safe fashion, returning a new structure. + * A secret-named key redacts its string value outright; every other string is + * judged on its own shape. Non-string leaves pass through untouched. + * @param value - parsed JSON-like value (object, array, or primitive). + * @returns a structurally identical value with secret strings replaced. + */ + redactValue(value: T): T { + return this.#redactNode(value, false) as T + } + + #redactNode(value: unknown, keyIsSecret: boolean): unknown { + if (typeof value === 'string') { + return keyIsSecret || this.isSecretValue(value) ? this.#placeholder : value + } + if (Array.isArray(value)) return value.map(item => this.#redactNode(item, false)) + if (value !== null && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, child]) => [key, this.#redactNode(child, keyLooksSecret(key))]), + ) + } + return value + } + + /** + * Redact secrets embedded in raw text (YAML, JSON, or `.env`-style content), + * preserving every line and key while replacing only secret-shaped values. + * @param text - raw file or message text. + * @returns text with detected secrets replaced by the placeholder. + */ + redactText(text: string): string { + let output = this.#redactPemBlocks(text) + output = this.#redactAssignments(output) + output = this.#redactUrlCredentials(output) + output = this.#redactBearerTokens(output) + return this.#redactStandaloneTokens(output) + } + + #redactPemBlocks(text: string): string { + return text.replace( + /-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----[\s\S]*?-----END (?:[A-Z ]+ )?PRIVATE KEY-----/g, + this.#placeholder, + ) + } + + #redactAssignments(text: string): string { + // `key: value`, `key = value`, or `"key": "value"` across YAML/JSON/.env. + return text.replace( + /("?)([A-Za-z0-9_.-]+)\1(\s*[:=]\s*)(["']?)([^\n\r"']+)\4/g, + (match, keyQuote: string, key: string, separator: string, valueQuote: string, value: string) => + keyLooksSecret(key) && value.trim().length > 0 + ? `${keyQuote}${key}${keyQuote}${separator}${valueQuote}${this.#placeholder}${valueQuote}` + : match, + ) + } + + #redactUrlCredentials(text: string): string { + // Redact only the password in `scheme://user:password@host`, keeping host visible. + return text.replace( + /([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)([^\s/@]+)(@)/gi, + (_match, prefix: string, _password: string, at: string) => `${prefix}${this.#placeholder}${at}`, + ) + } + + #redactBearerTokens(text: string): string { + return text.replace(/(bearer\s+)([a-z0-9._-]{8,})/gi, (_match, prefix: string) => `${prefix}${this.#placeholder}`) + } + + #redactStandaloneTokens(text: string): string { + // `/` is excluded so package names, file paths, and URLs are never split or + // redacted; a secret containing `/` is still scrubbed piecewise. + return text.replace(/[A-Za-z0-9][A-Za-z0-9+=_.-]{7,}/g, token => + this.isSecretValue(token) ? this.#placeholder : token) + } +} diff --git a/packages/sdk/telemetry/tests/anonymous-id.spec.ts b/packages/sdk/telemetry/tests/anonymous-id.spec.ts new file mode 100644 index 0000000000..df8bcffea2 --- /dev/null +++ b/packages/sdk/telemetry/tests/anonymous-id.spec.ts @@ -0,0 +1,100 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + ANONYMOUS_ID_FILE_NAME, + getOrCreateAnonymousId, + globalConfigDir, +} from '@deepseek-ai/dsh-telemetry' + +const dirs: string[] = [] + +async function tempDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-anon-')) + dirs.push(dir) + return dir +} + +afterEach(async () => { + await Promise.all(dirs.splice(0).map(dir => rm(dir, { recursive: true, force: true }))) +}) + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +describe('globalConfigDir', () => { + it('prefers an explicit DSH_CONFIG_HOME override', () => { + expect(globalConfigDir({ env: { DSH_CONFIG_HOME: '/custom/dsh' } })).toBe('/custom/dsh') + }) + + it('falls back to XDG_CONFIG_HOME under the harness namespace', () => { + expect(globalConfigDir({ env: { XDG_CONFIG_HOME: '/xdg' } })).toBe(join('/xdg', 'deepseek-harness')) + }) + + it('uses %APPDATA% on Windows', () => { + expect(globalConfigDir({ env: { APPDATA: 'C:/Users/x/AppData/Roaming' }, platform: 'win32' })) + .toBe(join('C:/Users/x/AppData/Roaming', 'deepseek-harness')) + }) + + it('falls back to ~/.config on Windows without APPDATA and on posix', () => { + const home = () => '/home/dev' + expect(globalConfigDir({ env: {}, platform: 'win32', homeDir: home })) + .toBe(join('/home/dev', '.config', 'deepseek-harness')) + expect(globalConfigDir({ env: {}, platform: 'linux', homeDir: home })) + .toBe(join('/home/dev', '.config', 'deepseek-harness')) + }) + + it('reads process.env by default', () => { + // No override supplied: the call must not throw and must return an absolute path. + expect(globalConfigDir()).toContain('deepseek-harness') + }) +}) + +describe('getOrCreateAnonymousId', () => { + it('creates, persists, and returns a UUID on first use', async () => { + const dir = await tempDir() + const id = await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: dir } }) + expect(id).toMatch(UUID) + const stored: unknown = JSON.parse(await readFile(join(dir, ANONYMOUS_ID_FILE_NAME), 'utf8')) + expect(stored).toEqual({ anonymousId: id }) + }) + + it('returns the same persisted id on subsequent calls', async () => { + const dir = await tempDir() + const first = await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: dir } }) + const second = await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: dir } }) + expect(second).toBe(first) + }) + + it('uses the injected UUID generator', async () => { + const dir = await tempDir() + const id = await getOrCreateAnonymousId({ + env: { DSH_CONFIG_HOME: dir }, + randomUUID: () => '00000000-0000-4000-8000-000000000000', + }) + expect(id).toBe('00000000-0000-4000-8000-000000000000') + }) + + it('regenerates when the stored file is corrupt JSON', async () => { + const dir = await tempDir() + await writeFile(join(dir, ANONYMOUS_ID_FILE_NAME), 'not json', 'utf8') + const id = await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: dir } }) + expect(id).toMatch(UUID) + }) + + it('regenerates when the stored value is not a valid UUID or object', async () => { + const dir = await tempDir() + await writeFile(join(dir, ANONYMOUS_ID_FILE_NAME), JSON.stringify({ anonymousId: 'nope' }), 'utf8') + expect(await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: dir } })).toMatch(UUID) + await writeFile(join(dir, ANONYMOUS_ID_FILE_NAME), '123', 'utf8') + expect(await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: dir } })).toMatch(UUID) + }) + + it('returns a usable id even when persistence fails', async () => { + const dir = await tempDir() + // A regular file where a directory is expected makes mkdir/writeFile fail. + await writeFile(join(dir, 'blocker'), 'x', 'utf8') + const id = await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: join(dir, 'blocker') } }) + expect(id).toMatch(UUID) + }) +}) diff --git a/packages/sdk/telemetry/tests/consent-resolver.spec.ts b/packages/sdk/telemetry/tests/consent-resolver.spec.ts new file mode 100644 index 0000000000..e1f5f90f81 --- /dev/null +++ b/packages/sdk/telemetry/tests/consent-resolver.spec.ts @@ -0,0 +1,131 @@ +import { mkdtemp, mkdir, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { ConsentResolver, DEFAULT_TELEMETRY_PLUGIN_NAME, type ConsentDecision } from '@deepseek-ai/dsh-telemetry' + +const dirs: string[] = [] + +async function projectDir(cordisYml?: string): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-consent-')) + dirs.push(dir) + if (cordisYml !== undefined) await writeFile(join(dir, 'cordis.yml'), cordisYml, 'utf8') + return dir +} + +afterEach(async () => { + await Promise.all(dirs.splice(0).map(dir => import('node:fs/promises').then(fs => fs.rm(dir, { recursive: true, force: true })))) +}) + +const enabledYml = `- id: telemetry\n name: '${DEFAULT_TELEMETRY_PLUGIN_NAME}'\n` + +describe('ConsentResolver environment opt-out', () => { + it('denies when DO_NOT_TRACK is set', async () => { + const decision = await new ConsentResolver({ env: { DO_NOT_TRACK: '1' } }).resolve(await projectDir(enabledYml)) + expect(decision).toEqual({ allowed: false, reason: 'do-not-track' }) + }) + + it('denies when CI is set', async () => { + const decision = await new ConsentResolver({ env: { CI: 'true' } }).resolve(await projectDir(enabledYml)) + expect(decision).toEqual({ allowed: false, reason: 'ci' }) + }) + + it('ignores falsy env values and continues to the file', async () => { + const decision = await new ConsentResolver({ env: { DO_NOT_TRACK: '0', CI: 'false' } }) + .resolve(await projectDir(enabledYml)) + expect(decision).toEqual({ allowed: true, reason: 'enabled' }) + }) + + it('can be told to ignore env opt-out signals', async () => { + const decision = await new ConsentResolver({ env: { DO_NOT_TRACK: '1' }, honorEnvOptOut: false }) + .resolve(await projectDir(enabledYml)) + expect(decision).toEqual({ allowed: true, reason: 'enabled' }) + }) + + it('reads process.env by default', async () => { + const saved = { CI: process.env.CI, DO_NOT_TRACK: process.env.DO_NOT_TRACK } + delete process.env.CI + delete process.env.DO_NOT_TRACK + try { + const decision = await new ConsentResolver().resolve(await projectDir(enabledYml)) + expect(decision).toEqual({ allowed: true, reason: 'enabled' }) + } finally { + if (saved.CI !== undefined) process.env.CI = saved.CI + if (saved.DO_NOT_TRACK !== undefined) process.env.DO_NOT_TRACK = saved.DO_NOT_TRACK + } + }) +}) + +describe('ConsentResolver cordis.yml state', () => { + const resolver = new ConsentResolver({ env: {} }) + + it('allows when the telemetry entry is enabled', async () => { + expect(await resolver.resolve(await projectDir(enabledYml))) + .toEqual({ allowed: true, reason: 'enabled' }) + }) + + it('denies when the telemetry entry is disabled', async () => { + const yml = `- id: telemetry\n name: '${DEFAULT_TELEMETRY_PLUGIN_NAME}'\n disabled: true\n` + expect(await resolver.resolve(await projectDir(yml))) + .toEqual({ allowed: false, reason: 'disabled' }) + }) + + it('tolerates !!js expression tags while reading plain scalars', async () => { + const yml = [ + '- id: telemetry', + ` name: '${DEFAULT_TELEMETRY_PLUGIN_NAME}'`, + '- id: llm', + ' name: \'@deepseek-ai/dsh-llm-deepseek\'', + ' config:', + ' apiKey: !!js process.env.DEEPSEEK_API_KEY', + '', + ].join('\n') + expect(await resolver.resolve(await projectDir(yml))) + .toEqual({ allowed: true, reason: 'enabled' }) + }) + + it('reports absent when cordis.yml has no telemetry entry, defaulting to deny', async () => { + const yml = '- id: llm\n name: \'@deepseek-ai/dsh-llm-deepseek\'\n' + expect(await resolver.resolve(await projectDir(yml))) + .toEqual({ allowed: false, reason: 'absent' }) + }) + + it('can allow when the entry is absent', async () => { + const yml = '- id: llm\n name: \'@deepseek-ai/dsh-llm-deepseek\'\n' + const decision = await new ConsentResolver({ env: {}, allowWhenEntryAbsent: true }).resolve(await projectDir(yml)) + expect(decision).toEqual({ allowed: true, reason: 'absent' }) + }) + + it('skips non-object sequence items and a non-sequence root', async () => { + expect(await resolver.resolve(await projectDir('- just-a-string\n- id: x\n name: y\n'))) + .toEqual({ allowed: false, reason: 'absent' }) + expect(await resolver.resolve(await projectDir('root: not-a-sequence\n'))) + .toEqual({ allowed: false, reason: 'absent' }) + }) + + it('honors a custom telemetry plugin name', async () => { + const yml = '- id: t\n name: \'my-consent-marker\'\n' + const decision = await new ConsentResolver({ env: {}, telemetryPluginName: 'my-consent-marker' }) + .resolve(await projectDir(yml)) + expect(decision).toEqual({ allowed: true, reason: 'enabled' }) + }) +}) + +describe('ConsentResolver missing or unreadable cordis.yml', () => { + it('reports no-config and allows by default on first init', async () => { + expect(await new ConsentResolver({ env: {} }).resolve(await projectDir())) + .toEqual({ allowed: true, reason: 'no-config' }) + }) + + it('can deny on first init', async () => { + const decision = await new ConsentResolver({ env: {}, allowWhenNoConfig: false }).resolve(await projectDir()) + expect(decision).toEqual({ allowed: false, reason: 'no-config' }) + }) + + it('denies with an unreadable reason when cordis.yml is not a regular file', async () => { + const dir = await projectDir() + await mkdir(join(dir, 'cordis.yml')) // a directory where the resolver expects a file + expect(await new ConsentResolver({ env: {} }).resolve(dir)) + .toEqual({ allowed: false, reason: 'unreadable' }) + }) +}) diff --git a/packages/sdk/telemetry/tests/payload.spec.ts b/packages/sdk/telemetry/tests/payload.spec.ts new file mode 100644 index 0000000000..1af5139ef5 --- /dev/null +++ b/packages/sdk/telemetry/tests/payload.spec.ts @@ -0,0 +1,59 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { SecretRedactor, buildTelemetryPayload } from '@deepseek-ai/dsh-telemetry' + +const dirs: string[] = [] + +async function projectDir(files: Record): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-payload-')) + dirs.push(dir) + await Promise.all(Object.entries(files).map(([name, content]) => writeFile(join(dir, name), content, 'utf8'))) + return dir +} + +afterEach(async () => { + await Promise.all(dirs.splice(0).map(dir => rm(dir, { recursive: true, force: true }))) +}) + +describe('buildTelemetryPayload', () => { + it('carries lifecycle facts and redacted file content', async () => { + const dir = await projectDir({ + 'cordis.yml': '- id: llm\n name: \'@deepseek-ai/dsh-llm-deepseek\'\n config:\n apiKey: sk-abcdefghij1234567890\n', + 'package.json': '{ "name": "my-app", "config": { "token": "sk-abcdefghij1234567890" } }', + }) + const payload = await buildTelemetryPayload({ command: 'build', durationMs: 42, success: true, projectDir: dir }) + expect(payload.command).toBe('build') + expect(payload.durationMs).toBe(42) + expect(payload.success).toBe(true) + expect(payload.cordisYmlContent).toContain('@deepseek-ai/dsh-llm-deepseek') // package name preserved + expect(payload.cordisYmlContent).not.toContain('sk-abcdefghij1234567890') // secret scrubbed + expect(payload.packageJsonContent).toContain('my-app') + expect(payload.packageJsonContent).not.toContain('sk-abcdefghij1234567890') + }) + + it('omits fields whose files do not exist', async () => { + const dir = await projectDir({ 'cordis.yml': '- id: llm\n name: \'@deepseek-ai/dsh-llm-deepseek\'\n' }) + const payload = await buildTelemetryPayload({ command: 'create', durationMs: 1, success: false, projectDir: dir }) + expect(payload.cordisYmlContent).toBeDefined() + expect('packageJsonContent' in payload).toBe(false) + }) + + it('omits both fields when neither file exists', async () => { + const dir = await projectDir({}) + const payload = await buildTelemetryPayload({ command: 'create', durationMs: 0, success: true, projectDir: dir }) + expect('cordisYmlContent' in payload).toBe(false) + expect('packageJsonContent' in payload).toBe(false) + }) + + it('uses a supplied redactor', async () => { + const dir = await projectDir({ 'package.json': '{ "password": "hunter2" }' }) + const redactor = new SecretRedactor({ placeholder: '<>' }) + const payload = await buildTelemetryPayload({ + command: 'config', durationMs: 5, success: true, projectDir: dir, redactor, + }) + expect(payload.packageJsonContent).toContain('<>') + expect(payload.packageJsonContent).not.toContain('hunter2') + }) +}) diff --git a/packages/sdk/telemetry/tests/reporter.spec.ts b/packages/sdk/telemetry/tests/reporter.spec.ts new file mode 100644 index 0000000000..5d8a490b9e --- /dev/null +++ b/packages/sdk/telemetry/tests/reporter.spec.ts @@ -0,0 +1,134 @@ +import { describe, expect, it, vi } from 'vitest' +import { + DSH_TELEMETRY_ENDPOINT, + SecretRedactor, + TELEMETRY_SCHEMA_VERSION, + TelemetryReporter, + type AnonymousId, + type ConsentDecision, + type TelemetryPayload, +} from '@deepseek-ai/dsh-telemetry' + +const ALLOW: ConsentDecision = { allowed: true, reason: 'enabled' } +const DENY: ConsentDecision = { allowed: false, reason: 'disabled' } +const anon = (value = 'anon-123'): (() => Promise) => async () => value as AnonymousId + +function okResponse(): Response { + return { ok: true } as Response +} + +describe('TelemetryReporter.report', () => { + it('skips delivery when consent is denied', async () => { + const fetchMock = vi.fn(async () => okResponse()) + const reporter = new TelemetryReporter({ fetch: fetchMock, anonymousId: anon() }) + reporter.report({ command: 'build', durationMs: 1, success: true }, DENY) + await reporter.flush(50) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('posts a redacted envelope when consent is granted', async () => { + const fetchMock = vi.fn(() => Promise.resolve(okResponse())) + const reporter = new TelemetryReporter({ + endpoint: 'https://collector.test/telemetry', + fetch: fetchMock, + anonymousId: anon('anon-xyz'), + redactor: new SecretRedactor(), + now: () => 0, + timeoutMs: 100, + }) + const payload: TelemetryPayload = { + command: 'config', + durationMs: 7, + success: true, + cordisYmlContent: 'apiKey: sk-abcdefghij1234567890\nname: \'@deepseek-ai/dsh-llm-deepseek\'\n', + packageJsonContent: '{ "name": "app" }', + } + reporter.report(payload, ALLOW) + await reporter.flush(50) + + expect(fetchMock).toHaveBeenCalledTimes(1) + const call = fetchMock.mock.calls[0]! + expect(call[0]).toBe('https://collector.test/telemetry') + const init = call[1]! + expect(init.method).toBe('POST') + const body = JSON.parse(init.body as string) as Record + expect(body.schemaVersion).toBe(TELEMETRY_SCHEMA_VERSION) + expect(body.anonymousId).toBe('anon-xyz') + expect(body.sentAt).toBe('1970-01-01T00:00:00.000Z') + expect(body.command).toBe('config') + expect(body.cordisYmlContent).not.toContain('sk-abcdefghij1234567890') + expect(body.cordisYmlContent).toContain('@deepseek-ai/dsh-llm-deepseek') + expect(body.packageJsonContent).toContain('app') + }) + + it('posts an envelope without content fields when they are absent', async () => { + const fetchMock = vi.fn(() => Promise.resolve(okResponse())) + const reporter = new TelemetryReporter({ fetch: fetchMock, anonymousId: anon(), now: () => 0, timeoutMs: 100 }) + reporter.report({ command: 'start', durationMs: 2, success: true }, ALLOW) + await reporter.flush(50) + const body = JSON.parse(fetchMock.mock.calls[0]![1]!.body as string) as Record + expect('cordisYmlContent' in body).toBe(false) + expect('packageJsonContent' in body).toBe(false) + }) + + it('swallows a non-OK HTTP status', async () => { + const fetchMock = vi.fn(async () => ({ ok: false, status: 503 } as Response)) + const reporter = new TelemetryReporter({ fetch: fetchMock, anonymousId: anon(), timeoutMs: 100 }) + reporter.report({ command: 'dev', durationMs: 3, success: true }, ALLOW) + await expect(reporter.flush(50)).resolves.toBeUndefined() + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('swallows a transport failure', async () => { + const fetchMock = vi.fn(async () => { throw new Error('network down') }) + const reporter = new TelemetryReporter({ fetch: fetchMock, anonymousId: anon(), timeoutMs: 100 }) + reporter.report({ command: 'dev', durationMs: 3, success: false }, ALLOW) + await expect(reporter.flush(50)).resolves.toBeUndefined() + }) + + it('swallows a non-Error transport rejection', async () => { + const fetchMock = vi.fn(async () => { throw 'boom' }) + const reporter = new TelemetryReporter({ fetch: fetchMock, anonymousId: anon(), timeoutMs: 100 }) + reporter.report({ command: 'dev', durationMs: 3, success: false }, ALLOW) + await expect(reporter.flush(50)).resolves.toBeUndefined() + }) + + it('swallows a failure while resolving the anonymous id, never sending', async () => { + const fetchMock = vi.fn(async () => okResponse()) + const reporter = new TelemetryReporter({ + fetch: fetchMock, + anonymousId: async () => { throw new Error('config unwritable') }, + timeoutMs: 100, + }) + reporter.report({ command: 'build', durationMs: 1, success: true }, ALLOW) + await reporter.flush(50) + expect(fetchMock).not.toHaveBeenCalled() + }) +}) + +describe('TelemetryReporter.flush', () => { + it('returns immediately when nothing is in flight', async () => { + const reporter = new TelemetryReporter({ fetch: vi.fn(async () => okResponse()), anonymousId: anon() }) + await expect(reporter.flush()).resolves.toBeUndefined() + }) + + it('resolves on the timeout cap when a send never settles', async () => { + const reporter = new TelemetryReporter({ + fetch: () => new Promise(() => {}), + anonymousId: anon(), + timeoutMs: 10, + }) + reporter.report({ command: 'start', durationMs: 1, success: true }, ALLOW) + const started = Date.now() + await reporter.flush(15) + expect(Date.now() - started).toBeLessThan(1000) + }) +}) + +describe('TelemetryReporter defaults', () => { + it('defaults the endpoint and transport seams without options', () => { + const reporter = new TelemetryReporter() + expect(reporter).toBeInstanceOf(TelemetryReporter) + expect(DSH_TELEMETRY_ENDPOINT).toContain('.invalid') + }) +}) diff --git a/packages/sdk/telemetry/tests/secret-redactor.spec.ts b/packages/sdk/telemetry/tests/secret-redactor.spec.ts new file mode 100644 index 0000000000..c3250d9ace --- /dev/null +++ b/packages/sdk/telemetry/tests/secret-redactor.spec.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from 'vitest' +import { + DEFAULT_ENTROPY_THRESHOLD, + DEFAULT_MIN_TOKEN_LENGTH, + DEFAULT_REDACTION_PLACEHOLDER, + SecretRedactor, + keyLooksSecret, +} from '@deepseek-ai/dsh-telemetry' + +const REDACTED = DEFAULT_REDACTION_PLACEHOLDER + +describe('exported defaults', () => { + it('expose the documented tunable defaults', () => { + expect(DEFAULT_REDACTION_PLACEHOLDER).toBe('[REDACTED]') + expect(DEFAULT_MIN_TOKEN_LENGTH).toBe(24) + expect(DEFAULT_ENTROPY_THRESHOLD).toBe(4) + }) +}) + +describe('keyLooksSecret', () => { + it('matches secret substrings across casings and separators', () => { + for (const key of ['password', 'API_KEY', 'apiKey', 'clientSecret', 'x-api-key', 'privateKey', 'CREDENTIALS']) { + expect(keyLooksSecret(key)).toBe(true) + } + }) + + it('matches *token as a suffix but not tokenizer', () => { + expect(keyLooksSecret('accessToken')).toBe(true) + expect(keyLooksSecret('token')).toBe(true) + expect(keyLooksSecret('tokenizer')).toBe(false) + }) + + it('matches short ambiguous words only as whole keys', () => { + expect(keyLooksSecret('auth')).toBe(true) + expect(keyLooksSecret('authorization')).toBe(true) + expect(keyLooksSecret('cookie')).toBe(true) + expect(keyLooksSecret('author')).toBe(false) + }) + + it('does not match ordinary config keys', () => { + for (const key of ['name', 'version', 'model', 'baseURL', 'timeout', 'path', 'pass']) { + expect(keyLooksSecret(key)).toBe(false) + } + }) + + it('returns false for a key with no alphanumerics', () => { + expect(keyLooksSecret('---')).toBe(false) + }) +}) + +describe('SecretRedactor.isSecretValue', () => { + const redactor = new SecretRedactor() + + it('detects known token shapes regardless of length', () => { + expect(redactor.isSecretValue('sk-abcdefghij1234567890')).toBe(true) + expect(redactor.isSecretValue('sk-ant-abcdefghij1234567890')).toBe(true) + expect(redactor.isSecretValue('ghp_abcdefghijklmnop1234')).toBe(true) + expect(redactor.isSecretValue('github_pat_abcdefghijklmnopqrst')).toBe(true) + expect(redactor.isSecretValue('xoxb-abcdefghij-klmno')).toBe(true) + expect(redactor.isSecretValue('AKIA1234567890ABCDEF')).toBe(true) + expect(redactor.isSecretValue(`AIza${'a'.repeat(35)}`)).toBe(true) + expect(redactor.isSecretValue('eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abcdefghijklmnop')).toBe(true) + }) + + it('detects high-entropy opaque tokens with three character classes', () => { + // Non-hex letters keep it off the hex-digest exemption; three classes trip the rule. + expect(redactor.isSecretValue('zX9zX9zX9zX9zX9zX9zX9zX9')).toBe(true) + }) + + it('detects high-entropy opaque tokens by entropy even within two classes', () => { + // 30 distinct lowercase+digit chars: entropy ~4.9, only two classes. + const token = 'abcdefghijklmnopqrstuvwxyz0123' + expect(token.length).toBeGreaterThanOrEqual(DEFAULT_MIN_TOKEN_LENGTH) + expect(redactor.isSecretValue(token)).toBe(true) + }) + + it('leaves short values, non-opaque text, hex digests, and versions untouched', () => { + expect(redactor.isSecretValue('deepseek-chat')).toBe(false) // short + expect(redactor.isSecretValue('a token with spaces here!!')).toBe(false) // not opaque + expect(redactor.isSecretValue('a'.repeat(40))).toBe(false) // low entropy, one class + expect(redactor.isSecretValue('abcdef0123456789abcdef0123456789abcdef01')).toBe(false) // 40-hex git SHA + expect(redactor.isSecretValue('1.2.3.4.5.6.7.8.9.10.11.12')).toBe(false) // version-like + expect(redactor.isSecretValue('ZXQPZXQPZXQPZXQPZXQPZXQP')).toBe(false) // uppercase only, low entropy + }) + + it('honors a custom entropy threshold', () => { + const strict = new SecretRedactor({ entropyThreshold: 100 }) + // Two-class token can no longer trip the entropy branch under an impossible threshold. + expect(strict.isSecretValue('abcdefghijklmnopqrstuvwxyz0123')).toBe(false) + }) +}) + +describe('SecretRedactor.redactValue', () => { + const redactor = new SecretRedactor() + + it('redacts secret-keyed strings and secret-shaped strings, keeping structure', () => { + const result = redactor.redactValue({ + apiKey: 'short-not-shaped', + name: 'my-package', + token: 'sk-abcdefghij1234567890', + count: 3, + enabled: true, + missing: null, + nested: { password: 'p', note: 'plain text value' }, + list: ['harmless', 'sk-abcdefghij1234567890'], + }) + expect(result).toEqual({ + apiKey: REDACTED, // redacted by key even though the value is not secret-shaped + name: 'my-package', + token: REDACTED, + count: 3, + enabled: true, + missing: null, + nested: { password: REDACTED, note: 'plain text value' }, + list: ['harmless', REDACTED], + }) + }) + + it('redacts a top-level secret string and passes through primitives', () => { + expect(redactor.redactValue('sk-abcdefghij1234567890')).toBe(REDACTED) + expect(redactor.redactValue('plain')).toBe('plain') + expect(redactor.redactValue(42)).toBe(42) + expect(redactor.redactValue(null)).toBeNull() + }) +}) + +describe('SecretRedactor.redactText', () => { + const redactor = new SecretRedactor() + + it('redacts PEM private key blocks', () => { + const text = '-----BEGIN RSA PRIVATE KEY-----\nMIIabc\ndef==\n-----END RSA PRIVATE KEY-----' + expect(redactor.redactText(text)).toBe(REDACTED) + }) + + it('redacts secret-keyed assignments across YAML, JSON, and .env', () => { + expect(redactor.redactText('password: hunter2')).toBe(`password: ${REDACTED}`) + expect(redactor.redactText('apiKey: "sk-abcdefghij1234567890"')).toBe(`apiKey: "${REDACTED}"`) + expect(redactor.redactText('"token": "abcdefgh"')).toBe(`"token": "${REDACTED}"`) + expect(redactor.redactText('API_KEY=sk-abcdefghij1234567890')).toBe(`API_KEY=${REDACTED}`) + }) + + it('keeps non-secret assignments and whitespace-only secret values intact', () => { + expect(redactor.redactText('model: deepseek-chat')).toBe('model: deepseek-chat') + expect(redactor.redactText('password: \n')).toBe('password: \n') + }) + + it('redacts only the password in URL credentials, keeping the host', () => { + expect(redactor.redactText('url: https://user:s3cretPass@api.deepseek.com/v1')) + .toBe(`url: https://user:${REDACTED}@api.deepseek.com/v1`) + }) + + it('redacts bearer tokens embedded in free text', () => { + expect(redactor.redactText('sending Bearer abcdefgh12345678 now')) + .toBe(`sending Bearer ${REDACTED} now`) + }) + + it('redacts standalone secret-shaped tokens while keeping package names and paths', () => { + expect(redactor.redactText('key sk-abcdefghij1234567890 end')) + .toBe(`key ${REDACTED} end`) + expect(redactor.redactText('name: @deepseek-ai/dsh-telemetry')).toBe('name: @deepseek-ai/dsh-telemetry') + expect(redactor.redactText('path: ./plugins/local-plugin/src/index.ts')) + .toBe('path: ./plugins/local-plugin/src/index.ts') + }) + + it('is idempotent on already-redacted text', () => { + const once = redactor.redactText('password: hunter2') + expect(redactor.redactText(once)).toBe(once) + }) +}) diff --git a/packages/sdk/telemetry/tsconfig.json b/packages/sdk/telemetry/tsconfig.json new file mode 100644 index 0000000000..8acc8f11c5 --- /dev/null +++ b/packages/sdk/telemetry/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { "path": "../../util/brand" } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 25a8fadc47..0acc85fcab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1267,6 +1267,19 @@ importers: specifier: ^4.22.4 version: 4.22.4 + packages/sdk/telemetry: + dependencies: + yaml: + specifier: ^2.9.0 + version: 2.9.0 + devDependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/session-persistence/session-persistence: devDependencies: '@deepseek-ai/dsh-session': diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 4a845b4554..004338fa2d 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -54,6 +54,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/sdk/helper': { kind: 'none', reason: 'The project domain edits files and registers no live agent or model surface.' }, 'packages/sdk/plugin-fetch': { kind: 'none', reason: 'The fetcher acquires plugin sources into a temp dir and registers no live agent or model surface.' }, 'packages/sdk/scripts': { kind: 'indirect', reason: 'The launcher delegates model context to the loaded project plugin tree.' }, + 'packages/sdk/telemetry': { kind: 'none', reason: 'The launcher-side reporter sends developer-cycle telemetry and registers no live agent or model surface.' }, 'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' }, 'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' }, 'packages/skill/skill-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-skill.' }, diff --git a/tsconfig.build.json b/tsconfig.build.json index 9a6133db36..9359d7a531 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -99,6 +99,7 @@ { "path": "./packages/sdk/helper" }, { "path": "./packages/sdk/scripts" }, { "path": "./packages/sdk/create-sdk" }, - { "path": "./packages/sdk/plugin-fetch" } + { "path": "./packages/sdk/plugin-fetch" }, + { "path": "./packages/sdk/telemetry" } ] } diff --git a/tsconfig.json b/tsconfig.json index 4588c81c72..48c5187fdc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -110,6 +110,7 @@ { "path": "./packages/sdk/helper" }, { "path": "./packages/sdk/scripts" }, { "path": "./packages/sdk/create-sdk" }, - { "path": "./packages/sdk/plugin-fetch" } + { "path": "./packages/sdk/plugin-fetch" }, + { "path": "./packages/sdk/telemetry" } ] }