From 6d73db52e846cf87fb1445d3714a62b265a2cdfd Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 24 Jul 2026 00:02:12 -0400 Subject: [PATCH 1/5] feat: add explicit opt-in CLI telemetry controls --- README.md | 15 + .../optional-cli-usage-telemetry.md | 130 +++ package.json | 2 + .../ecc-cli-telemetry-deletion.schema.json | 28 + schemas/ecc-cli-telemetry-event.schema.json | 87 ++ scripts/ecc.js | 56 +- scripts/lib/cli-telemetry.js | 742 ++++++++++++++++++ scripts/lib/telemetry-state-lock.js | 108 +++ scripts/telemetry.js | 181 +++++ tests/lib/cli-telemetry.test.js | 717 +++++++++++++++++ tests/scripts/ecc.test.js | 1 + tests/scripts/npm-publish-surface.test.js | 6 + tests/scripts/telemetry.test.js | 214 +++++ 13 files changed, 2283 insertions(+), 4 deletions(-) create mode 100644 docs/architecture/optional-cli-usage-telemetry.md create mode 100644 schemas/ecc-cli-telemetry-deletion.schema.json create mode 100644 schemas/ecc-cli-telemetry-event.schema.json create mode 100644 scripts/lib/cli-telemetry.js create mode 100644 scripts/lib/telemetry-state-lock.js create mode 100644 scripts/telemetry.js create mode 100644 tests/lib/cli-telemetry.test.js create mode 100644 tests/scripts/telemetry.test.js diff --git a/README.md b/README.md index f986b6da39..417a19eeb8 100644 --- a/README.md +++ b/README.md @@ -1876,6 +1876,21 @@ ECC takes supply-chain and agent safety seriously. - **Built-in guardrails.** GateGuard gates destructive shell commands (including `rm`, force/path `git checkout`, and destructive `find -exec`) before they run; the supply-chain IOC scanner runs in CI; and [AgentShield](#agentshield--security-auditor) audits your own agent, hook, MCP, permission, and secret surfaces (`/security-scan`). - **Deep dive.** See the [Security Guide](./the-security-guide.md). +### Optional CLI usage telemetry + +CLI telemetry is **off by default** and never runs during package installation. +Only `ecc telemetry enable` persists consent; `ECC_TELEMETRY=0` is a hard +override. Use `ecc telemetry preview` to inspect the complete local event and +`ecc telemetry schema` to print its public allowlist. Events never contain +arguments, prompts, paths, usernames, repository names, credentials, RFQs, or +supplier/demand data; sponsor-specific commands are excluded from collection. +Delivery is inert unless an HTTPS endpoint is separately configured, and +deletion remains bound to every original receiving endpoint. + +See the +[optional telemetry architecture and privacy contract](docs/architecture/optional-cli-usage-telemetry.md) +before configuring collection. + --- ## Sponsors diff --git a/docs/architecture/optional-cli-usage-telemetry.md b/docs/architecture/optional-cli-usage-telemetry.md new file mode 100644 index 0000000000..1a1e05766b --- /dev/null +++ b/docs/architecture/optional-cli-usage-telemetry.md @@ -0,0 +1,130 @@ +# Optional ECC CLI Usage Telemetry + +ECC's default remains local-first: CLI usage telemetry is off unless a user +explicitly runs `ecc telemetry enable`. Installing or updating the npm package +does not enable telemetry, create an identifier, or make a telemetry request. +The package has no `preinstall`, `install`, or `postinstall` telemetry hook. + +This client is an endpoint-gated candidate. The public package does not contain +a default collection endpoint. An event can leave the machine only when both +of these conditions are true: + +1. the user has run `ecc telemetry enable`; and +2. `ECC_TELEMETRY_ENDPOINT` names a valid HTTPS endpoint. + +Do not add a default endpoint until an owner has published the privacy notice, +reviewed the lawful basis and processor contracts, and verified the server-side +retention, deletion, and source-IP controls below. + +## User Controls + +```bash +ecc telemetry status +ecc telemetry preview --command consult --result success --latency-ms 250 +ecc telemetry schema +ecc telemetry enable +ecc telemetry disable +ecc telemetry delete +ecc telemetry delete --local-only +ECC_TELEMETRY=0 ecc doctor +``` + +- `status` shows persisted consent, effective collection, endpoint readiness, + identifier presence, rotation, and retention without printing a local path + or identifier. +- `preview` builds an ephemeral, local-only event without persisting consent or + sending a request. +- `schema` prints the event allowlist in + `schemas/ecc-cli-telemetry-event.schema.json`. +- `enable` is the only way to persist consent. Setting an environment variable + cannot opt a user in. +- `disable` stops future collection and retains only the rotating identities + and bound endpoints needed for a later remote deletion request. +- Before an event is sent, its rotating identifier is durably bound to the + normalized endpoint that will receive it. `delete` sends the deletion + contract to every original bound endpoint and clears only endpoint/identifier + bindings whose deletion succeeded. Partial failures remain locally retryable. +- `delete --local-only` clears local state without making a request. +- `ECC_TELEMETRY=0` is a hard override, including for remote deletion. Remove + the override temporarily or use `--local-only` if deletion is required. + +## Complete Event Allowlist + +The event has `additionalProperties: false` and contains only: + +| Field | Value | +| --- | --- | +| `schemaVersion` | Fixed schema identifier | +| `anonymousId` | Random UUID rotated every 30 days | +| `packageName` / `packageVersion` | Published ECC package and version | +| `commandId` | Allowlisted sponsor-neutral top-level command or `other` | +| `result` | `success` or `failure` | +| `latencyBucket` | One of four coarse duration ranges | +| `os` | `macos`, `linux`, `windows`, or `other` | +| `arch` | `arm64`, `x64`, or `other` | + +There is no event timestamp; the ingestion service can use receipt time. ECC +does not capture or send command arguments, prompts, file paths, usernames, +repository names, hostnames, IP addresses as an event field, credentials, +cookies, RFQs, supplier details, demand details, prices, or quantities. + +The sponsor-specific `ito` command is deliberately excluded from collection; +telemetry cannot measure sponsor or RFQ usage. + +HTTP necessarily exposes a source address to the receiving network edge. The +ingestion service must disable source-IP storage in access logs and analytics, +must not enrich events with geolocation or organization identity, and must not +set cookies or join this identifier to account, marketing, RFQ, or billing data. + +## Endpoint Contract + +`ECC_TELEMETRY_ENDPOINT` must use HTTPS and cannot contain credentials, a query +string, or a fragment. The client binds the normalized endpoint to the +identifier before attempting delivery, so changing the environment later +cannot redirect a deletion request away from the original recipient. The +client sends: + +- `POST` with `ecc.cli-telemetry.v1`; or +- `DELETE` with `ecc.cli-telemetry-deletion.v1`. + +Delivery is best-effort, has a 750 ms timeout, and never changes the wrapped +command's result. Failed events are dropped rather than queued on disk. +Cross-process state locking serializes rotation, delivery, and deletion so an +emitted identifier remains covered by deletion and a deletion cannot race a +later event. The server must: + +- enforce the public schemas and reject additional properties; +- retain raw pseudonymous events for no more than 30 days; +- delete matching raw events when it accepts a deletion request; +- strip the rotating identifier before retaining longer-lived aggregate + counts; +- suppress small cohorts before publishing breakdowns; and +- publish current controller, processor, contact, purpose, retention, and + deletion information. + +The client displaying a 30-day ceiling does not prove server compliance. A +default endpoint must remain absent until these controls are independently +verified. + +## What The Analytics Can And Cannot Answer + +npm exposes package-level download counts, which can help measure aggregate +adoption, but those counts do not reveal which ECC commands or workflows were +used. Explicitly opted-in CLI events can estimate aggregate command usage, +failure rates, broad performance buckets, and platform compatibility needs. +They are a self-selected sample, not a user census, and must not be used to +identify or target individual people, companies, repositories, suppliers, or +demand. + +Relevant primary sources: + +- [npm package search and aggregate download-count sorting](https://docs.npmjs.com/searching-for-and-choosing-packages-to-download/) +- [npm lifecycle-script behavior](https://docs.npmjs.com/cli/using-npm/scripts/) +- [GDPR Articles 5, 6, 13, and 25](https://eur-lex.europa.eu/eli/reg/2016/679/oj) +- [California Privacy Protection Agency data-minimization advisory](https://cppa.ca.gov/pdf/enfadvisory202401.pdf) + +This design is a technical minimization baseline, not legal advice. Consent +does not by itself settle jurisdiction, controller obligations, employee-use +issues, international transfers, or whether a pseudonymous identifier is +personal information. Counsel should review the final endpoint, notice, and +retention implementation before any public collection endpoint is configured. diff --git a/package.json b/package.json index 5779d6d0aa..72463168e9 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "docs/vi-VN/", "docs/zh-CN/", "docs/zh-TW/", + "docs/architecture/optional-cli-usage-telemetry.md", "hooks/", "install.ps1", "install.sh", @@ -124,6 +125,7 @@ "scripts/setup-package-manager.js", "scripts/skill-create-output.js", "scripts/status.js", + "scripts/telemetry.js", "scripts/sync-ecc-to-codex.sh", "scripts/work-items.js", "scripts/uninstall.js", diff --git a/schemas/ecc-cli-telemetry-deletion.schema.json b/schemas/ecc-cli-telemetry-deletion.schema.json new file mode 100644 index 0000000000..c5df4050c3 --- /dev/null +++ b/schemas/ecc-cli-telemetry-deletion.schema.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/affaan-m/ECC/schemas/ecc-cli-telemetry-deletion.schema.json", + "title": "ECC CLI telemetry deletion request", + "description": "The complete payload for an explicit telemetry deletion request.", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "anonymousIds" + ], + "properties": { + "schemaVersion": { + "const": "ecc.cli-telemetry-deletion.v1" + }, + "anonymousIds": { + "type": "array", + "description": "Identifiers bound to the same original collection endpoint.", + "minItems": 1, + "maxItems": 64, + "uniqueItems": true, + "items": { + "type": "string", + "format": "uuid" + } + } + } +} diff --git a/schemas/ecc-cli-telemetry-event.schema.json b/schemas/ecc-cli-telemetry-event.schema.json new file mode 100644 index 0000000000..befa3303d6 --- /dev/null +++ b/schemas/ecc-cli-telemetry-event.schema.json @@ -0,0 +1,87 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/affaan-m/ECC/schemas/ecc-cli-telemetry-event.schema.json", + "title": "ECC privacy-safe CLI usage event", + "description": "The complete allowlist for an explicitly opted-in ECC CLI usage event. No command arguments or user/workspace data are permitted.", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "anonymousId", + "packageName", + "packageVersion", + "commandId", + "result", + "latencyBucket", + "os", + "arch" + ], + "properties": { + "schemaVersion": { + "const": "ecc.cli-telemetry.v1" + }, + "anonymousId": { + "type": "string", + "format": "uuid", + "description": "Random pseudonymous identifier rotated every 30 days." + }, + "packageName": { + "const": "ecc-universal" + }, + "packageVersion": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?$" + }, + "commandId": { + "enum": [ + "install", + "plan", + "catalog", + "consult", + "control-pane", + "list-installed", + "doctor", + "repair", + "auto-update", + "status", + "platform-audit", + "security-ioc-scan", + "sessions", + "work-items", + "session-inspect", + "loop-status", + "uninstall", + "other" + ] + }, + "result": { + "enum": [ + "success", + "failure" + ] + }, + "latencyBucket": { + "enum": [ + "under_100ms", + "100ms_to_1s", + "1s_to_10s", + "10s_or_more" + ] + }, + "os": { + "enum": [ + "macos", + "linux", + "windows", + "other" + ] + }, + "arch": { + "enum": [ + "arm64", + "x64", + "other" + ] + } + } +} diff --git a/scripts/ecc.js b/scripts/ecc.js index 00928c2a19..7c5141f768 100755 --- a/scripts/ecc.js +++ b/scripts/ecc.js @@ -5,6 +5,7 @@ const path = require('path'); const { listAvailableLanguages } = require('./lib/install-executor'); const { getComputeSponsorCopy } = require('./lib/compute-sponsor'); const { createSafeItoEnvironment } = require('./lib/ito-environment'); +const { recordCommandUsage } = require('./lib/cli-telemetry'); const COMMANDS = { install: { @@ -31,6 +32,10 @@ const COMMANDS = { script: 'ito.js', description: 'Invoke the separately installed canonical Itô compute CLI', }, + telemetry: { + script: 'telemetry.js', + description: 'Control optional privacy-safe CLI usage telemetry (default off)', + }, 'install-plan': { script: 'install-plan.js', description: 'Alias for plan', @@ -92,6 +97,7 @@ const PRIMARY_COMMANDS = [ 'consult', 'control-pane', 'ito', + 'telemetry', 'list-installed', 'doctor', 'repair', @@ -141,6 +147,10 @@ Examples: ecc ito auth ecc ito find --gpu h200 --count 8 --nodes 1 --gpus-per-node 8 --days 30 --storage-tb 1 --start-window 2099-08-15 --max-rate 3.00 --form-factor bare_metal --contract-type reservation --fabric infiniband --region us-east-1 ecc ito status --json + ecc telemetry status + ecc telemetry preview --command consult --result success --latency-ms 250 + ecc telemetry enable + ECC_TELEMETRY=0 ecc doctor ecc list-installed --json ecc doctor --target cursor ecc repair --dry-run @@ -268,7 +278,17 @@ function runCommand(commandName, args) { return 1; } -function main() { +async function recordCommandUsageSafely(input, recorder = recordCommandUsage) { + try { + return await recorder(input); + } catch { + return { sent: false, reason: 'telemetry_unavailable' }; + } +} + +async function main() { + let trackedCommand = null; + const startedAt = Date.now(); try { const resolution = resolveCommand(process.argv); @@ -289,11 +309,39 @@ function main() { return; } - process.exitCode = runCommand(resolution.command, resolution.args); + trackedCommand = resolution.command; + const exitCode = runCommand(resolution.command, resolution.args); + if (trackedCommand !== 'telemetry' && trackedCommand !== 'ito') { + await recordCommandUsageSafely({ + commandId: trackedCommand, + exitCode, + elapsedMs: Date.now() - startedAt, + }); + } + process.exitCode = exitCode; } catch (error) { console.error(`Error: ${error.message}`); - process.exit(1); + if (trackedCommand && trackedCommand !== 'telemetry' && trackedCommand !== 'ito') { + await recordCommandUsageSafely({ + commandId: trackedCommand, + exitCode: 1, + elapsedMs: Date.now() - startedAt, + }); + } + process.exitCode = 1; } } -main(); +if (require.main === module) { + main().catch(error => { + console.error(`Error: ${error.message}`); + process.exitCode = 1; + }); +} + +module.exports = Object.freeze({ + main, + recordCommandUsageSafely, + resolveCommand, + runCommand, +}); diff --git a/scripts/lib/cli-telemetry.js b/scripts/lib/cli-telemetry.js new file mode 100644 index 0000000000..00edf876d7 --- /dev/null +++ b/scripts/lib/cli-telemetry.js @@ -0,0 +1,742 @@ +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const packageJson = require('../../package.json'); +const { acquireFileLock } = require('./telemetry-state-lock'); + +const EVENT_SCHEMA_VERSION = 'ecc.cli-telemetry.v1'; +const DELETION_SCHEMA_VERSION = 'ecc.cli-telemetry-deletion.v1'; +const CONSENT_SCHEMA_VERSION = 'ecc.cli-telemetry-consent.v2'; +const CONSENT_VERSION = 2; +const IDENTITY_ROTATION_DAYS = 30; +const EVENT_RETENTION_DAYS = 30; +const DELIVERY_TIMEOUT_MS = 750; +const DELETE_BATCH_SIZE = 64; +const DAY_MS = 24 * 60 * 60 * 1000; + +const COMMAND_IDS = Object.freeze([ + 'install', + 'plan', + 'catalog', + 'consult', + 'control-pane', + 'list-installed', + 'doctor', + 'repair', + 'auto-update', + 'status', + 'platform-audit', + 'security-ioc-scan', + 'sessions', + 'work-items', + 'session-inspect', + 'loop-status', + 'uninstall', + 'other', +]); + +const COMMAND_ID_SET = new Set(COMMAND_IDS); +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +function resolveHomeDir(options = {}) { + const env = options.env || process.env; + const explicitHome = options.homeDir || env.HOME || env.USERPROFILE; + return path.resolve(explicitHome || os.homedir()); +} + +function getTelemetryConfigPath(options = {}) { + const env = options.env || process.env; + const configRoot = options.configDir + || (options.homeDir ? path.join(resolveHomeDir(options), '.config') : null) + || env.XDG_CONFIG_HOME + || path.join(resolveHomeDir(options), '.config'); + return path.join(path.resolve(configRoot), 'ecc', 'telemetry.json'); +} + +function getTelemetryLockPath(options = {}) { + return `${getTelemetryConfigPath(options)}.lock`; +} + +function parseInstant(value, fieldName) { + const instant = new Date(value); + if (Number.isNaN(instant.getTime())) { + throw new Error(`${fieldName} must be an ISO timestamp`); + } + return instant; +} + +function normalizeNow(now) { + return parseInstant(now || new Date().toISOString(), 'now').toISOString(); +} + +function normalizeEndpoint(endpoint) { + return resolveTelemetryEndpoint({ ECC_TELEMETRY_ENDPOINT: endpoint }); +} + +function isValidIdentity(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + if (!UUID_PATTERN.test(value.anonymousId || '')) return false; + if (typeof value.createdAt !== 'string' || Number.isNaN(Date.parse(value.createdAt))) { + return false; + } + if ( + value.retiredAt !== null + && (typeof value.retiredAt !== 'string' || Number.isNaN(Date.parse(value.retiredAt))) + ) { + return false; + } + if (!Array.isArray(value.endpoints)) return false; + if (new Set(value.endpoints).size !== value.endpoints.length) return false; + return value.endpoints.every(endpoint => { + if (typeof endpoint !== 'string') return false; + try { + return normalizeEndpoint(endpoint) === endpoint; + } catch { + return false; + } + }); +} + +function isValidConsentState(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + if (value.schemaVersion !== CONSENT_SCHEMA_VERSION) return false; + if (value.consentVersion !== CONSENT_VERSION) return false; + if (typeof value.enabled !== 'boolean') return false; + if (typeof value.updatedAt !== 'string' || Number.isNaN(Date.parse(value.updatedAt))) return false; + if (!Array.isArray(value.identities) || !value.identities.every(isValidIdentity)) return false; + + const identityIds = value.identities.map(identity => identity.anonymousId); + if (new Set(identityIds).size !== identityIds.length) return false; + if (value.activeAnonymousId !== null && !UUID_PATTERN.test(value.activeAnonymousId || '')) { + return false; + } + + const activeIdentity = value.activeAnonymousId === null + ? null + : value.identities.find(identity => identity.anonymousId === value.activeAnonymousId); + if (value.activeAnonymousId !== null && (!activeIdentity || activeIdentity.retiredAt !== null)) { + return false; + } + if (value.enabled && !activeIdentity) return false; + + return value.identities.every(identity => ( + identity.anonymousId === value.activeAnonymousId || identity.retiredAt !== null + )); +} + +function readConsentState(options = {}) { + const configPath = getTelemetryConfigPath(options); + if (!fs.existsSync(configPath)) { + return { state: 'absent', configPath, value: null }; + } + + try { + const value = JSON.parse(fs.readFileSync(configPath, 'utf8')); + if (!isValidConsentState(value)) { + return { state: 'invalid', configPath, value: null }; + } + return { state: 'valid', configPath, value }; + } catch { + return { state: 'invalid', configPath, value: null }; + } +} + +function writeConsentState(value, options = {}) { + if (!isValidConsentState(value)) { + throw new Error('Refusing to write invalid telemetry consent state'); + } + + const configPath = getTelemetryConfigPath(options); + const configDir = path.dirname(configPath); + fs.mkdirSync(configDir, { recursive: true, mode: 0o700 }); + const tempPath = path.join( + configDir, + `.telemetry-${process.pid}-${crypto.randomBytes(8).toString('hex')}.tmp` + ); + let tempFile = null; + + try { + tempFile = fs.openSync(tempPath, 'wx', 0o600); + fs.writeFileSync(tempFile, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); + fs.fsyncSync(tempFile); + const completedFile = tempFile; + tempFile = null; + fs.closeSync(completedFile); + fs.renameSync(tempPath, configPath); + try { + const configDirectory = fs.openSync(configDir, 'r'); + try { + fs.fsyncSync(configDirectory); + } finally { + fs.closeSync(configDirectory); + } + } catch { + // Directory fsync is unavailable on some platforms. + } + try { + fs.chmodSync(configPath, 0o600); + } catch { + // File modes are advisory or unsupported on some platforms. + } + } finally { + if (tempFile !== null) fs.closeSync(tempFile); + if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); + } + + return value; +} + +function acquireTelemetryLock(options = {}) { + return acquireFileLock(getTelemetryLockPath(options), options); +} + +function withTelemetryLockSync(options, fn) { + const release = acquireTelemetryLock(options); + try { + return fn(); + } finally { + release(); + } +} + +async function withTelemetryLock(options, fn) { + const release = acquireTelemetryLock(options); + try { + return await fn(); + } finally { + release(); + } +} + +function createIdentity(options = {}) { + const now = normalizeNow(options.now); + const randomUUID = options.randomUUID || crypto.randomUUID; + const anonymousId = randomUUID(); + if (!UUID_PATTERN.test(anonymousId)) { + throw new Error('Generated telemetry identity must be a UUID'); + } + return { + anonymousId, + createdAt: now, + retiredAt: null, + endpoints: [], + }; +} + +function createConsentState(identity, now) { + return { + schemaVersion: CONSENT_SCHEMA_VERSION, + consentVersion: CONSENT_VERSION, + enabled: true, + activeAnonymousId: identity.anonymousId, + identities: [identity], + updatedAt: now, + }; +} + +function enableTelemetry(options = {}) { + return withTelemetryLockSync(options, () => { + const now = normalizeNow(options.now); + const current = readConsentState(options); + if (current.state === 'absent') { + const identity = createIdentity({ ...options, now }); + return writeConsentState(createConsentState(identity, now), options); + } + if (current.state !== 'valid') { + throw new Error( + 'Telemetry state is invalid; use telemetry delete --local-only before enabling' + ); + } + + let identities = current.value.identities; + let activeAnonymousId = current.value.activeAnonymousId; + if (!activeAnonymousId) { + const identity = createIdentity({ ...options, now }); + identities = [...identities, identity]; + activeAnonymousId = identity.anonymousId; + } + + return writeConsentState({ + ...current.value, + enabled: true, + activeAnonymousId, + identities, + updatedAt: now, + }, options); + }); +} + +function disableTelemetry(options = {}) { + return withTelemetryLockSync(options, () => { + const now = normalizeNow(options.now); + const current = readConsentState(options); + if (current.state === 'absent') { + return { enabled: false, state: 'absent' }; + } + if (current.state !== 'valid') { + return { enabled: false, state: 'invalid' }; + } + + return writeConsentState({ + ...current.value, + enabled: false, + updatedAt: now, + }, options); + }); +} + +function clearTelemetryStateUnlocked(options = {}) { + const configPath = getTelemetryConfigPath(options); + let removed = false; + try { + fs.unlinkSync(configPath); + removed = true; + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + if (removed) { + try { + const configDirectory = fs.openSync(path.dirname(configPath), 'r'); + try { + fs.fsyncSync(configDirectory); + } finally { + fs.closeSync(configDirectory); + } + } catch { + // Directory fsync is unavailable on some platforms. + } + } +} + +function clearTelemetryState(options = {}) { + return withTelemetryLockSync(options, () => clearTelemetryStateUnlocked(options)); +} + +function getHardOverride(env = process.env) { + return String(env.ECC_TELEMETRY || '').trim() === '0' + ? 'ECC_TELEMETRY=0' + : null; +} + +function resolveTelemetryEndpoint(env = process.env) { + const rawEndpoint = String(env.ECC_TELEMETRY_ENDPOINT || '').trim(); + if (!rawEndpoint) return null; + + let endpoint; + try { + endpoint = new URL(rawEndpoint); + } catch { + throw new Error('ECC_TELEMETRY_ENDPOINT must be a valid HTTPS URL'); + } + if (endpoint.protocol !== 'https:') { + throw new Error('ECC_TELEMETRY_ENDPOINT must use HTTPS'); + } + if (endpoint.username || endpoint.password) { + throw new Error('ECC_TELEMETRY_ENDPOINT must not contain credentials'); + } + if (endpoint.search) { + throw new Error('ECC_TELEMETRY_ENDPOINT must not contain a query'); + } + if (endpoint.hash) { + throw new Error('ECC_TELEMETRY_ENDPOINT must not contain a fragment'); + } + return endpoint.toString(); +} + +function getTelemetryStatus(options = {}) { + const env = options.env || process.env; + const consent = readConsentState(options); + const persistedEnabled = consent.state === 'valid' && consent.value.enabled; + const override = getHardOverride(env); + let deliveryConfigured = false; + let endpointState = 'not_configured'; + + try { + deliveryConfigured = Boolean(resolveTelemetryEndpoint(env)); + if (deliveryConfigured) endpointState = 'configured'; + } catch { + endpointState = 'invalid'; + } + + const boundEndpoints = consent.state === 'valid' + ? new Set(consent.value.identities.flatMap(identity => identity.endpoints)) + : new Set(); + return { + schemaVersion: CONSENT_SCHEMA_VERSION, + state: consent.state, + persistedEnabled, + effectiveEnabled: Boolean(persistedEnabled && !override), + override, + anonymousIdPresent: Boolean( + consent.state === 'valid' && consent.value.identities.length > 0 + ), + boundEndpointCount: boundEndpoints.size, + deliveryConfigured, + endpointState, + identityRotationDays: IDENTITY_ROTATION_DAYS, + eventRetentionDays: EVENT_RETENTION_DAYS, + }; +} + +function getOrRotateActiveIdentityUnlocked(options = {}) { + const current = readConsentState(options); + if (current.state !== 'valid' || !current.value.enabled) return null; + + const activeIdentity = current.value.identities.find( + identity => identity.anonymousId === current.value.activeAnonymousId + ); + if (!activeIdentity) return null; + + const now = parseInstant(options.now || new Date().toISOString(), 'now'); + const createdAt = parseInstant(activeIdentity.createdAt, 'createdAt'); + if (now.getTime() - createdAt.getTime() < IDENTITY_ROTATION_DAYS * DAY_MS) { + return { state: current.value, identity: activeIdentity }; + } + + const retiredIdentity = { + ...activeIdentity, + retiredAt: now.toISOString(), + }; + const nextIdentity = createIdentity({ + ...options, + now: now.toISOString(), + }); + const nextState = writeConsentState({ + ...current.value, + activeAnonymousId: nextIdentity.anonymousId, + identities: [ + ...current.value.identities + .filter(identity => identity.anonymousId !== activeIdentity.anonymousId), + retiredIdentity, + nextIdentity, + ], + updatedAt: now.toISOString(), + }, options); + return { state: nextState, identity: nextIdentity }; +} + +function getActiveConsentState(options = {}) { + return withTelemetryLockSync(options, () => { + const active = getOrRotateActiveIdentityUnlocked(options); + if (!active) return null; + const previousAnonymousIds = active.state.identities + .filter(identity => identity.anonymousId !== active.identity.anonymousId) + .map(identity => ({ + anonymousId: identity.anonymousId, + retiredAt: identity.retiredAt, + endpoints: [...identity.endpoints], + })); + return { + ...active.state, + anonymousId: active.identity.anonymousId, + identityCreatedAt: active.identity.createdAt, + previousAnonymousIds, + }; + }); +} + +function bindIdentityToEndpoint(state, anonymousId, endpoint, options = {}) { + const identity = state.identities.find(item => item.anonymousId === anonymousId); + if (!identity) throw new Error('Active telemetry identity is missing'); + if (identity.endpoints.includes(endpoint)) return { state, identity }; + + const nextIdentity = { + ...identity, + endpoints: [...identity.endpoints, endpoint].sort(), + }; + const nextState = writeConsentState({ + ...state, + identities: state.identities.map(item => ( + item.anonymousId === anonymousId ? nextIdentity : item + )), + }, options); + return { state: nextState, identity: nextIdentity }; +} + +function coarsenCommandId(commandId) { + return COMMAND_ID_SET.has(commandId) ? commandId : 'other'; +} + +function coarsenResult(exitCode) { + return Number(exitCode) === 0 ? 'success' : 'failure'; +} + +function bucketLatency(elapsedMs) { + const duration = Math.max(0, Number(elapsedMs) || 0); + if (duration < 100) return 'under_100ms'; + if (duration < 1000) return '100ms_to_1s'; + if (duration < 10000) return '1s_to_10s'; + return '10s_or_more'; +} + +function coarsenOs(platform) { + const values = { + darwin: 'macos', + linux: 'linux', + win32: 'windows', + }; + return values[platform] || 'other'; +} + +function coarsenArch(arch) { + return arch === 'arm64' || arch === 'x64' ? arch : 'other'; +} + +function buildUsageEvent(input) { + if (!UUID_PATTERN.test(input.anonymousId || '')) { + throw new Error('anonymousId must be a UUID'); + } + + return Object.freeze({ + schemaVersion: EVENT_SCHEMA_VERSION, + anonymousId: input.anonymousId, + packageName: packageJson.name, + packageVersion: packageJson.version, + commandId: coarsenCommandId(input.commandId), + result: coarsenResult(input.exitCode), + latencyBucket: bucketLatency(input.elapsedMs), + os: coarsenOs(input.platform || process.platform), + arch: coarsenArch(input.arch || process.arch), + }); +} + +async function defaultTransport(endpoint, payload, options = {}) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), DELIVERY_TIMEOUT_MS); + try { + const response = await fetch(endpoint, { + method: options.method || 'POST', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + }, + body: JSON.stringify(payload), + cache: 'no-store', + credentials: 'omit', + redirect: 'error', + referrerPolicy: 'no-referrer', + signal: controller.signal, + }); + if (!response.ok) { + throw new Error(`Telemetry endpoint returned HTTP ${response.status}`); + } + } finally { + clearTimeout(timeout); + } +} + +async function recordCommandUsage(options = {}) { + try { + if (options.commandId === 'ito') { + return { sent: false, reason: 'excluded_command' }; + } + const env = options.env || process.env; + if (getHardOverride(env)) return { sent: false, reason: 'disabled' }; + + const initial = readConsentState(options); + if (initial.state !== 'valid' || !initial.value.enabled) { + return { sent: false, reason: 'disabled' }; + } + + let endpoint; + try { + endpoint = resolveTelemetryEndpoint(env); + } catch { + return { sent: false, reason: 'invalid_endpoint' }; + } + if (!endpoint) return { sent: false, reason: 'endpoint_not_configured' }; + + return await withTelemetryLock(options, async () => { + if (getHardOverride(env)) return { sent: false, reason: 'disabled' }; + const active = getOrRotateActiveIdentityUnlocked(options); + if (!active) return { sent: false, reason: 'disabled' }; + + const bound = bindIdentityToEndpoint( + active.state, + active.identity.anonymousId, + endpoint, + options + ); + const event = buildUsageEvent({ + anonymousId: bound.identity.anonymousId, + commandId: options.commandId, + exitCode: options.exitCode, + elapsedMs: options.elapsedMs, + platform: options.platform, + arch: options.arch, + }); + + try { + const transport = options.transport || defaultTransport; + await transport(endpoint, event, { method: 'POST' }); + return { sent: true }; + } catch { + return { sent: false, reason: 'delivery_failed' }; + } + }); + } catch { + return { sent: false, reason: 'telemetry_unavailable' }; + } +} + +function chunk(values, size) { + const chunks = []; + for (let index = 0; index < values.length; index += size) { + chunks.push(values.slice(index, index + size)); + } + return chunks; +} + +function retireAllIdentities(state, now) { + return { + ...state, + enabled: false, + activeAnonymousId: null, + identities: state.identities.map(identity => ({ + ...identity, + retiredAt: identity.retiredAt || now, + })), + updatedAt: now, + }; +} + +function deletionGroups(state) { + const groups = new Map(); + for (const identity of state.identities) { + for (const endpoint of identity.endpoints) { + const ids = groups.get(endpoint) || []; + groups.set(endpoint, [...ids, identity.anonymousId]); + } + } + return groups; +} + +function removeDeletedBindings(state, endpoint, anonymousIds) { + const deletedIds = new Set(anonymousIds); + return { + ...state, + identities: state.identities + .map(identity => ( + deletedIds.has(identity.anonymousId) + ? { + ...identity, + endpoints: identity.endpoints.filter(item => item !== endpoint), + } + : identity + )) + .filter(identity => identity.endpoints.length > 0), + }; +} + +async function deleteTelemetryData(options = {}) { + return withTelemetryLock(options, async () => { + const current = readConsentState(options); + if (options.localOnly) { + clearTelemetryStateUnlocked(options); + return { deleted: true, localOnly: true }; + } + if (current.state === 'absent') { + return { deleted: false, reason: 'no_local_identity' }; + } + if (current.state !== 'valid') { + throw new Error('Telemetry identity is invalid; use --local-only to remove local state'); + } + + const env = options.env || process.env; + const override = getHardOverride(env); + if (override) { + throw new Error( + `${override} blocks remote deletion; use --local-only or remove the override` + ); + } + + const now = normalizeNow(options.now); + let pendingState = retireAllIdentities(current.value, now); + pendingState = { + ...pendingState, + identities: pendingState.identities.filter(identity => identity.endpoints.length > 0), + }; + if (pendingState.identities.length === 0) { + clearTelemetryStateUnlocked(options); + return { deleted: true, localOnly: false, endpointCount: 0 }; + } + pendingState = writeConsentState(pendingState, options); + + const groups = deletionGroups(pendingState); + const transport = options.transport || defaultTransport; + let failedBatches = 0; + let succeededBatches = 0; + + for (const [endpoint, anonymousIds] of groups.entries()) { + for (const anonymousIdBatch of chunk(anonymousIds, DELETE_BATCH_SIZE)) { + try { + await transport(endpoint, { + schemaVersion: DELETION_SCHEMA_VERSION, + anonymousIds: anonymousIdBatch, + }, { method: 'DELETE' }); + succeededBatches += 1; + pendingState = removeDeletedBindings( + pendingState, + endpoint, + anonymousIdBatch + ); + if (pendingState.identities.length === 0) { + clearTelemetryStateUnlocked(options); + } else { + pendingState = writeConsentState({ + ...pendingState, + updatedAt: normalizeNow(options.now), + }, options); + } + } catch { + failedBatches += 1; + } + } + } + + if (failedBatches > 0) { + const error = new Error( + `Telemetry deletion failed for ${failedBatches} endpoint batch(es); retained local deletion state` + ); + error.code = 'ECC_TELEMETRY_DELETE_PARTIAL'; + throw error; + } + return { + deleted: true, + localOnly: false, + endpointCount: groups.size, + succeededBatches, + }; + }); +} + +module.exports = Object.freeze({ + COMMAND_IDS, + CONSENT_SCHEMA_VERSION, + DELETION_SCHEMA_VERSION, + EVENT_RETENTION_DAYS, + EVENT_SCHEMA_VERSION, + IDENTITY_ROTATION_DAYS, + acquireTelemetryLock, + bucketLatency, + buildUsageEvent, + clearTelemetryState, + coarsenArch, + coarsenCommandId, + coarsenOs, + coarsenResult, + defaultTransport, + deleteTelemetryData, + disableTelemetry, + enableTelemetry, + getActiveConsentState, + getTelemetryConfigPath, + getTelemetryStatus, + readConsentState, + recordCommandUsage, + resolveTelemetryEndpoint, +}); diff --git a/scripts/lib/telemetry-state-lock.js b/scripts/lib/telemetry-state-lock.js new file mode 100644 index 0000000000..05fa9a9d84 --- /dev/null +++ b/scripts/lib/telemetry-state-lock.js @@ -0,0 +1,108 @@ +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +const DEFAULT_TIMEOUT_MS = 2000; +const DEFAULT_RETRY_MS = 10; +const STALE_MS = 5000; +const SLEEP_ARRAY = new Int32Array(new SharedArrayBuffer(4)); + +function sleepSync(milliseconds) { + Atomics.wait(SLEEP_ARRAY, 0, 0, milliseconds); +} + +function processIsAlive(pid) { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error.code !== 'ESRCH'; + } +} + +function recoverStaleLock(lockPath, nowMs) { + let stat; + try { + stat = fs.statSync(lockPath); + } catch { + return false; + } + + let owner = null; + try { + owner = JSON.parse(fs.readFileSync(lockPath, 'utf8')); + } catch { + // A process can die between exclusive creation and completing the owner + // record. The mtime still provides a conservative stale threshold. + } + const createdAtMs = Number(owner?.createdAtMs); + const ageMs = nowMs - Math.max( + Number.isFinite(createdAtMs) ? createdAtMs : 0, + Number(stat.mtimeMs) || 0 + ); + if (ageMs < STALE_MS) return false; + if (owner && processIsAlive(Number(owner.pid))) return false; + + const stalePath = `${lockPath}.stale-${process.pid}-${crypto.randomBytes(6).toString('hex')}`; + try { + fs.renameSync(lockPath, stalePath); + } catch { + return false; + } + try { + fs.unlinkSync(stalePath); + } catch { + // A stale lock already moved out of the active path cannot block progress. + } + return true; +} + +function acquireFileLock(lockPath, options = {}) { + const lockDir = path.dirname(lockPath); + const timeoutMs = options.lockTimeoutMs ?? DEFAULT_TIMEOUT_MS; + const retryMs = options.lockRetryMs ?? DEFAULT_RETRY_MS; + const startedAt = Date.now(); + const token = crypto.randomBytes(16).toString('hex'); + + fs.mkdirSync(lockDir, { recursive: true, mode: 0o700 }); + while (true) { + const createdAtMs = Date.now(); + try { + fs.writeFileSync(lockPath, JSON.stringify({ + pid: process.pid, + token, + createdAtMs, + }), { + encoding: 'utf8', + mode: 0o600, + flag: 'wx', + }); + break; + } catch (error) { + if (error.code !== 'EEXIST') throw error; + if (recoverStaleLock(lockPath, createdAtMs)) continue; + if (createdAtMs - startedAt >= timeoutMs) { + const lockError = new Error('Telemetry state is busy'); + lockError.code = 'ECC_TELEMETRY_LOCK_TIMEOUT'; + throw lockError; + } + sleepSync(retryMs); + } + } + + return () => { + try { + const owner = JSON.parse(fs.readFileSync(lockPath, 'utf8')); + if (owner.token === token) fs.unlinkSync(lockPath); + } catch { + // Releasing telemetry state must never affect the wrapped command. + } + }; +} + +module.exports = Object.freeze({ + acquireFileLock, +}); diff --git a/scripts/telemetry.js b/scripts/telemetry.js new file mode 100644 index 0000000000..749b2fd45d --- /dev/null +++ b/scripts/telemetry.js @@ -0,0 +1,181 @@ +#!/usr/bin/env node + +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +const telemetry = require('./lib/cli-telemetry'); + +const EVENT_SCHEMA_PATH = path.join( + __dirname, + '..', + 'schemas', + 'ecc-cli-telemetry-event.schema.json' +); + +function showHelp() { + console.log(` +ECC optional CLI usage telemetry (default off) + +Usage: + ecc telemetry status [--json] + ecc telemetry enable [--json] + ecc telemetry disable [--json] + ecc telemetry preview [--command ] [--result ] [--latency-ms ] [--json] + ecc telemetry schema + ecc telemetry delete [--local-only] [--json] + +Privacy: + - No install-time telemetry and no collection until "enable" is run. + - ECC_TELEMETRY=0 is a hard runtime override. + - Events contain only package/version, a coarse command ID, result category, + latency bucket, coarse OS/architecture, and a rotating anonymous ID. + - Arguments, prompts, paths, usernames, repositories, credentials, RFQs, + supplier data, and demand data are never included. + - "preview" and "schema" are local-only and never send an event. +`); +} + +function parseFlags(args, allowedFlags) { + const values = {}; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (!allowedFlags.has(arg)) { + throw new Error(`Unknown telemetry option: ${arg}`); + } + if (arg === '--json' || arg === '--local-only') { + values[arg.slice(2)] = true; + continue; + } + if (index + 1 >= args.length) { + throw new Error(`${arg} requires a value`); + } + values[arg.slice(2)] = args[index + 1]; + index += 1; + } + return values; +} + +function printStatus(status, asJson) { + if (asJson) { + console.log(JSON.stringify(status, null, 2)); + return; + } + + console.log(`Telemetry consent: ${status.persistedEnabled ? 'enabled' : 'disabled'}`); + console.log(`Effective collection: ${status.effectiveEnabled ? 'enabled' : 'disabled'}`); + console.log(`Delivery endpoint: ${status.endpointState.replace('_', ' ')}`); + console.log(`Anonymous identity present: ${status.anonymousIdPresent ? 'yes' : 'no'}`); + console.log(`Bound receiving endpoints: ${status.boundEndpointCount}`); + console.log(`Identity rotation: ${status.identityRotationDays} days`); + console.log(`Raw-event retention ceiling: ${status.eventRetentionDays} days`); + if (status.override) console.log(`Override: ${status.override}`); +} + +function parsePreviewOptions(args) { + const flags = parseFlags(args, new Set([ + '--command', + '--result', + '--latency-ms', + '--json', + ])); + const commandId = flags.command || 'doctor'; + if (!telemetry.COMMAND_IDS.includes(commandId) || commandId === 'other') { + throw new Error(`Unsupported preview command: ${commandId}`); + } + const result = flags.result || 'success'; + if (result !== 'success' && result !== 'failure') { + throw new Error('Preview result must be success or failure'); + } + const elapsedMs = Number(flags['latency-ms'] || 250); + if (!Number.isFinite(elapsedMs) || elapsedMs < 0) { + throw new Error('Preview latency must be a non-negative number'); + } + return { + asJson: Boolean(flags.json), + commandId, + exitCode: result === 'success' ? 0 : 1, + elapsedMs, + }; +} + +function preview(args) { + const options = parsePreviewOptions(args); + const event = telemetry.buildUsageEvent({ + anonymousId: crypto.randomUUID(), + commandId: options.commandId, + exitCode: options.exitCode, + elapsedMs: options.elapsedMs, + }); + if (options.asJson) { + console.log(JSON.stringify(event, null, 2)); + return; + } + console.log('Local preview only; nothing was sent:'); + console.log(JSON.stringify(event, null, 2)); +} + +function printSchema(args) { + parseFlags(args, new Set()); + console.log(fs.readFileSync(EVENT_SCHEMA_PATH, 'utf8').trim()); +} + +async function handleCommand(command, args) { + if (command === 'status') { + const flags = parseFlags(args, new Set(['--json'])); + printStatus(telemetry.getTelemetryStatus(), Boolean(flags.json)); + return; + } + if (command === 'enable') { + const flags = parseFlags(args, new Set(['--json'])); + telemetry.enableTelemetry(); + printStatus(telemetry.getTelemetryStatus(), Boolean(flags.json)); + return; + } + if (command === 'disable') { + const flags = parseFlags(args, new Set(['--json'])); + telemetry.disableTelemetry(); + printStatus(telemetry.getTelemetryStatus(), Boolean(flags.json)); + return; + } + if (command === 'preview') { + preview(args); + return; + } + if (command === 'schema') { + printSchema(args); + return; + } + if (command === 'delete') { + const flags = parseFlags(args, new Set(['--local-only', '--json'])); + await telemetry.deleteTelemetryData({ localOnly: Boolean(flags['local-only']) }); + printStatus(telemetry.getTelemetryStatus(), Boolean(flags.json)); + return; + } + throw new Error(`Unknown telemetry command: ${command}`); +} + +async function main(argv = process.argv.slice(2)) { + const command = argv[0]; + if (!command || command === '--help' || command === '-h' || command === 'help') { + showHelp(); + return; + } + await handleCommand(command, argv.slice(1)); +} + +if (require.main === module) { + main().catch(error => { + console.error(`Error: ${error.message}`); + process.exitCode = 1; + }); +} + +module.exports = Object.freeze({ + handleCommand, + main, + parseFlags, + parsePreviewOptions, +}); diff --git a/tests/lib/cli-telemetry.test.js b/tests/lib/cli-telemetry.test.js new file mode 100644 index 0000000000..625df0ae0f --- /dev/null +++ b/tests/lib/cli-telemetry.test.js @@ -0,0 +1,717 @@ +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawn } = require('child_process'); + +const telemetry = require('../../scripts/lib/cli-telemetry'); +const eventSchema = require('../../schemas/ecc-cli-telemetry-event.schema.json'); +const deletionSchema = require('../../schemas/ecc-cli-telemetry-deletion.schema.json'); +const TELEMETRY_LIBRARY = path.join( + __dirname, + '..', + '..', + 'scripts', + 'lib', + 'cli-telemetry.js' +); + +function createTempDir(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function runTest(name, fn) { + return Promise.resolve() + .then(fn) + .then(() => { + console.log(` ✓ ${name}`); + return true; + }) + .catch(error => { + console.log(` ✗ ${name}`); + console.error(` ${error.stack || error.message}`); + return false; + }); +} + +function runChild(args, env = {}) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, args, { + cwd: path.join(__dirname, '..', '..'), + env: { ...process.env, ...env }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', chunk => { + stdout += chunk; + }); + child.stderr.on('data', chunk => { + stderr += chunk; + }); + child.on('error', reject); + child.on('close', status => { + if (status === 0) resolve({ stdout, stderr }); + else reject(new Error(`child exited ${status}: ${stderr}`)); + }); + }); +} + +async function waitForFile(filePath, timeoutMs = 2000) { + const startedAt = Date.now(); + while (!fs.existsSync(filePath)) { + if (Date.now() - startedAt >= timeoutMs) { + throw new Error(`timed out waiting for ${filePath}`); + } + await new Promise(resolve => setTimeout(resolve, 10)); + } +} + +async function main() { + console.log('\n=== Testing privacy-safe CLI telemetry library ===\n'); + + const tests = [ + ['builds the exact allowlisted event shape without arguments or paths', () => { + const event = telemetry.buildUsageEvent({ + anonymousId: '11111111-1111-4111-8111-111111111111', + commandId: 'consult', + exitCode: 0, + elapsedMs: 874, + platform: 'darwin', + arch: 'arm64', + ignoredArguments: ['secret prompt', '/Users/example/private-repo'], + }); + + assert.deepStrictEqual(Object.keys(event).sort(), [ + 'anonymousId', + 'arch', + 'commandId', + 'latencyBucket', + 'os', + 'packageName', + 'packageVersion', + 'result', + 'schemaVersion', + ]); + assert.strictEqual(event.commandId, 'consult'); + assert.strictEqual(event.result, 'success'); + assert.strictEqual(event.latencyBucket, '100ms_to_1s'); + assert.strictEqual(event.os, 'macos'); + assert.strictEqual(event.arch, 'arm64'); + assert.ok(!JSON.stringify(event).includes('secret prompt')); + assert.ok(!JSON.stringify(event).includes('/Users/example')); + }], + ['keeps implementation fields and command IDs aligned with the public schema', () => { + const event = telemetry.buildUsageEvent({ + anonymousId: '11111111-1111-4111-8111-111111111111', + commandId: 'doctor', + exitCode: 0, + elapsedMs: 10, + }); + + assert.deepStrictEqual(Object.keys(event).sort(), [...eventSchema.required].sort()); + assert.deepStrictEqual( + [...telemetry.COMMAND_IDS].sort(), + [...eventSchema.properties.commandId.enum].sort() + ); + assert.strictEqual(event.schemaVersion, eventSchema.properties.schemaVersion.const); + assert.strictEqual(event.packageName, eventSchema.properties.packageName.const); + assert.strictEqual(eventSchema.additionalProperties, false); + assert.strictEqual(deletionSchema.additionalProperties, false); + assert.strictEqual(deletionSchema.properties.anonymousIds.maxItems, 64); + }], + ['coarsens commands, results, latency, OS, and architecture', () => { + assert.strictEqual(telemetry.coarsenCommandId('ito'), 'other'); + assert.strictEqual(telemetry.coarsenCommandId('not-a-real-command'), 'other'); + assert.strictEqual(telemetry.coarsenResult(0), 'success'); + assert.strictEqual(telemetry.coarsenResult(9), 'failure'); + assert.strictEqual(telemetry.bucketLatency(99), 'under_100ms'); + assert.strictEqual(telemetry.bucketLatency(100), '100ms_to_1s'); + assert.strictEqual(telemetry.bucketLatency(1000), '1s_to_10s'); + assert.strictEqual(telemetry.bucketLatency(10000), '10s_or_more'); + assert.strictEqual(telemetry.coarsenOs('win32'), 'windows'); + assert.strictEqual(telemetry.coarsenOs('freebsd'), 'other'); + assert.strictEqual(telemetry.coarsenArch('x64'), 'x64'); + assert.strictEqual(telemetry.coarsenArch('riscv64'), 'other'); + }], + ['defaults to disabled when no consent state exists', () => { + const homeDir = createTempDir('ecc-telemetry-default-'); + const status = telemetry.getTelemetryStatus({ homeDir, env: {} }); + + assert.strictEqual(status.persistedEnabled, false); + assert.strictEqual(status.effectiveEnabled, false); + assert.strictEqual(status.state, 'absent'); + assert.strictEqual(status.deliveryConfigured, false); + }], + ['enables only through persisted explicit consent and disables cleanly', () => { + const homeDir = createTempDir('ecc-telemetry-consent-'); + const enabled = telemetry.enableTelemetry({ + homeDir, + now: '2026-07-23T00:00:00.000Z', + randomUUID: () => '22222222-2222-4222-8222-222222222222', + }); + const enabledStatus = telemetry.getTelemetryStatus({ homeDir, env: {} }); + + assert.strictEqual(enabled.enabled, true); + assert.strictEqual(enabledStatus.persistedEnabled, true); + assert.strictEqual(enabledStatus.effectiveEnabled, true); + assert.strictEqual(enabledStatus.anonymousIdPresent, true); + + telemetry.disableTelemetry({ + homeDir, + now: '2026-07-23T01:00:00.000Z', + }); + const disabledStatus = telemetry.getTelemetryStatus({ homeDir, env: {} }); + assert.strictEqual(disabledStatus.persistedEnabled, false); + assert.strictEqual(disabledStatus.effectiveEnabled, false); + assert.strictEqual(disabledStatus.anonymousIdPresent, true); + }], + ['ECC_TELEMETRY=0 is a hard override over persisted consent', () => { + const homeDir = createTempDir('ecc-telemetry-override-'); + telemetry.enableTelemetry({ homeDir }); + const status = telemetry.getTelemetryStatus({ + homeDir, + env: { + ECC_TELEMETRY: '0', + ECC_TELEMETRY_ENDPOINT: 'https://telemetry.example.test/v1/events', + }, + }); + + assert.strictEqual(status.persistedEnabled, true); + assert.strictEqual(status.effectiveEnabled, false); + assert.strictEqual(status.override, 'ECC_TELEMETRY=0'); + assert.strictEqual(status.deliveryConfigured, true); + }], + ['corrupt consent state fails closed', () => { + const homeDir = createTempDir('ecc-telemetry-corrupt-'); + const configPath = telemetry.getTelemetryConfigPath({ homeDir }); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, '{not-json', 'utf8'); + + const status = telemetry.getTelemetryStatus({ homeDir, env: {} }); + assert.strictEqual(status.state, 'invalid'); + assert.strictEqual(status.persistedEnabled, false); + assert.strictEqual(status.effectiveEnabled, false); + assert.throws( + () => telemetry.enableTelemetry({ homeDir }), + /state is invalid/ + ); + assert.deepStrictEqual( + telemetry.disableTelemetry({ homeDir }), + { enabled: false, state: 'invalid' } + ); + assert.strictEqual(fs.readFileSync(configPath, 'utf8'), '{not-json'); + }], + ['rejects malformed consent and identity state without recovering it as enabled', () => { + const seedHome = createTempDir('ecc-telemetry-state-seed-'); + const seed = telemetry.enableTelemetry({ + homeDir: seedHome, + randomUUID: () => '23232323-2323-4232-8232-232323232323', + }); + const malformedStates = [ + null, + { ...seed, schemaVersion: 'unexpected' }, + { ...seed, consentVersion: 99 }, + { ...seed, enabled: 'yes' }, + { ...seed, updatedAt: 'not-a-date' }, + { ...seed, identities: {} }, + { ...seed, identities: [null] }, + { + ...seed, + identities: [{ ...seed.identities[0], anonymousId: 'not-a-uuid' }], + }, + { + ...seed, + identities: [{ ...seed.identities[0], createdAt: 'not-a-date' }], + }, + { + ...seed, + identities: [{ ...seed.identities[0], retiredAt: 'not-a-date' }], + }, + { + ...seed, + identities: [{ ...seed.identities[0], endpoints: 'not-an-array' }], + }, + { + ...seed, + identities: [{ + ...seed.identities[0], + endpoints: [ + 'https://collector.example.test/events', + 'https://collector.example.test/events', + ], + }], + }, + { + ...seed, + identities: [{ ...seed.identities[0], endpoints: [42] }], + }, + { + ...seed, + identities: [{ ...seed.identities[0], endpoints: ['http://insecure.test'] }], + }, + { + ...seed, + identities: [ + seed.identities[0], + { ...seed.identities[0], retiredAt: seed.updatedAt }, + ], + }, + { ...seed, activeAnonymousId: 'not-a-uuid' }, + { + ...seed, + activeAnonymousId: '24242424-2424-4242-8242-242424242424', + }, + { ...seed, activeAnonymousId: null }, + { + ...seed, + enabled: false, + activeAnonymousId: null, + }, + ]; + + for (const value of malformedStates) { + const homeDir = createTempDir('ecc-telemetry-malformed-state-'); + const configPath = telemetry.getTelemetryConfigPath({ homeDir }); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, JSON.stringify(value), 'utf8'); + const status = telemetry.getTelemetryStatus({ homeDir, env: {} }); + assert.strictEqual(status.state, 'invalid'); + assert.strictEqual(status.effectiveEnabled, false); + } + }], + ['rotates the pseudonymous client identifier after thirty days', () => { + const homeDir = createTempDir('ecc-telemetry-rotation-'); + telemetry.enableTelemetry({ + homeDir, + now: '2026-06-01T00:00:00.000Z', + randomUUID: () => '33333333-3333-4333-8333-333333333333', + }); + + const state = telemetry.getActiveConsentState({ + homeDir, + now: '2026-07-02T00:00:00.000Z', + randomUUID: () => '44444444-4444-4444-8444-444444444444', + }); + + assert.strictEqual(state.anonymousId, '44444444-4444-4444-8444-444444444444'); + assert.strictEqual(state.identityCreatedAt, '2026-07-02T00:00:00.000Z'); + assert.deepStrictEqual(state.previousAnonymousIds, [{ + anonymousId: '33333333-3333-4333-8333-333333333333', + retiredAt: '2026-07-02T00:00:00.000Z', + endpoints: [], + }]); + }], + ['serializes parallel first-use rotation without losing an emitted identifier', async () => { + const homeDir = createTempDir('ecc-telemetry-parallel-rotation-'); + const outputDir = createTempDir('ecc-telemetry-parallel-events-'); + telemetry.enableTelemetry({ + homeDir, + now: '2026-06-01T00:00:00.000Z', + randomUUID: () => '34343434-3434-4434-8434-343434343434', + }); + const childSource = ` + const fs = require('fs'); + const telemetry = require(process.argv[1]); + let emittedId = null; + telemetry.recordCommandUsage({ + homeDir: process.argv[2], + now: '2026-07-23T00:00:00.000Z', + env: { ECC_TELEMETRY_ENDPOINT: 'https://collector.example.test/events' }, + commandId: 'doctor', + exitCode: 0, + elapsedMs: 1, + transport: async (_endpoint, event) => { emittedId = event.anonymousId; }, + }).then(result => { + fs.writeFileSync( + process.argv[3], + JSON.stringify({ result, emittedId }), + 'utf8' + ); + }).catch(error => { + console.error(error.stack || error.message); + process.exitCode = 1; + }); + `; + const childCount = 24; + await Promise.all(Array.from({ length: childCount }, (_, index) => ( + runChild([ + '-e', + childSource, + TELEMETRY_LIBRARY, + homeDir, + path.join(outputDir, `${index}.json`), + ]) + ))); + + const emittedIds = Array.from({ length: childCount }, (_, index) => { + const payload = JSON.parse( + fs.readFileSync(path.join(outputDir, `${index}.json`), 'utf8') + ); + assert.strictEqual(payload.result.sent, true); + return payload.emittedId; + }); + assert.strictEqual(new Set(emittedIds).size, 1); + + const state = telemetry.readConsentState({ homeDir }); + assert.strictEqual(state.state, 'valid'); + const retainedIds = new Set( + state.value.identities.map(identity => identity.anonymousId) + ); + for (const emittedId of emittedIds) { + assert.ok(retainedIds.has(emittedId), `missing emitted identity ${emittedId}`); + } + }], + ['recovers an abandoned lock without stealing a live process lock', () => { + const staleHome = createTempDir('ecc-telemetry-stale-lock-'); + const staleLockPath = `${telemetry.getTelemetryConfigPath({ homeDir: staleHome })}.lock`; + fs.mkdirSync(path.dirname(staleLockPath), { recursive: true }); + fs.writeFileSync(staleLockPath, 'incomplete-owner-record', 'utf8'); + fs.utimesSync(staleLockPath, new Date(0), new Date(0)); + const enabled = telemetry.enableTelemetry({ homeDir: staleHome }); + assert.strictEqual(enabled.enabled, true); + assert.strictEqual(fs.existsSync(staleLockPath), false); + + const liveHome = createTempDir('ecc-telemetry-live-lock-'); + const liveLockPath = `${telemetry.getTelemetryConfigPath({ homeDir: liveHome })}.lock`; + fs.mkdirSync(path.dirname(liveLockPath), { recursive: true }); + fs.writeFileSync(liveLockPath, JSON.stringify({ + pid: process.pid, + token: 'live-owner', + createdAtMs: 1, + }), 'utf8'); + fs.utimesSync(liveLockPath, new Date(0), new Date(0)); + assert.throws( + () => telemetry.enableTelemetry({ + homeDir: liveHome, + lockTimeoutMs: 0, + }), + /state is busy/ + ); + fs.unlinkSync(liveLockPath); + }], + ['does not invoke transport while disabled or without an endpoint', async () => { + const homeDir = createTempDir('ecc-telemetry-no-send-'); + let calls = 0; + const transport = async () => { + calls += 1; + }; + + const disabled = await telemetry.recordCommandUsage({ + homeDir, + env: { ECC_TELEMETRY_ENDPOINT: 'https://telemetry.example.test/v1/events' }, + commandId: 'doctor', + exitCode: 0, + elapsedMs: 12, + transport, + }); + assert.strictEqual(disabled.reason, 'disabled'); + + telemetry.enableTelemetry({ homeDir }); + const noEndpoint = await telemetry.recordCommandUsage({ + homeDir, + env: {}, + commandId: 'doctor', + exitCode: 0, + elapsedMs: 12, + transport, + }); + assert.strictEqual(noEndpoint.reason, 'endpoint_not_configured'); + assert.strictEqual(calls, 0); + }], + ['excludes the sponsor-specific Ito command without creating a binding', async () => { + const homeDir = createTempDir('ecc-telemetry-ito-excluded-'); + telemetry.enableTelemetry({ homeDir }); + let calls = 0; + + const result = await telemetry.recordCommandUsage({ + homeDir, + env: { ECC_TELEMETRY_ENDPOINT: 'https://collector.example.test/events' }, + commandId: 'ito', + exitCode: 0, + elapsedMs: 12, + transport: async () => { + calls += 1; + }, + }); + + assert.deepStrictEqual(result, { + sent: false, + reason: 'excluded_command', + }); + assert.strictEqual(calls, 0); + assert.strictEqual( + telemetry.getTelemetryStatus({ homeDir, env: {} }).boundEndpointCount, + 0 + ); + }], + ['sends one minimized event through an injected transport after consent', async () => { + const homeDir = createTempDir('ecc-telemetry-send-'); + telemetry.enableTelemetry({ + homeDir, + randomUUID: () => '55555555-5555-4555-8555-555555555555', + }); + const calls = []; + + const result = await telemetry.recordCommandUsage({ + homeDir, + env: { ECC_TELEMETRY_ENDPOINT: 'https://telemetry.example.test/v1/events' }, + commandId: 'catalog', + exitCode: 1, + elapsedMs: 2800, + platform: 'linux', + arch: 'x64', + transport: async (endpoint, event) => { + calls.push({ endpoint, event }); + }, + }); + + assert.strictEqual(result.sent, true); + assert.strictEqual(calls.length, 1); + assert.strictEqual(calls[0].endpoint, 'https://telemetry.example.test/v1/events'); + assert.strictEqual(calls[0].event.commandId, 'catalog'); + assert.strictEqual(calls[0].event.result, 'failure'); + assert.strictEqual(calls[0].event.latencyBucket, '1s_to_10s'); + }], + ['rejects insecure or credential-bearing telemetry endpoints', () => { + assert.throws( + () => telemetry.resolveTelemetryEndpoint({ ECC_TELEMETRY_ENDPOINT: 'http://example.com/events' }), + /must use HTTPS/ + ); + assert.throws( + () => telemetry.resolveTelemetryEndpoint({ ECC_TELEMETRY_ENDPOINT: 'https://user:pass@example.com/events' }), + /must not contain credentials/ + ); + assert.throws( + () => telemetry.resolveTelemetryEndpoint({ ECC_TELEMETRY_ENDPOINT: 'https://example.com/events?token=secret' }), + /must not contain a query/ + ); + }], + ['remote deletion follows every original endpoint after the environment changes', async () => { + const homeDir = createTempDir('ecc-telemetry-delete-'); + telemetry.enableTelemetry({ + homeDir, + now: '2026-06-01T00:00:00.000Z', + randomUUID: () => '66666666-6666-4666-8666-666666666666', + }); + await telemetry.recordCommandUsage({ + homeDir, + now: '2026-06-01T00:00:00.000Z', + env: { ECC_TELEMETRY_ENDPOINT: 'https://collector-a.example/events' }, + commandId: 'doctor', + exitCode: 0, + elapsedMs: 1, + transport: async () => {}, + }); + await telemetry.recordCommandUsage({ + homeDir, + now: '2026-07-02T00:00:00.000Z', + env: { ECC_TELEMETRY_ENDPOINT: 'https://collector-a.example/events' }, + randomUUID: () => '77777777-7777-4777-8777-777777777777', + commandId: 'doctor', + exitCode: 0, + elapsedMs: 1, + transport: async () => {}, + }); + const calls = []; + + const result = await telemetry.deleteTelemetryData({ + homeDir, + now: '2026-07-02T00:00:00.000Z', + env: { ECC_TELEMETRY_ENDPOINT: 'https://collector-b.example/events' }, + transport: async (endpoint, payload, options) => { + calls.push({ endpoint, payload, options }); + }, + }); + + assert.strictEqual(result.deleted, true); + assert.strictEqual(calls[0].endpoint, 'https://collector-a.example/events'); + assert.deepStrictEqual(calls[0].payload, { + schemaVersion: 'ecc.cli-telemetry-deletion.v1', + anonymousIds: [ + '66666666-6666-4666-8666-666666666666', + '77777777-7777-4777-8777-777777777777', + ], + }); + assert.deepStrictEqual( + Object.keys(calls[0].payload).sort(), + [...deletionSchema.required].sort() + ); + assert.strictEqual(calls[0].options.method, 'DELETE'); + assert.strictEqual(telemetry.getTelemetryStatus({ homeDir, env: {} }).state, 'absent'); + }], + ['partial deletion retains only bindings that still need deletion', async () => { + const homeDir = createTempDir('ecc-telemetry-delete-partial-'); + telemetry.enableTelemetry({ + homeDir, + randomUUID: () => '78787878-7878-4878-8878-787878787878', + }); + for (const endpoint of [ + 'https://collector-a.example/events', + 'https://collector-b.example/events', + ]) { + await telemetry.recordCommandUsage({ + homeDir, + env: { ECC_TELEMETRY_ENDPOINT: endpoint }, + commandId: 'doctor', + exitCode: 0, + elapsedMs: 1, + transport: async () => {}, + }); + } + + await assert.rejects( + telemetry.deleteTelemetryData({ + homeDir, + env: {}, + transport: async endpoint => { + if (endpoint === 'https://collector-b.example/events') { + throw new Error('collector unavailable'); + } + }, + }), + /retained local deletion state/ + ); + + const retained = telemetry.readConsentState({ homeDir }); + assert.strictEqual(retained.state, 'valid'); + assert.strictEqual(retained.value.enabled, false); + assert.strictEqual(retained.value.activeAnonymousId, null); + assert.deepStrictEqual( + retained.value.identities.flatMap(identity => identity.endpoints), + ['https://collector-b.example/events'] + ); + + await telemetry.deleteTelemetryData({ + homeDir, + env: {}, + transport: async endpoint => { + assert.strictEqual(endpoint, 'https://collector-b.example/events'); + }, + }); + assert.strictEqual(telemetry.readConsentState({ homeDir }).state, 'absent'); + }], + ['serializes deletion behind an in-flight event across processes', async () => { + const homeDir = createTempDir('ecc-telemetry-delete-race-'); + const evidenceDir = createTempDir('ecc-telemetry-delete-race-evidence-'); + const enteredPath = path.join(evidenceDir, 'entered'); + const orderPath = path.join(evidenceDir, 'order'); + telemetry.enableTelemetry({ + homeDir, + randomUUID: () => '79797979-7979-4979-8979-797979797979', + }); + + const recordSource = ` + const fs = require('fs'); + const telemetry = require(process.argv[1]); + telemetry.recordCommandUsage({ + homeDir: process.argv[2], + env: { ECC_TELEMETRY_ENDPOINT: 'https://collector.example.test/events' }, + commandId: 'doctor', + exitCode: 0, + elapsedMs: 1, + transport: async () => { + fs.writeFileSync(process.argv[3], 'entered', 'utf8'); + await new Promise(resolve => setTimeout(resolve, 250)); + fs.appendFileSync(process.argv[4], 'POST\\n', 'utf8'); + }, + }).then(result => { + if (!result.sent) throw new Error(JSON.stringify(result)); + }).catch(error => { + console.error(error.stack || error.message); + process.exitCode = 1; + }); + `; + const recordPromise = runChild([ + '-e', + recordSource, + TELEMETRY_LIBRARY, + homeDir, + enteredPath, + orderPath, + ]); + await waitForFile(enteredPath); + + const deleteSource = ` + const fs = require('fs'); + const telemetry = require(process.argv[1]); + telemetry.deleteTelemetryData({ + homeDir: process.argv[2], + env: {}, + transport: async () => { + fs.appendFileSync(process.argv[3], 'DELETE\\n', 'utf8'); + }, + }).catch(error => { + console.error(error.stack || error.message); + process.exitCode = 1; + }); + `; + const deletePromise = runChild([ + '-e', + deleteSource, + TELEMETRY_LIBRARY, + homeDir, + orderPath, + ]); + await Promise.all([recordPromise, deletePromise]); + + assert.deepStrictEqual( + fs.readFileSync(orderPath, 'utf8').trim().split('\n'), + ['POST', 'DELETE'] + ); + assert.strictEqual(telemetry.readConsentState({ homeDir }).state, 'absent'); + }], + ['local-only deletion never invokes transport', async () => { + const homeDir = createTempDir('ecc-telemetry-delete-local-'); + telemetry.enableTelemetry({ homeDir }); + let calls = 0; + + const result = await telemetry.deleteTelemetryData({ + homeDir, + env: { ECC_TELEMETRY_ENDPOINT: 'https://telemetry.example.test/v1/events' }, + localOnly: true, + transport: async () => { + calls += 1; + }, + }); + + assert.strictEqual(result.deleted, true); + assert.strictEqual(result.localOnly, true); + assert.strictEqual(calls, 0); + assert.strictEqual(telemetry.getTelemetryStatus({ homeDir, env: {} }).state, 'absent'); + }], + ['hard override prevents even a deletion network request', async () => { + const homeDir = createTempDir('ecc-telemetry-delete-override-'); + telemetry.enableTelemetry({ homeDir }); + let calls = 0; + + await assert.rejects( + telemetry.deleteTelemetryData({ + homeDir, + env: { + ECC_TELEMETRY: '0', + ECC_TELEMETRY_ENDPOINT: 'https://telemetry.example.test/v1/events', + }, + transport: async () => { + calls += 1; + }, + }), + /ECC_TELEMETRY=0/ + ); + assert.strictEqual(calls, 0); + }], + ]; + + let passed = 0; + let failed = 0; + for (const [name, fn] of tests) { + if (await runTest(name, fn)) passed += 1; + else failed += 1; + } + + console.log(`\nPassed: ${passed}`); + console.log(`Failed: ${failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +main(); diff --git a/tests/scripts/ecc.test.js b/tests/scripts/ecc.test.js index b3c757d931..f79d351f9a 100644 --- a/tests/scripts/ecc.test.js +++ b/tests/scripts/ecc.test.js @@ -29,6 +29,7 @@ function runCli(args, options = {}) { maxBuffer: 10 * 1024 * 1024, env: { ...process.env, + ECC_TELEMETRY: '0', ...envOverrides, }, }); diff --git a/tests/scripts/npm-publish-surface.test.js b/tests/scripts/npm-publish-surface.test.js index c00205ee1e..16b4d241b6 100644 --- a/tests/scripts/npm-publish-surface.test.js +++ b/tests/scripts/npm-publish-surface.test.js @@ -51,6 +51,7 @@ function buildExpectedPublishPaths(repoRoot) { "scripts/discussion-audit.js", "scripts/doctor.js", "scripts/status.js", + "scripts/telemetry.js", "scripts/sessions-cli.js", "scripts/work-items.js", "scripts/install-apply.js", @@ -90,6 +91,7 @@ function buildExpectedPublishPaths(repoRoot) { "assets/hero.png", "docs/CODEX-NAVIGATION-GUIDE.md", "docs/COMMAND-AGENT-MAP.md", + "docs/architecture/optional-cli-usage-telemetry.md", "assets/images/sponsors", ] const exclusionPaths = [ @@ -145,6 +147,7 @@ function main() { "scripts/consult.js", "scripts/control-pane.js", "scripts/ito.js", + "scripts/telemetry.js", "scripts/discussion-audit.js", "scripts/operator-readiness-dashboard.js", "scripts/preview-pack-smoke.js", @@ -166,6 +169,9 @@ function main() { "assets/hero.png", "docs/CODEX-NAVIGATION-GUIDE.md", "docs/COMMAND-AGENT-MAP.md", + "docs/architecture/optional-cli-usage-telemetry.md", + "schemas/ecc-cli-telemetry-event.schema.json", + "schemas/ecc-cli-telemetry-deletion.schema.json", "schemas/install-state.schema.json", "skills/backend-patterns/SKILL.md", ]) { diff --git a/tests/scripts/telemetry.test.js b/tests/scripts/telemetry.test.js new file mode 100644 index 0000000000..1d5f6c97bd --- /dev/null +++ b/tests/scripts/telemetry.test.js @@ -0,0 +1,214 @@ +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const ECC_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'ecc.js'); +const telemetry = require('../../scripts/lib/cli-telemetry'); + +function createTempDir(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function runCli(args, options = {}) { + const homeDir = options.homeDir || createTempDir('ecc-telemetry-cli-'); + return spawnSync(process.execPath, [ECC_SCRIPT, ...args], { + cwd: path.join(__dirname, '..', '..'), + encoding: 'utf8', + env: { + ...process.env, + HOME: homeDir, + USERPROFILE: homeDir, + XDG_CONFIG_HOME: path.join(homeDir, '.config'), + ECC_TELEMETRY: '', + ECC_TELEMETRY_ENDPOINT: '', + ...options.env, + }, + }); +} + +function parseJson(stdout) { + return JSON.parse(stdout.trim()); +} + +function runTest(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (error) { + console.log(` ✗ ${name}`); + console.error(` ${error.stack || error.message}`); + return false; + } +} + +function main() { + console.log('\n=== Testing telemetry CLI consent flow ===\n'); + + const tests = [ + ['top-level help exposes the telemetry control surface', () => { + const result = runCli(['--help']); + assert.strictEqual(result.status, 0, result.stderr); + assert.match(result.stdout, /telemetry/); + assert.match(result.stdout, /default off/i); + }], + ['status is disabled by default', () => { + const result = runCli(['telemetry', 'status', '--json']); + assert.strictEqual(result.status, 0, result.stderr); + const payload = parseJson(result.stdout); + assert.strictEqual(payload.persistedEnabled, false); + assert.strictEqual(payload.effectiveEnabled, false); + assert.strictEqual(payload.deliveryConfigured, false); + }], + ['enable, status, and disable require explicit runtime commands', () => { + const homeDir = createTempDir('ecc-telemetry-cli-consent-'); + const enabled = runCli(['telemetry', 'enable', '--json'], { homeDir }); + assert.strictEqual(enabled.status, 0, enabled.stderr); + assert.strictEqual(parseJson(enabled.stdout).persistedEnabled, true); + + const status = runCli(['telemetry', 'status', '--json'], { homeDir }); + assert.strictEqual(status.status, 0, status.stderr); + assert.strictEqual(parseJson(status.stdout).effectiveEnabled, true); + + const disabled = runCli(['telemetry', 'disable', '--json'], { homeDir }); + assert.strictEqual(disabled.status, 0, disabled.stderr); + assert.strictEqual(parseJson(disabled.stdout).persistedEnabled, false); + }], + ['ECC_TELEMETRY=0 remains a hard runtime override', () => { + const homeDir = createTempDir('ecc-telemetry-cli-override-'); + assert.strictEqual(runCli(['telemetry', 'enable'], { homeDir }).status, 0); + const result = runCli(['telemetry', 'status', '--json'], { + homeDir, + env: { ECC_TELEMETRY: '0' }, + }); + const payload = parseJson(result.stdout); + assert.strictEqual(payload.persistedEnabled, true); + assert.strictEqual(payload.effectiveEnabled, false); + assert.strictEqual(payload.override, 'ECC_TELEMETRY=0'); + }], + ['preview is local, schema-constrained, and does not persist consent', () => { + const homeDir = createTempDir('ecc-telemetry-cli-preview-'); + const result = runCli([ + 'telemetry', + 'preview', + '--command', + 'consult', + '--result', + 'failure', + '--latency-ms', + '2450', + '--json', + ], { homeDir }); + assert.strictEqual(result.status, 0, result.stderr); + const payload = parseJson(result.stdout); + assert.strictEqual(payload.commandId, 'consult'); + assert.strictEqual(payload.result, 'failure'); + assert.strictEqual(payload.latencyBucket, '1s_to_10s'); + assert.deepStrictEqual(Object.keys(payload).sort(), [ + 'anonymousId', + 'arch', + 'commandId', + 'latencyBucket', + 'os', + 'packageName', + 'packageVersion', + 'result', + 'schemaVersion', + ]); + + const status = runCli(['telemetry', 'status', '--json'], { homeDir }); + assert.strictEqual(parseJson(status.stdout).state, 'absent'); + }], + ['schema command prints the public additionalProperties-false contract', () => { + const result = runCli(['telemetry', 'schema']); + assert.strictEqual(result.status, 0, result.stderr); + const schema = parseJson(result.stdout); + assert.strictEqual(schema.$id, 'https://github.com/affaan-m/ECC/schemas/ecc-cli-telemetry-event.schema.json'); + assert.strictEqual(schema.additionalProperties, false); + assert.ok(schema.required.includes('commandId')); + }], + ['delete --local-only removes local telemetry identity without network', () => { + const homeDir = createTempDir('ecc-telemetry-cli-delete-'); + assert.strictEqual(runCli(['telemetry', 'enable'], { homeDir }).status, 0); + const result = runCli(['telemetry', 'delete', '--local-only', '--json'], { homeDir }); + assert.strictEqual(result.status, 0, result.stderr); + assert.strictEqual(parseJson(result.stdout).state, 'absent'); + }], + ['deletion clears an identity that was never emitted without needing an endpoint', () => { + const homeDir = createTempDir('ecc-telemetry-cli-delete-fail-'); + assert.strictEqual(runCli(['telemetry', 'enable'], { homeDir }).status, 0); + const result = runCli(['telemetry', 'delete'], { homeDir }); + assert.strictEqual(result.status, 0, result.stderr); + assert.strictEqual( + parseJson(runCli(['telemetry', 'status', '--json'], { homeDir }).stdout).state, + 'absent' + ); + }], + ['read-only rotation state never changes a successful parent command result', () => { + if (process.platform === 'win32') return; + const homeDir = createTempDir('ecc-telemetry-cli-readonly-'); + telemetry.enableTelemetry({ + homeDir, + now: '2026-06-01T00:00:00.000Z', + randomUUID: () => '89898989-8989-4989-8989-898989898989', + }); + const configDir = path.dirname(telemetry.getTelemetryConfigPath({ homeDir })); + fs.chmodSync(configDir, 0o500); + let result; + try { + result = runCli(['catalog', 'profiles'], { + homeDir, + env: { + ECC_TELEMETRY_ENDPOINT: 'https://collector.example.test/events', + }, + }); + } finally { + fs.chmodSync(configDir, 0o700); + } + + assert.strictEqual(result.status, 0, result.stderr); + assert.match(result.stdout, /Install profiles:/); + assert.doesNotMatch(result.stderr, /telemetry|EACCES/i); + }], + ['package contains no telemetry install lifecycle hook', () => { + const packageJson = JSON.parse( + fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8') + ); + for (const lifecycle of ['preinstall', 'install', 'postinstall']) { + assert.ok( + !packageJson.scripts?.[lifecycle], + `${lifecycle} must never run telemetry` + ); + } + }], + ['invalid telemetry commands and preview values fail closed', () => { + const unknown = runCli(['telemetry', 'unknown']); + assert.strictEqual(unknown.status, 1); + assert.match(unknown.stderr, /Unknown telemetry command/); + + const rawCommand = runCli([ + 'telemetry', + 'preview', + '--command', + 'private-customer-name', + ]); + assert.strictEqual(rawCommand.status, 1); + assert.match(rawCommand.stderr, /Unsupported preview command/); + }], + ]; + + let passed = 0; + let failed = 0; + for (const [name, fn] of tests) { + if (runTest(name, fn)) passed += 1; + else failed += 1; + } + + console.log(`\nPassed: ${passed}`); + console.log(`Failed: ${failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +main(); From af7e6470e9c1d42725685b63a0757e7296611a84 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 24 Jul 2026 00:20:29 -0400 Subject: [PATCH 2/5] fix: make telemetry lock recovery race-safe --- scripts/lib/telemetry-state-lock.js | 35 ++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/scripts/lib/telemetry-state-lock.js b/scripts/lib/telemetry-state-lock.js index 05fa9a9d84..b871ec9821 100644 --- a/scripts/lib/telemetry-state-lock.js +++ b/scripts/lib/telemetry-state-lock.js @@ -24,20 +24,21 @@ function processIsAlive(pid) { } function recoverStaleLock(lockPath, nowMs) { + let descriptor; let stat; - try { - stat = fs.statSync(lockPath); - } catch { - return false; - } - let owner = null; try { - owner = JSON.parse(fs.readFileSync(lockPath, 'utf8')); + descriptor = fs.openSync(lockPath, 'r'); + stat = fs.fstatSync(descriptor); + owner = JSON.parse(fs.readFileSync(descriptor, 'utf8')); } catch { // A process can die between exclusive creation and completing the owner // record. The mtime still provides a conservative stale threshold. + } finally { + if (descriptor !== undefined) fs.closeSync(descriptor); } + if (!stat) return false; + const createdAtMs = Number(owner?.createdAtMs); const ageMs = nowMs - Math.max( Number.isFinite(createdAtMs) ? createdAtMs : 0, @@ -46,14 +47,32 @@ function recoverStaleLock(lockPath, nowMs) { if (ageMs < STALE_MS) return false; if (owner && processIsAlive(Number(owner.pid))) return false; + // Serialize recovery for this exact inode. Once this exclusive guard exists, + // no cooperating process can replace the checked lock before the rename. + const recoveryPath = `${lockPath}.recover-${stat.dev}-${stat.ino}`; const stalePath = `${lockPath}.stale-${process.pid}-${crypto.randomBytes(6).toString('hex')}`; + let recoveryDescriptor; try { + recoveryDescriptor = fs.openSync(recoveryPath, 'wx', 0o600); + fs.writeFileSync(recoveryDescriptor, JSON.stringify({ + pid: process.pid, + createdAtMs: nowMs, + }), 'utf8'); fs.renameSync(lockPath, stalePath); } catch { return false; + } finally { + if (recoveryDescriptor !== undefined) { + fs.closeSync(recoveryDescriptor); + try { + fs.unlinkSync(recoveryPath); + } catch { + // A failed recovery attempt must not affect the wrapped command. + } + } } try { - fs.unlinkSync(stalePath); + fs.rmSync(stalePath, { force: true }); } catch { // A stale lock already moved out of the active path cannot block progress. } From bd03fa9b49e3083e707698374b637341ec0ded74 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 24 Jul 2026 00:38:35 -0400 Subject: [PATCH 3/5] test(telemetry): stabilize concurrent rotation on Windows --- tests/lib/cli-telemetry.test.js | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/lib/cli-telemetry.test.js b/tests/lib/cli-telemetry.test.js index 625df0ae0f..7f21878e46 100644 --- a/tests/lib/cli-telemetry.test.js +++ b/tests/lib/cli-telemetry.test.js @@ -321,6 +321,7 @@ async function main() { commandId: 'doctor', exitCode: 0, elapsedMs: 1, + lockTimeoutMs: 10000, transport: async (_endpoint, event) => { emittedId = event.anonymousId; }, }).then(result => { fs.writeFileSync( From 6832f1541b239a1a74d42cb93e35a2bc4a7c69d8 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 24 Jul 2026 00:44:50 -0400 Subject: [PATCH 4/5] fix(telemetry): read consent only under process lock --- scripts/lib/cli-telemetry.js | 5 ----- 1 file changed, 5 deletions(-) diff --git a/scripts/lib/cli-telemetry.js b/scripts/lib/cli-telemetry.js index 00edf876d7..c0f444b9ce 100644 --- a/scripts/lib/cli-telemetry.js +++ b/scripts/lib/cli-telemetry.js @@ -537,11 +537,6 @@ async function recordCommandUsage(options = {}) { const env = options.env || process.env; if (getHardOverride(env)) return { sent: false, reason: 'disabled' }; - const initial = readConsentState(options); - if (initial.state !== 'valid' || !initial.value.enabled) { - return { sent: false, reason: 'disabled' }; - } - let endpoint; try { endpoint = resolveTelemetryEndpoint(env); From d997e826c8e12ac6b568a55b7443829aaa4ddbaf Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 24 Jul 2026 01:00:27 -0400 Subject: [PATCH 5/5] test(telemetry): bound cross-process rotation stress --- tests/lib/cli-telemetry.test.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/lib/cli-telemetry.test.js b/tests/lib/cli-telemetry.test.js index 7f21878e46..e15e77c3e0 100644 --- a/tests/lib/cli-telemetry.test.js +++ b/tests/lib/cli-telemetry.test.js @@ -334,7 +334,9 @@ async function main() { process.exitCode = 1; }); `; - const childCount = 24; + // Eight independent processes exercise the cross-process boundary without + // turning slower Windows runners into a scheduler stress benchmark. + const childCount = 8; await Promise.all(Array.from({ length: childCount }, (_, index) => ( runChild([ '-e', @@ -349,7 +351,11 @@ async function main() { const payload = JSON.parse( fs.readFileSync(path.join(outputDir, `${index}.json`), 'utf8') ); - assert.strictEqual(payload.result.sent, true); + assert.strictEqual( + payload.result.sent, + true, + `telemetry child ${index} did not send: ${payload.result.reason || 'unknown reason'}` + ); return payload.emittedId; }); assert.strictEqual(new Set(emittedIds).size, 1);