diff --git a/docs/telemetry.md b/docs/telemetry.md index 6eac16e..0e7ff94 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -1,6 +1,6 @@ -# TUI usage telemetry +# Telemetry -MCode's TUI usage telemetry is disabled by default. No business telemetry client is created and no business telemetry request is sent until the user opts in. +All automatic telemetry uploads are disabled by default and each requires its own opt-in: TUI usage events (`telemetry.enabled`), runtime performance metrics (`telemetry.metrics`), and automatic error diagnostics (`telemetry.diagnostics`). Opting in to usage events does not authorize the other two channels. Login, model requests, update checks, and user-submitted feedback are separate product actions and are not controlled by these switches. ## Turn it on or off @@ -8,19 +8,21 @@ Add this to the active profile's `config.yaml`, normally `~/.minimax-code/config ```yaml telemetry: - enabled: true + enabled: true # TUI usage events (anonymous, described below) + metrics: false # Runtime counters, gauges, and histograms + diagnostics: false # Account-linked TUI and LLM error diagnostics ``` -Remove the setting or set it to `false` to turn reporting off. Either environment variable below also turns it off and takes precedence over the config file: +Remove a setting or set it to `false` to turn that channel off. Either environment variable below turns **all channels** off and takes precedence over the config file: ```sh MCODE_DISABLE_TELEMETRY=1 mcode DO_NOT_TRACK=1 mcode ``` -Inspect the effective setting with `mcode telemetry status`. Run `mcode telemetry preview` to see a representative decoded request. Preview does not send a request. When telemetry is disabled, preview shows `request: null`. +Inspect the effective setting of every channel with `mcode telemetry status`. Run `mcode telemetry preview` to see a representative decoded usage-event request. Preview does not send a request. When usage telemetry is disabled, preview shows `request: null`, independently of the metrics and diagnostics settings. -## Data sent +## Usage-event data sent The HTTP body is `application/x-www-form-urlencoded` with two fields: @@ -68,7 +70,7 @@ MCode does not send account IDs, device IDs, workspace paths or names, session I As with any network request, the receiving server can observe transport metadata such as the source IP address. The client does not add that value to the event payload. `mcode telemetry preview` displays the decoded envelope. -## Destinations +## Usage-event destinations The destination depends on region and build environment: @@ -79,4 +81,12 @@ The destination depends on region and build environment: The client keeps pending events only in memory and does not write them to disk. This repository does not define or verify server-side retention. Keep telemetry disabled when that policy does not meet your requirements. -This page covers TUI usage telemetry. Login, model requests, update checks, user-submitted feedback, and bounded error diagnostics have separate network behavior described in [TUI capability coverage](tui-capabilities.md). +## Runtime performance metrics + +`telemetry.metrics: true` enables the built-in cloud metrics transport: metric names, timestamps, counter/gauge/histogram values, and low-cardinality labels (runtime owner and mode, version, and per-instrument dimensions such as model, tool, or outcome). No account credential is attached. Production destinations are `https://agent.minimax.cn/matrix/api/v1/metrics/batch` (China) and `https://agent.minimax.io/matrix/api/v1/metrics/batch` (global). Metrics stay in memory; when the channel is disabled, no cloud reporter is created. + +## Automatic error diagnostics + +`telemetry.diagnostics: true` authorizes both TUI incident reports and LLM request-failure reports. Both additionally require a signed-in account: the transport uses the account's Bearer token and a `user_id` query parameter, so these reports are **account-linked** even though their contents are minimized and encrypted. The minimization schemas are described in [TUI capability coverage](tui-capabilities.md#diagnostic-upload-privacy). Both use `/minimax-cloud/api/v1/observability/desktop-errors/batch` on the regional MiniMax host. When the channel is disabled, TUI incidents are written as local-only files (7 days / 200 files) that are never uploaded, and LLM failure reports are dropped before buffering. + +Server-side retention for any channel is not defined or verified by this repository. Login, model requests, update checks, and user-submitted feedback have separate network behavior described in [TUI capability coverage](tui-capabilities.md). diff --git a/docs/tui-capabilities.md b/docs/tui-capabilities.md index 5d996e3..9eb47d9 100644 --- a/docs/tui-capabilities.md +++ b/docs/tui-capabilities.md @@ -14,8 +14,8 @@ The evidence column summarizes the historical TUI 0.3.11 restoration record from | Managed connectors | Cloud client, permissions, and invocation adapters restored | Connector runtime and cloud transport tests; actual tool discovery passed; business writes not run | | Updates | Update entry points, install-source detection, and signature verification restored; public packages use public npm | Update application and service tests; no real installation / upgrade on the development machine | | Feedback and diagnostic uploads | Reviewed feedback text and minimized diagnostic summaries | Synthetic fixtures exercise session collection and capture/decode the final ZIP upload; no live uploads or real content | -| Automatic LLM error reports | Bounded diagnostic facts minimized before encryption | Synthetic provider errors and native Headers; final HTTP batches captured and decrypted locally | -| Telemetry | Disabled by default; explicit opt-in with environment overrides; account, device, workspace, session, model, and command identifiers removed | Privacy regression tests intercept and decode requests locally; no live upload | +| Automatic LLM error reports | Disabled by default; requires `telemetry.diagnostics` opt-in; bounded diagnostic facts minimized before encryption | Synthetic provider errors and native Headers; final HTTP batches captured and decrypted locally | +| Telemetry | Usage, metrics, and diagnostics each disabled by default with separate opt-ins; `MCODE_DISABLE_TELEMETRY` / `DO_NOT_TRACK` override all channels; usage-event identifiers removed | Privacy regression tests intercept and decode requests locally; no live upload | | Auto permissions | Cloud classifier restored; local rules and confirmation on failure retained | Classifier client, permission facade, and sandbox tests | | Model catalog | Online catalog and bundled snapshot fallback restored | Actual build boundary checks and offline startup; online catalog contents not accepted | | Files, shell, subagents, sessions, headless, ACP | Actual runtime retained | BYOK, file reads, session resume, ACP, sandbox, and status protocol tests | diff --git a/packages/config/src/config.ts b/packages/config/src/config.ts index 4d1bab0..74254ed 100644 --- a/packages/config/src/config.ts +++ b/packages/config/src/config.ts @@ -917,6 +917,10 @@ export interface ReviewConfig { export interface TelemetryConfig { /** Send anonymous TUI usage events. Disabled until the user opts in. */ enabled: boolean; + /** Send runtime performance metrics. Separate opt-in, disabled by default. */ + metrics?: boolean; + /** Send minimized, account-linked automatic error reports. Separate opt-in, disabled by default. */ + diagnostics?: boolean; } export interface Config { @@ -1765,7 +1769,7 @@ const DEFAULTS: Omit< // No status line items by default: the TUI picks its build-specific default // when `tui.statusLine` is absent. tui: {}, - telemetry: { enabled: false }, + telemetry: { enabled: false, metrics: false, diagnostics: false }, opencode: { xdg: { dataIsolation: false, @@ -2098,6 +2102,8 @@ function parseTelemetryConfig(raw: unknown): TelemetryConfig { const enabled = Reflect.get(raw, "enabled"); return { enabled: typeof enabled === "boolean" ? enabled : DEFAULTS.telemetry.enabled, + metrics: Reflect.get(raw, "metrics") === true, + diagnostics: Reflect.get(raw, "diagnostics") === true, }; } diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 866c0a0..6b3563f 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -1,3 +1,4 @@ +export { isTelemetryChannelEnabled, type TelemetryChannel } from './telemetry-policy.js'; export { isLocalSourceProvenanceEnabled } from './source-provenance.js'; export { writeTuiStatusLineSetting } from './tui-status-line-write.js'; export { parseRunawayGuardOverride, resolveRunawayGuardConfig } from './runaway-guard-config.js'; diff --git a/packages/config/src/telemetry-policy.ts b/packages/config/src/telemetry-policy.ts new file mode 100644 index 0000000..f26323d --- /dev/null +++ b/packages/config/src/telemetry-policy.ts @@ -0,0 +1,23 @@ +import { getConfig, type TelemetryConfig } from './config.js'; + +export type TelemetryChannel = keyof TelemetryConfig; + +const TELEMETRY_OPT_OUT_ENV = ['MCODE_DISABLE_TELEMETRY', 'DO_NOT_TRACK'] as const; + +/** + * True only when the channel is explicitly opted in and no global opt-out is set. + * An unreadable config never authorizes an upload. + */ +export function isTelemetryChannelEnabled( + channel: TelemetryChannel, + readConfigured: () => boolean | undefined = () => getConfig().telemetry[channel], +): boolean { + for (const key of TELEMETRY_OPT_OUT_ENV) { + if (/^(?:1|true|yes|on)$/iu.test(process.env[key]?.trim() ?? '')) return false; + } + try { + return readConfigured() === true; + } catch { + return false; + } +} diff --git a/packages/local-runtime/src/error-reporting/reporter.ts b/packages/local-runtime/src/error-reporting/reporter.ts index 09eb831..e0c7262 100644 --- a/packages/local-runtime/src/error-reporting/reporter.ts +++ b/packages/local-runtime/src/error-reporting/reporter.ts @@ -15,7 +15,7 @@ * every stage; never expose them to callers or affect LLM requests, retries, or turn results. */ -import { getRuntimeBuildEnv, getRuntimeRegion } from '@mavis/config'; +import { getRuntimeBuildEnv, getRuntimeRegion, isTelemetryChannelEnabled } from '@mavis/config'; import { LLM_ERROR_REASONS } from '@mavis/shared/llm-error-classifier'; import { logger } from '../common/logger.js'; @@ -62,6 +62,7 @@ export function createDesktopErrorReporter( /** Read login state, encrypt each event_log, and POST the batch without letting failures affect the main flow. */ async function encryptAndSend(events: DesktopErrorLog[]): Promise { + if (!isTelemetryChannelEnabled('diagnostics', options.readTelemetryEnabled)) return; const authContext = options.authContextGetter?.(); const token = authContext?.accessToken?.trim(); const userId = authContext?.realUserID?.trim(); @@ -103,6 +104,7 @@ export function createDesktopErrorReporter( return { report(event: DesktopErrorLog): void { try { + if (!isTelemetryChannelEnabled('diagnostics', options.readTelemetryEnabled)) return; // Drop oversized raw logs entirely, never truncate (design ยง2). Measure plaintext size to bound both // memory usage and the final request body. const byteLength = Buffer.byteLength(event.event_log, 'utf8'); diff --git a/packages/local-runtime/src/error-reporting/types.ts b/packages/local-runtime/src/error-reporting/types.ts index c74e6a9..f5d8ed8 100644 --- a/packages/local-runtime/src/error-reporting/types.ts +++ b/packages/local-runtime/src/error-reporting/types.ts @@ -67,6 +67,8 @@ export interface DesktopErrorReporter { * DESKTOP_ERROR_REPORTING_DEFAULTS}); tests may override them for deterministic results. */ export interface DesktopErrorReporterOptions { + /** Explicit `telemetry.diagnostics` opt-in; environment opt-outs always take precedence. */ + readTelemetryEnabled?: () => boolean | undefined; /** Read live login state (token and real user ID) for each send. */ authContextGetter?: () => LocalRuntimeAuthContext | undefined; /** Managed-backend routing headers, consistent with other cloud calls. */ diff --git a/packages/local-runtime/src/runtime/host-metrics.ts b/packages/local-runtime/src/runtime/host-metrics.ts index e82642a..e4d8187 100644 --- a/packages/local-runtime/src/runtime/host-metrics.ts +++ b/packages/local-runtime/src/runtime/host-metrics.ts @@ -1,4 +1,9 @@ -import { getRuntimeBuildEnv, getRuntimeRegion, isManagedRuntime } from '@mavis/config'; +import { + getRuntimeBuildEnv, + getRuntimeRegion, + isManagedRuntime, + isTelemetryChannelEnabled, +} from '@mavis/config'; import { createPiTurnHistogramBucketsByName } from '@mavis/agent-core/pi-turn-runner'; import { logger } from '../common/logger.js'; @@ -12,6 +17,8 @@ import { import type { LocalRuntimeMode } from './mode.js'; export interface LocalRuntimeHostMetricsOptions { + /** Explicit `telemetry.metrics` opt-in; environment opt-outs always take precedence. */ + readonly readTelemetryEnabled?: () => boolean | undefined; readonly runtimeOwnerKind: string; readonly runtimeMode?: LocalRuntimeMode; readonly appVersion?: string; @@ -101,7 +108,9 @@ export function buildLocalRuntimeMetricsClient( options: LocalRuntimeHostMetricsOptions, ): MetricsClient { const managed = isManagedRuntime(); - const reporter = options.metricsReporter ?? buildManagedReporter(managed); + // The built-in cloud transport additionally requires an explicit `telemetry.metrics` opt-in. + const cloudEnabled = managed && isTelemetryChannelEnabled('metrics', options.readTelemetryEnabled); + const reporter = options.metricsReporter ?? buildManagedReporter(cloudEnabled); const onError = (err: unknown): void => { logger.warn( @@ -110,8 +119,11 @@ export function buildLocalRuntimeMetricsClient( ); }; - if (!managed && !options.metricsReporter) { - logger.info({ reason: 'unmanaged_runtime' }, 'Local runtime metrics reporter disabled'); + if (!cloudEnabled && !options.metricsReporter) { + logger.info( + { reason: managed ? 'telemetry_metrics_opt_in_required' : 'unmanaged_runtime' }, + 'Local runtime metrics reporter disabled', + ); } return createLocalRuntimeMetricsClient({ diff --git a/packages/local-runtime/test/unit/error-reporting-privacy.test.ts b/packages/local-runtime/test/unit/error-reporting-privacy.test.ts index 40f129e..9a7802a 100644 --- a/packages/local-runtime/test/unit/error-reporting-privacy.test.ts +++ b/packages/local-runtime/test/unit/error-reporting-privacy.test.ts @@ -26,9 +26,10 @@ function receiverDecrypt(event: DesktopErrorLog): string { ); } -function reporterFixture() { +function reporterFixture(readTelemetryEnabled: () => boolean | undefined = () => true) { const requests: Array<{ url: string; init: RequestInit }> = []; const reporter = createDesktopErrorReporter({ + readTelemetryEnabled, authContextGetter: () => ({ accessToken: token, realUserID: userId }), region: () => 'en', buildEnv: () => 'prod', @@ -41,6 +42,44 @@ function reporterFixture() { } describe('automatic error upload privacy boundary', () => { + it.each([undefined, false])('does not upload without an explicit diagnostics opt-in (%s)', async (enabled) => { + const { reporter, requests } = reporterFixture(() => enabled); + reporter.report({ event_type: 'llm_request_failure', event_log: '{}', occurred_at_ms: 1, code_location: 'synthetic' }); + await reporter.flush(); + expect(requests).toEqual([]); + }); + + it.each(['MCODE_DISABLE_TELEMETRY', 'DO_NOT_TRACK'])('%s overrides the diagnostics opt-in', async (key) => { + vi.stubEnv(key, '1'); + try { + const { reporter, requests } = reporterFixture(() => true); + reporter.report({ event_type: 'llm_request_failure', event_log: '{}', occurred_at_ms: 1, code_location: 'synthetic' }); + await reporter.flush(); + expect(requests).toEqual([]); + } finally { + vi.unstubAllEnvs(); + } + }); + + it.each(['config', 'MCODE_DISABLE_TELEMETRY', 'DO_NOT_TRACK'])('drops buffered diagnostics when %s revokes consent before flushing', async (source) => { + let enabled = true; + const { reporter, requests } = reporterFixture(() => enabled); + try { + reporter.report({ event_type: 'llm_request_failure', event_log: '{}', occurred_at_ms: 1, code_location: 'synthetic' }); + if (source === 'config') enabled = false; + else vi.stubEnv(source, '1'); + await reporter.flush(); + expect(requests).toEqual([]); + enabled = true; + vi.unstubAllEnvs(); + await reporter.flush(); + expect(requests).toEqual([]); + } finally { + await reporter.close(); + vi.unstubAllEnvs(); + } + }); + it('captures the final HTTP batch and decrypts it with receiver credentials without recovering provider content', async () => { const { reporter, requests } = reporterFixture(); const headers = new Headers({ diff --git a/packages/local-runtime/test/unit/metrics-telemetry-privacy.test.ts b/packages/local-runtime/test/unit/metrics-telemetry-privacy.test.ts new file mode 100644 index 0000000..cd02f14 --- /dev/null +++ b/packages/local-runtime/test/unit/metrics-telemetry-privacy.test.ts @@ -0,0 +1,55 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { buildLocalRuntimeMetricsClient } from '../../src/runtime/host-metrics.js'; +import type { MetricsClient } from '../../src/common/metrics.js'; + +const clients: MetricsClient[] = []; +const fetchRequest = vi.fn(); +beforeEach(() => { + vi.stubEnv('__MAVIS_RUNTIME_MANAGED', '1'); + vi.stubEnv('MAVIS_BUILD_ENV', 'prod'); + vi.stubEnv('MAVIS_REGION', 'en'); + vi.stubEnv('MCODE_DISABLE_TELEMETRY', ''); + vi.stubEnv('DO_NOT_TRACK', ''); + fetchRequest.mockReset().mockResolvedValue(new Response('{}', { status: 200 })); + vi.stubGlobal('fetch', fetchRequest); +}); +afterEach(async () => { + for (const client of clients.splice(0)) await client.close(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); + +function fixture(metrics?: boolean) { + const client = buildLocalRuntimeMetricsClient({ + runtimeOwnerKind: 'tui', + appVersion: '0.4.12', + readTelemetryEnabled: () => metrics, + }); + clients.push(client); + return client; +} + +describe('automatic runtime metrics consent', () => { + it.each([undefined, false])('does not send production metrics without an explicit opt-in (%s)', async (enabled) => { + const client = fixture(enabled); + client.counter('started_total', 1); + await client.flush(); + expect(fetchRequest).not.toHaveBeenCalled(); + }); + + it('sends metrics only after the metrics opt-in', async () => { + const client = fixture(true); + client.counter('started_total', 1); + await client.flush(); + expect(fetchRequest).toHaveBeenCalledOnce(); + expect(String(fetchRequest.mock.calls[0]![0])).toBe('https://agent.minimax.io/matrix/api/v1/metrics/batch'); + }); + + it.each(['MCODE_DISABLE_TELEMETRY', 'DO_NOT_TRACK'])('%s overrides the metrics opt-in', async (key) => { + vi.stubEnv(key, '1'); + const client = fixture(true); + client.counter('started_total', 1); + await client.flush(); + expect(fetchRequest).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/tui/src/cli/telemetry-command.ts b/packages/tui/src/cli/telemetry-command.ts index f5bf191..81f8bb1 100644 --- a/packages/tui/src/cli/telemetry-command.ts +++ b/packages/tui/src/cli/telemetry-command.ts @@ -23,16 +23,20 @@ export function runMcodeTelemetryCommand( ): string { const environment = dependencies.environment ?? process.env; const config = (dependencies.readConfig ?? getConfig)(); - const policy = resolveMcodeBusinessTelemetryPolicy({ - configEnabled: config.telemetry.enabled, - environment, - }); + const channel = (configEnabled: boolean | undefined) => + resolveMcodeBusinessTelemetryPolicy({ configEnabled, environment }); + const policy = channel(config.telemetry.enabled); const status = { enabled: policy.enabled, configured: policy.configured, blockedBy: policy.blockedBy ?? null, configFile: (dependencies.readConfigPath ?? getConfigPath)(), - optInSetting: { telemetry: { enabled: true } }, + channels: { + usage: policy, + metrics: channel(config.telemetry.metrics), + diagnostics: channel(config.telemetry.diagnostics), + }, + optInSetting: { telemetry: { enabled: true, metrics: true, diagnostics: true } }, optOutEnvironment: ['MCODE_DISABLE_TELEMETRY=1', 'DO_NOT_TRACK=1'], }; if (action === 'status') return `${JSON.stringify(status, null, 2)}\n`; @@ -41,7 +45,7 @@ export function runMcodeTelemetryCommand( { ...status, request: null, - message: 'Telemetry is disabled. No business telemetry request will be sent.', + message: 'Usage telemetry is disabled. No business telemetry request will be sent.', }, null, 2, diff --git a/packages/tui/src/observability/incident-reporter.ts b/packages/tui/src/observability/incident-reporter.ts index 8266933..dd0ff95 100644 --- a/packages/tui/src/observability/incident-reporter.ts +++ b/packages/tui/src/observability/incident-reporter.ts @@ -12,7 +12,7 @@ import { import { arch, platform } from 'node:os'; import { basename, dirname, join } from 'node:path'; -import type { MavisBuildEnv, MavisRegion } from '@mavis/config'; +import { isTelemetryChannelEnabled, type MavisBuildEnv, type MavisRegion } from '@mavis/config'; export type TuiIncidentPhase = 'startup' | 'runtime' | 'shutdown'; export type TuiIncidentSeverity = 'fatal' | 'error' | 'warning'; @@ -66,6 +66,8 @@ export interface TuiIncidentAuthContext { } export interface CreateTuiIncidentReporterOptions { + /** Explicit `telemetry.diagnostics` opt-in; environment opt-outs always take precedence. */ + readonly readTelemetryEnabled?: () => boolean | undefined; readonly dataDir: string; readonly appVersion: string; readonly region: MavisRegion; @@ -313,7 +315,10 @@ class LocalTuiIncidentReporter implements TuiIncidentReporter { }; writeJsonAtomically( this.directory, - join(this.directory, `pending-${occurredAtMs}-${incidentId}.json`), + join( + this.directory, + `${this.uploadEnabled() ? 'pending' : 'local'}-${occurredAtMs}-${incidentId}.json`, + ), stored, ); this.prune(); @@ -379,6 +384,7 @@ class LocalTuiIncidentReporter implements TuiIncidentReporter { } private async drainPending(): Promise { + if (!this.uploadEnabled()) return; const resolveAuthContext = this.options.resolveAuthContext; if (!resolveAuthContext) return; let auth: TuiIncidentAuthContext | undefined; @@ -415,7 +421,7 @@ class LocalTuiIncidentReporter implements TuiIncidentReporter { accessToken: string, realUserID: string, ): Promise { - if (this.networkDrainStopped) return false; + if (this.networkDrainStopped || !this.uploadEnabled()) return false; const requestController = new AbortController(); const requestTimeout = setTimeout( () => requestController.abort(), @@ -502,10 +508,14 @@ class LocalTuiIncidentReporter implements TuiIncidentReporter { } } + private uploadEnabled(): boolean { + return isTelemetryChannelEnabled('diagnostics', this.options.readTelemetryEnabled); + } + private prune(): void { const nowMs = this.nowMs(); const incidentFiles = safeReadDirectory(this.directory) - .filter((entry) => /^(?:pending|sent)-.*\.json$/u.test(entry)) + .filter((entry) => /^(?:pending|sent|local)-.*\.json$/u.test(entry)) .flatMap((entry) => { const path = join(this.directory, entry); try { @@ -527,6 +537,7 @@ class LocalTuiIncidentReporter implements TuiIncidentReporter { } const retained = incidentFiles.filter((file) => nowMs - file.modifiedAtMs <= RETENTION_MS); const deletionOrder = [ + ...retained.filter((file) => basename(file.path).startsWith('local-')), ...retained.filter((file) => basename(file.path).startsWith('sent-')), ...retained.filter((file) => basename(file.path).startsWith('pending-')), ]; diff --git a/packages/tui/test/unit/business-telemetry-privacy.test.ts b/packages/tui/test/unit/business-telemetry-privacy.test.ts index afc50e9..7a3c927 100644 --- a/packages/tui/test/unit/business-telemetry-privacy.test.ts +++ b/packages/tui/test/unit/business-telemetry-privacy.test.ts @@ -176,6 +176,23 @@ describe('business telemetry privacy', () => { expect(createTelemetry).toHaveBeenCalledOnce(); }); + it('distinguishes disabled usage previews from enabled metrics and diagnostics', () => { + const output = JSON.parse(runMcodeTelemetryCommand('preview', '0.4.12', { + environment: {}, + readConfig: () => ({ telemetry: { enabled: false, metrics: true, diagnostics: true } }), + readConfigPath: () => '/tmp/config.yaml', + })); + expect(output).toMatchObject({ + request: null, + channels: { + usage: { enabled: false }, + metrics: { enabled: true }, + diagnostics: { enabled: true }, + }, + message: 'Usage telemetry is disabled. No business telemetry request will be sent.', + }); + }); + it('reports disabled status and exposes status and preview CLI subcommands', async () => { const output = runMcodeTelemetryCommand('preview', '0.4.12', { environment: {}, diff --git a/packages/tui/test/unit/headless-config.test.ts b/packages/tui/test/unit/headless-config.test.ts index 270c422..d6f5dbc 100644 --- a/packages/tui/test/unit/headless-config.test.ts +++ b/packages/tui/test/unit/headless-config.test.ts @@ -58,7 +58,7 @@ describe('explicit headless config', () => { minCandidateKiB: 3, keepRecentRounds: 2, }, - telemetry: { enabled: true }, + telemetry: { enabled: true, metrics: false, diagnostics: false }, }); }); @@ -87,7 +87,7 @@ describe('explicit headless config', () => { minCandidateKiB: 2, keepRecentRounds: 5, }, - telemetry: { enabled: false }, + telemetry: { enabled: false, metrics: false, diagnostics: false }, }); }); }); diff --git a/packages/tui/test/unit/incident-reporter-privacy.test.ts b/packages/tui/test/unit/incident-reporter-privacy.test.ts index 0192748..ebe9cc6 100644 --- a/packages/tui/test/unit/incident-reporter-privacy.test.ts +++ b/packages/tui/test/unit/incident-reporter-privacy.test.ts @@ -10,6 +10,7 @@ import { import type { TuiIncidentCapture, TuiIncidentReporter, + CreateTuiIncidentReporterOptions, } from '../../src/observability/incident-reporter.js'; const privateText = 'Unannounced acquisition of Example Company /home/private/client-plan.txt'; @@ -29,13 +30,19 @@ function temporaryDirectory() { return dataDir; } -function fixture(dataDir = temporaryDirectory(), authenticated = true) { +function fixture( + dataDir = temporaryDirectory(), + authenticated = true, + readTelemetryEnabled: () => boolean | undefined = () => true, + resolveAuthContext?: CreateTuiIncidentReporterOptions['resolveAuthContext'], +) { const requests: Array<{ url: string; init: RequestInit }> = []; const fetchImpl = vi.fn(async (url, init) => { requests.push({ url: String(url), init: init! }); return new Response(null, { status: 204 }); }); const reporter = createTuiIncidentReporter({ + readTelemetryEnabled, dataDir, appVersion: '0.4.12', region: 'en', @@ -46,8 +53,8 @@ function fixture(dataDir = temporaryDirectory(), authenticated = true) { terminal: privateText, osVersion: privateText, tuiMode: privateText, - resolveAuthContext: () => - authenticated ? { accessToken: token, realUserID: userId } : undefined, + resolveAuthContext: resolveAuthContext ?? (() => + authenticated ? { accessToken: token, realUserID: userId } : undefined), fetchImpl, }); reporters.push(reporter); @@ -109,6 +116,50 @@ function diskRecords(directory: string) { } describe('TUI automatic incident HTTP privacy boundary', () => { + it.each([undefined, false])('keeps incidents local without an explicit diagnostics opt-in (%s)', async (enabled) => { + const { reporter, requests, directory } = fixture(undefined, true, () => enabled); + reporter.capture(input(new Error('synthetic'))); + await reporter.drain(); + expect(requests).toEqual([]); + const files = readdirSync(directory); + expect(files.filter((name) => name.startsWith('local-'))).toHaveLength(1); + expect(files.some((name) => name.startsWith('pending-'))).toBe(false); + }); + + it.each(['MCODE_DISABLE_TELEMETRY', 'DO_NOT_TRACK'])('%s overrides the diagnostics opt-in', async (key) => { + vi.stubEnv(key, '1'); + try { + const { reporter, requests } = fixture(undefined, true, () => true); + reporter.capture(input(new Error('synthetic'))); + await reporter.drain(); + expect(requests).toEqual([]); + } finally { + vi.unstubAllEnvs(); + } + }); + + it.each(['config', 'MCODE_DISABLE_TELEMETRY', 'DO_NOT_TRACK'])('does not upload when %s revokes consent during authentication', async (source) => { + let enabled = true; + let resolveAuth!: (auth: { accessToken: string; realUserID: string }) => void; + const auth = new Promise<{ accessToken: string; realUserID: string }>((resolve) => { + resolveAuth = resolve; + }); + const resolveAuthContext = vi.fn(() => auth); + const { reporter, requests } = fixture(undefined, true, () => enabled, resolveAuthContext); + try { + reporter.capture(input(new Error('synthetic'))); + const drain = reporter.drain(); + expect(resolveAuthContext).toHaveBeenCalled(); + if (source === 'config') enabled = false; + else vi.stubEnv(source, '1'); + resolveAuth({ accessToken: token, realUserID: userId }); + await drain; + expect(requests).toEqual([]); + } finally { + vi.unstubAllEnvs(); + } + }); + it('decrypts the final fetch payload without recovering private text from any capture field', async () => { const { reporter, requests, directory } = fixture(); const getter = vi.fn(() => privateText); diff --git a/release/public-source.json b/release/public-source.json index f6c0e31..b9c1ff4 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -447,6 +447,7 @@ "packages/config/src/skill-evolve-config.ts", "packages/config/src/skills-config.ts", "packages/config/src/source-provenance.ts", + "packages/config/src/telemetry-policy.ts", "packages/config/src/test-port/index.ts", "packages/config/src/tool-result-compaction-config.ts", "packages/config/src/tui-config.ts", @@ -2624,6 +2625,7 @@ "packages/local-runtime/test/unit/local-thread-goal-orchestrator.test.ts", "packages/local-runtime/test/unit/local-thread-goal-store.test.ts", "packages/local-runtime/test/unit/local-thread-goal-wiring.test.ts", + "packages/local-runtime/test/unit/metrics-telemetry-privacy.test.ts", "packages/local-runtime/test/unit/thread-goal-kickoff-host.test.ts", "packages/local-runtime/test/unit/thread-goal-verifier-contract.test.ts", "packages/local-runtime/test/unit/thread-goal/contract.test.ts", diff --git a/test/vitest-suites.json b/test/vitest-suites.json index 42dbdb2..3568d35 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -15,6 +15,7 @@ "packages/tui/test/unit/daily-checkin-application.test.ts", "packages/tui/test/unit/daily-checkin-http-gateway.test.ts", "packages/local-runtime/test/unit/error-reporting-privacy.test.ts", + "packages/local-runtime/test/unit/metrics-telemetry-privacy.test.ts", "packages/tui/test/unit/runtime-feedback-service.test.ts", "packages/tui/test/unit/runtime-feedback-diagnostic-upload.test.ts", "packages/tui/test/unit/update-service.test.ts",