From b9ca17478ce3032bdbcfa7387b0c365d63b9e2cd Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 7 Nov 2026 22:52:29 +0900 Subject: [PATCH 01/37] docs(devlog): plan opt-in telemetry and the opencodex.me usage dashboard Roadmap unit for a six-layer stack: client telemetry core, GUI consent popup, Cloudflare Worker collector, telemetry guide, and the public /dashboard page. Docs only. --- .../261107_telemetry_dashboard/000_plan.md | 93 +++++++++ .../010_client_core.md | 179 ++++++++++++++++++ .../020_gui_consent.md | 55 ++++++ .../030_collector.md | 80 ++++++++ .../040_docs_privacy.md | 31 +++ .../050_dashboard.md | 43 +++++ 6 files changed, 481 insertions(+) create mode 100644 devlog/_plan/261107_telemetry_dashboard/000_plan.md create mode 100644 devlog/_plan/261107_telemetry_dashboard/010_client_core.md create mode 100644 devlog/_plan/261107_telemetry_dashboard/020_gui_consent.md create mode 100644 devlog/_plan/261107_telemetry_dashboard/030_collector.md create mode 100644 devlog/_plan/261107_telemetry_dashboard/040_docs_privacy.md create mode 100644 devlog/_plan/261107_telemetry_dashboard/050_dashboard.md diff --git a/devlog/_plan/261107_telemetry_dashboard/000_plan.md b/devlog/_plan/261107_telemetry_dashboard/000_plan.md new file mode 100644 index 00000000000..e694f22d51a --- /dev/null +++ b/devlog/_plan/261107_telemetry_dashboard/000_plan.md @@ -0,0 +1,93 @@ +# Opt-in telemetry and the opencodex.me usage dashboard + +## Objective and scope + +Add anonymous, opt-in product telemetry to the opencodex proxy and a public, project-wide +analytics page at `https://opencodex.me/dashboard`. The proxy reports install/version/platform +pings, active time, request and token totals, and error counts; a collector stores daily +aggregates; the dashboard renders installs, active time, usage, errors and downloads. + +Delivery is a manual chain of stacked pull requests, one per work phase below, each based on the +previous layer and the bottom on `dev`. No deploy of the collector, no DNS change, no merge and no +release are part of this unit; those are maintainer actions after review. + +The visual and structural reference is [junhoyeo/tokscale](https://github.com/junhoyeo/tokscale) +(MIT): its contribution heatmap, KPI header, client/model breakdown tables, and the +validated aggregate submit API. Patterns are adapted, not vendored; credit lives in the dashboard +footer and in the PR descriptions. + +## Owner decisions (2026-11-07) + +| Id | Decision | +| --- | --- | +| hosting | Collector is a Cloudflare Worker with D1 at `telemetry.opencodex.me`. The repository ships its code, schema and tests; deployment is a maintainer step. | +| consent | Superseded by default-on. | +| update-popup | An install that updates and has no recorded decision sees the same consent popup once after the update. | +| default-on | Telemetry defaults to **on** (opt-out). The GUI first-run/post-update popup (checkbox pre-checked) and the first interactive `ocx start` (Y/n, default yes) let the user choose. `ocx init` and `ocx service install` ask the same question. An agent-driven start prints a notice and never answers for the user or records the notice. No payload is sent until a person has seen the disclosure. Advanced settings and `ocx telemetry disable` turn it off later. | +| scope | The dashboard is public and project-wide (aggregates only). No per-user profiles or leaderboards. | + +## Privacy contract (applies to every layer) + +- Effective state: env opt-out (`OCX_TELEMETRY=0` or `DO_NOT_TRACK=1`) → off; decision `declined` → off; `accepted` → on; undecided → on only after `noticeShownAt` is recorded, which only a person can cause (GUI popup seen, interactive TTY prompt answered). An agent-driven start prints a notice but records nothing, so an agent can never switch sending on. +- Identity is a random install id (UUID v4) generated on first send, never derived from + hardware, accounts, hostnames or paths. Disabling telemetry deletes it; re-enabling mints a new one. +- Payloads carry only closed-vocabulary values and counters: version, OS, arch, install channel, + provider *kind* (closed vendor roster), coarse model family, closed + error codes, and integer counts. Never request or response bodies, prompts, model output, API + keys, OAuth tokens, account ids or e-mails, custom base URLs, hostnames, file paths, or free-form + error text. +- The exact JSON the next send would contain is visible before consent ("What is sent") and via + the management API and `ocx telemetry status`. +- The collector stores no IP address and no raw install id: reports carry a per-day install hash, + rate limiting uses a salted hourly IP hash pruned after two hours, and only the first report per + install and day counts. +- No backfill: days before the disclosure are never sent; at most one day is sent per scheduler tick. +- The collector URL is a constant; it is not configurable from `config.json`. +- Telemetry code stays off the core request path: `src/router.ts`, + `src/server/lifecycle.ts` and `src/server/responses/core.ts` must not import `src/telemetry/`. + Aggregates are computed from the existing usage log on a timer, never inline in a request. + +## Work phases (stack order) + +| Layer | Doc | Thesis | +| --- | --- | --- | +| 1 | this unit | Docs-only roadmap. | +| 2 | [010_client_core.md](010_client_core.md) | Config field, consent state, install id, aggregate builder, payload v1, sender, management API, `ocx telemetry`, `structure/telemetry.md`. | +| 3 | [020_gui_consent.md](020_gui_consent.md) | First-run card, post-update popup, Advanced toggle, preview dialog, all locales. | +| 4 | [030_collector.md](030_collector.md) | Cloudflare Worker + D1: ingest validation, limits, daily rollups, public stats API. | +| 5 | [040_docs_privacy.md](040_docs_privacy.md) | Telemetry guide, sidebar, configuration reference. | +| 6 | [050_dashboard.md](050_dashboard.md) | Astro page `/dashboard`: heatmap, KPIs, breakdowns, errors, downloads. | + +## Verification floor per layer + +`bun run typecheck`; focused tests for the layer plus `bun run test:changed`; +`bun run structure:check`, `bun run privacy:scan`, file-size ratchet and test-layout guards for +any layer adding files; `bun run lint:gui` and `bun run build:gui` for layer 3; docs-site build +for layers 5 and 6; `bun run skill:surface:check` when a CLI capability is added. Each layer gets an +independent review before its PR is opened. + + +## Audit disposition (round 1) + +Independent audit (argon, read-only): FAIL with 9 blockers and 6 majors; all accepted and folded. +Agent-driven starts no longer record the notice; the notice and decision routes need a human session; +`ocx telemetry enable` and `ocx config set telemetry` refuse agent-driven use; no persisted endpoint; +no pre-consent backfill and one day per tick; an import-free shared contract module with closed rosters +that the collector imports; strict version regex; `OcxTelemetryConfig` in `src/types/telemetry.ts` +and no edits to `src/cli/index.ts` or `scripts/test-layout/layout.json` (ratchet headroom 0–4 lines); +`telemetry` added to the skill-surface roots; session-only route exemptions; the docs guide now lands +before the dashboard; collector typecheck test; a tick-driven scheduler that re-reads config and stops in +`stopProcessLoops`, with `OCX_TELEMETRY=0` in the test preload; explicit install-channel mapping; GUI +dialog sequencing via `onSettled`, notice only on dismiss, `readOnly`; unified `env` and kebab-case +causes; disjoint token buckets; `TelemetryStatsV1` with `dailyByProvider` and a 365-day heatmap; dedup +on `(day, install_day_hash)`; prompts in `ocx init` and `ocx service install`; sidebar, configuration +reference and both header menus. Goalplan note: work phase wp4 delivers 040 (docs) and wp5 delivers 050 +(dashboard). + + +## Audit disposition (round 2) + +Same reviewer: PASS, 0 blockers; all 9 blockers and 6 majors verified resolved. Two minors folded: the +collector `tsconfig.json` overrides `rootDir`/`declaration` (030), and the new capability objects in +`src/cli/capabilities-observe-system.ts` stay plain literals because `tests/helpers/cli-capability-data.ts` +rejects `/` tokens and host identifiers there (010). diff --git a/devlog/_plan/261107_telemetry_dashboard/010_client_core.md b/devlog/_plan/261107_telemetry_dashboard/010_client_core.md new file mode 100644 index 00000000000..62a3cb13885 --- /dev/null +++ b/devlog/_plan/261107_telemetry_dashboard/010_client_core.md @@ -0,0 +1,179 @@ +# 010 — Client telemetry core + +Stack layer 2. Everything the proxy needs to decide, build, preview and send a daily report, the +management API and CLI that later layers use, the shared wire contract, and the structure doc that +claims the new `src/` area. Anchors were read on `c61e957900`. + +## Shared wire contract (`src/telemetry/contract.ts`) + +An **import-free** module (same rule as `src/usage/telemetry-contract.ts`) holding the closed rosters +and the payload validator: `TELEMETRY_SCHEMA_VERSION = 1`, `PROVIDER_KINDS`, `MODEL_FAMILIES`, +`INSTALL_CHANNELS`, `RELEASE_CHANNELS`, `OS_VALUES`, `ARCH_VALUES`, the outcome classes and the failure +causes (re-declared and pinned equal to `REQUEST_OUTCOME_CLASSES` / `REQUEST_FAILURE_CAUSES` by a test), +and `validateTelemetryPayloadV1(input, now)`. The collector (030) imports this file directly, so the +client cannot emit a key or value the collector rejects and the two cannot drift. + +- `PROVIDER_KINDS` (vendor-level, closed): openai, chatgpt, anthropic, claude-subscription, google, + vertex, xai, deepseek, moonshot, qwen, zai, minimax, mistral, openrouter, groq, cerebras, together, + fireworks, cursor, kiro, copilot, devin, ollama, lmstudio, local-other, other. The client maps a + provider's registry preset to a kind; custom/unknown providers become `other`. User-chosen provider + keys, labels and base URLs are never read into the payload. +- `MODEL_FAMILIES` (closed prefix buckets): gpt-6, gpt-5, gpt-4, o-series, codex, claude-opus, + claude-sonnet, claude-haiku, gemini-3, gemini-2, grok, deepseek, qwen, kimi, glm, minimax, mistral, + llama, other. +- `version` must match `^\d+\.\d+\.\d+(-preview\.\d+)?$` (no build metadata). + +## State model + +Type `OcxTelemetryConfig` lives in a new `src/types/telemetry.ts`; `src/types/config.ts` (1996 lines, +ratchet threshold 2000) gains exactly one import line and one field line, or an existing block moves +out first if the count says otherwise. Schema in `src/config/schema/config-schema.ts` as +`telemetryConfigSchema.optional().catch(undefined)`, strict live-write validation in +`src/config/diagnostics.ts`, load-degrade warning in `src/config/load-degrade.ts`, baseline adoption +`adoptPersistedTelemetry` in `src/config/live-reconcile.ts`. + +```ts +type OcxTelemetryConfig = { + decision?: "accepted" | "declined"; // absent = undecided + decidedAt?: string; // ISO + noticeShownAt?: string; // a human saw the disclosure (GUI or interactive TTY) +}; +``` + +There is **no persisted endpoint**: the collector URL is the constant +`https://telemetry.opencodex.me/v1/events` in `sender.ts` with a test-only injection seam. + +`resolveTelemetryStatus(config, env)` (`src/telemetry/consent.ts`): + +| Condition (first match) | `effective` | `reason` | +| --- | --- | --- | +| `OCX_TELEMETRY` ∈ {0,false,off,no} | false | `env-ocx-telemetry` | +| `DO_NOT_TRACK` ∈ {1,true,yes} | false | `env-do-not-track` | +| `decision === "declined"` | false | `declined` | +| `decision === "accepted"` | true | `accepted` | +| undecided and `noticeShownAt` set | true | `default-on` | +| undecided, no notice | false | `awaiting-notice` | + +## Who may change it (agent boundary) + +Telemetry is on by default, so the only thing that can switch sending on for an undecided install is +a human seeing the disclosure. Following `AGENTS_INSTALL.md` and `src/cli/star-prompt.ts`: + +- An agent-driven start (`isAgentDriven()`, non-TTY, or `OCX_SERVICE=1`) prints a one-line notice at + most once per version (`.telemetry-deferred` marker in the config dir) and writes **neither** + `noticeShownAt` nor `decision`. The install stays `awaiting-notice` until a human sees the GUI popup + or an interactive prompt. +- `PUT /api/telemetry` and `POST /api/telemetry/notice` both require `hasHumanDashboardSession(ctx)`. +- `ocx telemetry enable` refuses with `agent_consent_required` (non-zero exit) when `isAgentDriven()`; + `ocx telemetry disable` is always allowed (opting out is never a consent risk). +- `src/cli/config-command.ts` `set`/`unset` refuse the `telemetry` key, and `import` drops a + `telemetry` block when agent-driven, pointing to `ocx telemetry`. +- `AGENTS_INSTALL.md` gains a short "Telemetry" entry in its user-consent list. + +## Identity + +`src/telemetry/install-id.ts` keeps `/telemetry-id.json` (`0o600`, exclusive no-follow +create, pattern of `src/quota-pool/identity.ts`), created on the first send and deleted when the +decision becomes `declined`. The raw id never leaves the machine; each report carries +`installDayHash = sha256(installId + ":" + day)` and `newInstall: true` only on the very first report +of a freshly minted id (the collector counts new installs per day, version and channel from it). + +## Aggregation (`src/telemetry/aggregate.ts`) + +Scans `usage.jsonl` with `scanUsageLedgerCooperatively` (`src/usage/ledger-scanner.ts`) for one +completed UTC day: + +- `activeMinutes`: distinct `floor(timestamp / 60000)` values (≤ 1440). +- `requests`: counts per outcome class via `classifyRequestOutcome` (`src/usage/request-outcome.ts`). +- `models`: per (`providerKind`, `modelFamily`) requests and **disjoint** token buckets — + `inputTokens` = uncached input (`normalizeCostTokens`, `src/usage/cost.ts`), `cacheReadTokens`, + `cacheWriteTokens`, `outputTokens` = output minus reasoning, `reasoningTokens`. The five sum to the + display total (`usageDisplayTotalTokens`); a test pins that identity. +- `errors`: counts keyed by failure cause (kebab-case, e.g. `rate-limit`). + +## Payload v1 + +```json +{ "schemaVersion": 1, "batchId": "", "day": "2026-11-06", "installDayHash": "<64 hex>", + "newInstall": false, + "env": { "version": "2.40.1", "releaseChannel": "latest", "os": "darwin", "arch": "arm64", + "installChannel": "npm-global" }, + "activeMinutes": 312, + "requests": { "completed": 840, "failed": 12, "incomplete": 3, "aborted": 5 }, + "models": [ { "providerKind": "anthropic", "modelFamily": "claude-opus", "requests": 400, + "inputTokens": 0, "cacheReadTokens": 0, "cacheWriteTokens": 0, + "outputTokens": 0, "reasoningTokens": 0 } ], + "errors": { "rate-limit": 7 } } +``` + +`installChannel` mapping in `src/telemetry/payload.ts`: `OCX_DESKTOP_SUPERVISED === "1"` → desktop; +container markers (`/.dockerenv`, `/run/.containerenv`) → docker; `detectInstallOwnership().installer` +npm → npm-global, pnpm → pnpm-global, bun → bun-global, mise → mise, standalone → standalone, +source → source; otherwise unknown. Every field is named explicitly in the builder and the result is +run through `validateTelemetryPayloadV1` before sending or previewing. + +## Scheduling and sending + +- `src/server/background-lifecycle.ts` `startProcessLoops` starts a dormant `.unref()` scheduler via + `void import("../telemetry/scheduler")…` unless an env opt-out is set; `stopProcessLoops` stops it + (handle kept in background-lifecycle), and `registerOptionalShutdownHook("telemetry", stop)` covers + drain. `src/server/index.ts` and `src/cli/index.ts` (1999 lines) are not touched. +- Each tick (startup jitter, then every 6 h) re-reads config passively from disk and re-evaluates + `resolveTelemetryStatus`, so CLI and GUI changes take effect without a restart. The management + routes also call `adoptPersistedTelemetry` and nudge the scheduler. +- It sends only completed days on or after the UTC day of `decidedAt ?? noticeShownAt` (no pre-consent + backfill), at most **one day per tick**, oldest first, at most the last 7 days; 10 s + `signalWithTimeout`, no in-tick retry. Sent days live in `/telemetry-state.json`. +- `tests/preload.ts` sets `OCX_TELEMETRY=0` so no test process ever sends. + +## Management API + +Thin `src/server/management/telemetry-routes.ts` (seen by `management-route-registry.test.ts`) +delegating to `src/telemetry/routes.ts`, lazily namespaced from `src/server/management-api.ts` and +declared in `route-registry.ts`: + +- `GET /api/telemetry` → effective/reason/decision/env override, install channel, last sent day, + `preview` (the exact payload for the latest completed day; hash shown as a placeholder when no id + exists). +- `PUT /api/telemetry` `{ decision }` — human session only; `exempt: { reason: "session-only" }`. +- `POST /api/telemetry/notice` — human session only, idempotent; `exempt: { reason: "session-only" }`. + +## CLI + +`src/cli/telemetry.ts`: `ocx telemetry status [--json]` (route `GET /api/telemetry`, falls back to +local status when the proxy is down), `enable` and `disable` (local config mutation, `routes: []`, +`mutates: true`). Capability objects stay plain string/array/object literals (the lexical guard in `tests/helpers/cli-capability-data.ts` rejects `/` tokens and host identifiers). Registered in `capabilities-observe-system.ts`, `registry.ts`, `dispatch.ts`, +`help.ts`; `telemetry` is added to the `observe-system` roots in +`scripts/generate-ocx-skill-surface.ts` and `tests/ci-workflows/skill-ocx-generated.test.ts`, then +`bun run skill:surface`. + +Interactive prompt `Share anonymous usage statistics? [Y/n]` (default yes; Enter = yes; records +`decision` and `noticeShownAt`) shown once when undecided, from: `maybeShowStarPrompt()`'s module on +interactive `ocx start` (no new line in `src/cli/index.ts`), `ocx service install` next to its star +prompt (`src/service/cli.ts`), and the end of `ocx init` (`src/cli/init.ts`). + +## Ownership + +`structure/telemetry.md` registered in `structure/manifest.json` claiming `src/telemetry/`, +`src/types/telemetry.ts`, `src/server/management/telemetry-routes.ts` and `src/cli/telemetry.ts`, +naming only paths that exist in this layer; `bun run structure:index`. The four 600-line structure +docs are not extended. + +## Verification + +- `tests/usage/usage-telemetry-client.test.ts`: status matrix, env precedence, id file mode and + deletion, aggregate bucketing and the disjoint-token identity on a fixture ledger, payload validator + rejects extra keys/unknown roster values/semver metadata, roster parity with the usage contract, no + pre-consent days, one day per tick, sender timeout, nothing sent when not effective. +- `tests/usage/usage-telemetry-core-boundary.test.ts`: `router.ts`, `lifecycle.ts`, + `responses/core.ts`, `management-api.ts` have no load-time path to `src/telemetry/`. +- `tests/server/server-telemetry-routes.test.ts`: GET preview; PUT and notice refused with admin token + and remembered-admin; accepted with a human session; notice idempotent. +- `tests/cli/cli-telemetry.test.ts`: status/enable/disable, enable refused when agent-driven, config + set refusal, agent-driven start writes no notice, prompt default yes. +- New tests registered in a domain shard (`layout-usage.json` etc.; `layout.json` is at 1999 lines) + and `tests/fixtures/test-layout-expected.json`. +- `bun run typecheck`, focused files, `bun run test:changed`, `bun run structure:check`, + `bun run skill:surface:check`, `bun run privacy:scan`, file-size and layout guards, + `tests/cli/cli-capabilities.test.ts`, `tests/server/management-route-registry.test.ts`. + diff --git a/devlog/_plan/261107_telemetry_dashboard/020_gui_consent.md b/devlog/_plan/261107_telemetry_dashboard/020_gui_consent.md new file mode 100644 index 00000000000..96a0a3bc3b7 --- /dev/null +++ b/devlog/_plan/261107_telemetry_dashboard/020_gui_consent.md @@ -0,0 +1,55 @@ +# 020 — GUI consent popup, post-update popup and Advanced toggle + +Stack layer 3. Depends on 010 (`GET /api/telemetry`, `PUT /api/telemetry`, `POST /api/telemetry/notice`). + +## Behaviour + +- `TelemetryConsentCard` is a native `` mounted beside + `` in `gui/src/App.tsx`. It is enabled only when + `targetsSettled && !targets.connected && sharedSessionReady && starOnboardingSettled`. + `DesktopStarOnboarding` gains an `onSettled` callback (fired when it closes or decides it will not + show), so the two dialogs never open together. +- It opens when the status reports an undecided install with no env override and the proxy says + `reason === "awaiting-notice"` or `"default-on"` without a decision. That covers first run and + installs that update into this version (they have no decision yet): the post-update popup. +- The checkbox is **pre-checked**. Continue sends `PUT /api/telemetry { decision: checked ? "accepted" : + "declined" }` (this also records the notice server-side). Escape/backdrop without a decision sends + `POST /api/telemetry/notice`: the user saw the disclosure and the default (on) applies. Opening the + dialog alone records nothing, so an unread or automated page load never enables sending. +- The card lists what is sent and what is never sent and has a "What is sent" disclosure with the + exact preview JSON from the status endpoint. +- `TelemetrySettingsPanel` renders in Proxy details (`SettingsSection id="proxy-telemetry"` in + `dashboard-overview-panels.tsx`, id appended to `PROXY_SECTION_IDS`) with `Switch` from `ui.tsx`, + `readOnly` honoured for Remote Link children, and the preview dialog. With `OCX_TELEMETRY=0` or + `DO_NOT_TRACK=1` the switch is disabled and a `Notice tone="warn"` names the variable. + `AdvancedHub.tsx` gets `telemetry`/`privacy` search words on the proxy entry. +- The panel and card fetch on their own with `createBoundedFetch(15_000)`, outside + `useDashboardData` (dashboard-contracts poll-wave rule). + +## Files + +| File | Change | +| --- | --- | +| `gui/src/telemetry-api.ts` | DTO types and fetch helpers (non-component, oxlint `only-export-components`). | +| `gui/src/components/telemetry-consent-card.tsx` | First-run / post-update dialog. | +| `gui/src/components/TelemetrySettingsPanel.tsx` | Toggle row and preview dialog. | +| `gui/src/components/desktop-star-onboarding.tsx` | `onSettled` prop. | +| `gui/src/App.tsx` | Mount the card, track `starOnboardingSettled`. | +| `gui/src/pages/dashboard-section-ids.ts`, `dashboard-overview-panels.tsx`, `AdvancedHub.tsx` | Section, panel, search words. | +| `gui/src/i18n/v2-pages/proxy-copy.ts` | All `proxyV2.telemetry*` rows in the 11-column table (en, ko, de, fr, zh, zh-TW, ru, ja, tr, vi, pt). | + +No CSS is added to `gui/src/styles.css` (ratchet cap); reuse `.modal-overlay`/`.modal-card` and v2 +utilities. + +## Verification + +- `gui/tests/telemetry-consent-card.test.tsx`: waits for star onboarding; shows only when undecided + and no env override; checkbox pre-checked; nothing recorded on open; Continue sends accepted / + declined; Escape sends notice only; hidden once decided. +- `gui/tests/telemetry-settings-panel.test.tsx`: switch mirrors effective state, toggles via PUT, + disabled with env notice and in read-only mode, preview dialog shows the JSON. +- `gui/tests/desktop-star-onboarding.test.tsx` extended for `onSettled`. +- `cd gui && bun test tests && bun run lint && bun run lint:i18n && bun run build`; root + `bun run typecheck`. +- Screenshot of the card and panel for the PR description (uploaded via `pr-assets`, never committed). + diff --git a/devlog/_plan/261107_telemetry_dashboard/030_collector.md b/devlog/_plan/261107_telemetry_dashboard/030_collector.md new file mode 100644 index 00000000000..a8a02208b02 --- /dev/null +++ b/devlog/_plan/261107_telemetry_dashboard/030_collector.md @@ -0,0 +1,80 @@ +# 030 — Telemetry collector (Cloudflare Worker + D1) + +Stack layer 4. Imports the wire contract `src/telemetry/contract.ts` from 010 directly. + +## Layout (dependency-free) + +| File | Role | +| --- | --- | +| `services/telemetry-collector/wrangler.toml` | Worker name, route `telemetry.opencodex.me/*`, D1 binding `DB` with placeholder id `00000000-0000-0000-0000-000000000000`, cron trigger for pruning. Real ids are set by a maintainer at deploy time. | +| `services/telemetry-collector/schema.sql` | D1 tables (below). | +| `services/telemetry-collector/tsconfig.json` | Extends root settings with `rootDir: "../.."`, `noEmit: true`, `declaration: false` (as in `scripts/test-layout/tsconfig.verify.json`) so importing `../../src/telemetry/contract.ts` does not hit TS6059. | +| `services/telemetry-collector/src/types.ts` | Minimal local `D1Database`/`D1PreparedStatement`/`ExecutionContext` interfaces, `CollectorEnv`, `TelemetryStatsV1`. | +| `services/telemetry-collector/src/ingest.ts` | `POST /v1/events`. | +| `services/telemetry-collector/src/stats.ts` | `GET /v1/stats?range=7d|30d|90d|365d`. | +| `services/telemetry-collector/src/prune.ts` | Scheduled pruning. | +| `services/telemetry-collector/src/index.ts` | `fetch`/`scheduled` handlers, CORS, 404/405. | +| `services/telemetry-collector/README.md` | Deploy: `bunx wrangler d1 create`, `d1 execute --file schema.sql`, secrets (`RATE_LIMIT_SALT`), `wrangler deploy`. | + +No root `package.json`/`bun.lock` change. The root typecheck only covers `src/`; this layer adds a +test that typechecks the collector project with the repository's own TypeScript toolchain (command +chosen and proven in B, since `node_modules/.bin/tsc` is absent and `bun check` is the root checker). + +## Storage + +- `daily_installs(day, install_day_hash, version, release_channel, os, arch, install_channel, + new_install, active_minutes, PRIMARY KEY(day, install_day_hash))` — pruned after 400 days. +- `daily_totals(day, reports, new_installs, active_minutes, completed, failed, incomplete, aborted, + input, cache_read, cache_write, output, reasoning)`. +- `daily_models(day, provider_kind, model_family, requests, input, cache_read, cache_write, output, + reasoning)`. +- `daily_errors(day, cause, count)`. +- `rate_limits(bucket_hour, ip_hash, hits)` — pruned after 2 h. + +**Dedup:** ingest first `INSERT OR IGNORE` into `daily_installs`; only when that inserted a new +`(day, install_day_hash)` row are the rollups incremented (one D1 `batch`). A re-sent day — new +`batchId` or not — changes nothing, so lost client state cannot inflate totals. Raw install ids and IPs +are never stored; `ip_hash = sha256(RATE_LIMIT_SALT + ip + hour)`. + +## Validation and limits + +`validateTelemetryPayloadV1` from the contract (closed keys at every level, closed rosters for +provider kind, model family, channels, os, arch, failure causes; calendar-checked `day` in +[today − 7, today + 1]; strict version regex; bounded integers; ≤ 64 model rows). Body ≤ 16 KiB +(413), JSON only (400), 30 reports/hour per IP hash (429 with `Retry-After`). + +## Stats API (`TelemetryStatsV1`, consumed by 050) + +```ts +type TelemetryStatsV1 = { + schemaVersion: 1; generatedAt: string; range: "7d" | "30d" | "90d" | "365d"; + totals: { activeInstalls: number; newInstalls: number; activeMinutes: number; requests: number; + tokens: { input: number; cacheRead: number; cacheWrite: number; output: number; reasoning: number } }; + daily: Array<{ day: string; activeInstalls: number; newInstalls: number; activeMinutes: number; + requests: number; failed: number; tokens: number }>; + dailyByProvider: Array<{ day: string; providerKind: string; requests: number; tokens: number }>; + heatmap: Array<{ day: string; activeInstalls: number }>; // always the last 365 days + providers: Array<{ providerKind: string; requests: number; tokens: number }>; + models: Array<{ modelFamily: string; requests: number; tokens: number }>; + versions: Array<{ version: string; installs: number; newInstalls: number }>; + platforms: Array<{ os: string; arch: string; installs: number }>; + channels: Array<{ installChannel: string; installs: number; newInstalls: number }>; + outcomes: { completed: number; failed: number; incomplete: number; aborted: number }; + errors: Array<{ cause: string; count: number }>; +}; +``` + +Served with `Cache-Control: public, max-age=300` and CORS for `https://opencodex.me` and +`http://localhost:4321`. + +## Verification + +- `tests/usage/usage-telemetry-collector-worker.test.ts`: `bun:sqlite` in-memory D1 shim loading + `schema.sql` via `repoPath`; ingest accepts a payload built by the 010 builder, rejects unknown keys + and roster values, dedups re-sent days, rate-limits with 429, sets CORS, returns every stats field + for each range with a full 365-day heatmap, never stores a raw id or IP; pruning. +- `tests/usage/usage-telemetry-collector-typecheck.test.ts`: the collector project typechecks. +- Registered in `scripts/test-layout/layout-usage.json` and `tests/fixtures/test-layout-expected.json`; + add `ci:full` if the import graph misses them. +- `bun run typecheck`, `bun run privacy:scan` (placeholder ids only), file-size and layout guards. + diff --git a/devlog/_plan/261107_telemetry_dashboard/040_docs_privacy.md b/devlog/_plan/261107_telemetry_dashboard/040_docs_privacy.md new file mode 100644 index 00000000000..b50b0589b7e --- /dev/null +++ b/devlog/_plan/261107_telemetry_dashboard/040_docs_privacy.md @@ -0,0 +1,31 @@ +# 040 — Telemetry guide and configuration reference + +Stack layer 5. Lands before the dashboard (050) because the dashboard links to this guide and the +docs build rejects links to missing pages. + +## Public docs + +- `docs-site/src/content/docs/guides/telemetry.md` (English source): what is collected, what is never + collected, default-on with "nothing is sent until a person has seen the notice", agent-driven starts + never enable it, how to turn it off (GUI popup, Proxy details toggle, `ocx telemetry disable`, + `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1`), how to see the exact payload (`ocx telemetry status --json`, + "What is sent"), storage and retention (daily aggregates, install hash rotates daily, no IPs), and + the collector source location. No link to `/dashboard` yet (050 adds it). +- `docs-site/astro.config.mjs`: add `guides/telemetry` to the Guides sidebar. +- `docs-site/src/content/docs/reference/configuration/server.md`: `telemetry` config block and the + two environment variables. +- `guides/web-dashboard.md`: one line pointing to the Proxy details telemetry toggle. +- Translated locales follow the docs-site locale rule (translated or absent with English fallback); + nothing may contradict the English page. + +## Maintainer docs + +`structure/telemetry.md` (from 010) gains the collector location (`services/telemetry-collector/`, +now present) and the guide path. + +## Verification + +docs-site build (`cd docs-site && bun install --frozen-lockfile && bun run build`), +`tests/ci-workflows/docs-link-targets.test.ts` and the other `docs-*` claim guards, +`bun run structure:check`, `bun run privacy:scan`. + diff --git a/devlog/_plan/261107_telemetry_dashboard/050_dashboard.md b/devlog/_plan/261107_telemetry_dashboard/050_dashboard.md new file mode 100644 index 00000000000..41609671e36 --- /dev/null +++ b/devlog/_plan/261107_telemetry_dashboard/050_dashboard.md @@ -0,0 +1,43 @@ +# 050 — opencodex.me/dashboard + +Stack layer 6. Reads `GET https://telemetry.opencodex.me/v1/stats` (`TelemetryStatsV1`, 030) from the browser and links the telemetry guide from 040. + +## Page + +`docs-site/src/content/docs/dashboard.mdx` with `template: splash`, mounting +``. A content page (not `src/pages/`) gets Starlight's locale fallback routes, +so the header's language links (`/ko/dashboard/` ...) resolve and `internal-links.mjs` and +`tests/ci-workflows/docs-link-targets.test.ts` pass unchanged. + +## Sections (adapted from tokscale, MIT, credited in the footer) + +1. Header: title, range pills (7d / 30d / 90d / 365d), last-updated time, privacy callout linking the + telemetry guide and the opt-out variables. +2. KPI strip (tokscale `ProfileOverview`): active installs, requests, tokens, active hours — + `tabular-nums`, compact K/M/B with exact values in `title`. +3. Token mix bar (tokscale `TokenBreakdown`): input / cache read / cache write / output / reasoning — disjoint buckets from the payload contract, so the segments sum to the total. +4. Activity: a 52-week heatmap of daily active installs from the always-365-day `heatmap` series (tokscale `ProfileContributionGraph`, 5 grades) + and an ECharts stacked daily chart from `dailyByProvider` (tokens or requests by provider kind). ECharts is already a + docs-site dependency (`FrontierBoards.astro`) and re-themes on `data-theme` changes. +5. Provider-kind and model-family share tables (tokscale `ProfileModels` colour dots). +6. Platform, version and install-channel distribution, new installs per version/channel; reliability (outcome ratio and top error codes). +7. Downloads: npm weekly/total downloads (`api.npmjs.org/downloads`) and GitHub release asset download + counts (`api.github.com/repos///releases`), fetched client-side with graceful fallback. + +Loading, empty ("collector not deployed yet") and error states are explicit; no layout jumps. + +## Files + +`docs-site/src/components/TelemetryDashboard.astro` (shell + client script), +`docs-site/src/components/telemetry-dashboard/*.ts` (pure formatting / series helpers testable from +`tests/`), `docs-site/src/data/telemetry-dashboard-i18n.ts` (copy for the 8 site locales), and the localized `/dashboard/` link in both the desktop nav and the splash +mobile menu of `docs-site/src/components/Header.astro`, plus a link from `guides/telemetry.md`. + +## Verification + +- `tests/ci-workflows/docs-telemetry-dashboard.test.ts`: the route exists, helpers format and bucket a + fixture stats response correctly, empty/error states render, no hard-coded secrets. +- `cd docs-site && bun install --frozen-lockfile && bun run build`. +- Browser check of the built page against a fixture stats JSON (screenshot for the PR). + + From 0c542dea857d825ac6407fc8202a769761a29b70 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 7 Nov 2026 23:30:56 +0900 Subject: [PATCH 02/37] feat(telemetry): add shared wire contract, config schema, and core telemetry engine --- src/config/diagnostics.ts | 15 + src/config/live-reconcile.ts | 13 + src/config/load-degrade.ts | 16 ++ src/config/schema/config-schema.ts | 2 + src/config/schema/leaf-validators.ts | 11 + src/server/background-lifecycle.ts | 27 ++ src/telemetry/aggregate.ts | 321 +++++++++++++++++++++ src/telemetry/consent.ts | 127 +++++++++ src/telemetry/contract.ts | 403 +++++++++++++++++++++++++++ src/telemetry/install-id.ts | 197 +++++++++++++ src/telemetry/payload.ts | 129 +++++++++ src/telemetry/scheduler.ts | 246 ++++++++++++++++ src/telemetry/sender.ts | 63 +++++ src/types.ts | 2 + src/types/config.ts | 2 + src/types/telemetry.ts | 15 + tests/preload.ts | 1 + 17 files changed, 1590 insertions(+) create mode 100644 src/telemetry/aggregate.ts create mode 100644 src/telemetry/consent.ts create mode 100644 src/telemetry/contract.ts create mode 100644 src/telemetry/install-id.ts create mode 100644 src/telemetry/payload.ts create mode 100644 src/telemetry/scheduler.ts create mode 100644 src/telemetry/sender.ts create mode 100644 src/types/telemetry.ts diff --git a/src/config/diagnostics.ts b/src/config/diagnostics.ts index 5b68cffaab4..617db940f68 100644 --- a/src/config/diagnostics.ts +++ b/src/config/diagnostics.ts @@ -49,6 +49,7 @@ import { malformedQuotaResetNotifyWarning, malformedCatalogAutoRefreshWarning, malformedCodexPoolWarning, + malformedTelemetryWarning, malformedSpendWarning, rawConfigRecord, malformedNativeSubagentFields, @@ -81,6 +82,7 @@ import { compactionRoutingSchema, reasoningRetentionSchema, skillsConfigSchema, + telemetryConfigSchema, memoryModelsSchema, } from "./schema/leaf-validators"; @@ -175,6 +177,8 @@ function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): Conf if (catalogRefreshWarning) warnings.push(catalogRefreshWarning); const codexPoolWarning = malformedCodexPoolWarning(rawParsed); if (codexPoolWarning) warnings.push(codexPoolWarning); + const telemetryWarning = malformedTelemetryWarning(rawParsed); + if (telemetryWarning) warnings.push(telemetryWarning); const spendWarning = malformedSpendWarning(rawParsed); if (spendWarning) warnings.push(spendWarning); const retentionWarning = degradedReasoningRetentionWarning(rawParsed, normalized); @@ -709,6 +713,16 @@ function skillsConfigError(value: unknown): string | null { return "schema_invalid: skills" + (field ? "." + field : "") + ": " + (issue?.message ?? "invalid configuration"); } +export function telemetryConfigError(value: unknown): string | null { + const raw = rawConfigRecord(value); + if (!raw || !Object.hasOwn(raw, "telemetry") || raw.telemetry === undefined) return null; + const result = telemetryConfigSchema.safeParse(raw.telemetry); + if (result.success) return null; + const issue = result.error.issues[0]; + const field = issue?.path.join("."); + return "schema_invalid: telemetry" + (field ? "." + field : "") + ": " + (issue?.message ?? "invalid configuration"); +} + export function validateConfigCandidate(value: unknown): { ok: true; config: OcxConfig } | { ok: false; error: string } { const chatgptDesktop = rawConfigRecord(value)?.chatgptDesktop; if (chatgptDesktop !== undefined && !chatgptDesktopSchema.safeParse(chatgptDesktop).success) { @@ -773,6 +787,7 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx ?? managementIngressConfigError(value) ?? metricsExportConfigError(value) ?? serviceHealthRecoveryConfigError(value) + ?? telemetryConfigError(value) ?? skillsConfigError(value); if (boundaryError) return { ok: false, error: boundaryError }; const result = configSchema.safeParse(value); diff --git a/src/config/live-reconcile.ts b/src/config/live-reconcile.ts index 3e9d14d4439..e836e31add0 100644 --- a/src/config/live-reconcile.ts +++ b/src/config/live-reconcile.ts @@ -118,6 +118,19 @@ export function adoptPersistedAdvisor(config: OcxConfig, persisted: OcxConfig["a clearConfigTopLevelDeletionIntent(config, ["advisor"]); if (baseline) clearConfigTopLevelDeletionIntent(baseline, ["advisor"]); } + +/** Adopt the committed telemetry consent subtree without advancing unrelated pending live intent. */ +export function adoptPersistedTelemetry(config: OcxConfig, persisted: OcxConfig["telemetry"]): void { + if (persisted === undefined) delete config.telemetry; + else config.telemetry = structuredClone(persisted); + const baseline = liveConfigBaseline.get(config); + if (baseline) { + if (persisted === undefined) delete baseline.telemetry; + else baseline.telemetry = structuredClone(persisted); + } + clearConfigTopLevelDeletionIntent(config, ["telemetry"]); + if (baseline) clearConfigTopLevelDeletionIntent(baseline, ["telemetry"]); +} /** * The live config retains the address of the socket Bun actually opened, while * this map retains the operator's desired address for the next process start. diff --git a/src/config/load-degrade.ts b/src/config/load-degrade.ts index 355819b2ef8..ce3bae9c269 100644 --- a/src/config/load-degrade.ts +++ b/src/config/load-degrade.ts @@ -52,6 +52,7 @@ import { transientRetryOn5xxPolicySchema, runtimeRoleSchema, spendSchema, + telemetryConfigSchema, chatgptDesktopConfigIssue, } from "./schema/leaf-validators"; import { hasWarnedInheritedFastWireConflict, markWarnedInheritedFastWireConflict } from "./warn-memo"; @@ -152,6 +153,7 @@ export function warnDegradedTopLevelOptIns(rawParsed: unknown, validated: OcxCon warnDegradedStreamMode(rawParsed, validated); warnDegradedCompactionRouting(rawParsed, validated); warnDegradedMemoryModels(rawParsed, validated); + warnDegradedTelemetry(rawParsed); // The schema replaces a malformed policy with { allProfiles: false }, which silently turns every Aside profile off (#6757). const asidePolicy = rawParsed && typeof rawParsed === "object" ? (rawParsed as Record).asideProfileSync : undefined; if (asidePolicy !== undefined && !asideProfileSyncSchema.safeParse(asidePolicy).success) { @@ -850,6 +852,20 @@ export function malformedCodexPoolWarning(rawParsed: unknown): string | null { return `codexPool${field ? `.${field}` : ""} ignored: invalid Codex pool selection policy`; } +export function malformedTelemetryWarning(rawParsed: unknown): string | null { + const raw = rawConfigRecord(rawParsed); + if (!raw || !Object.hasOwn(raw, "telemetry") || raw.telemetry === undefined) return null; + const result = telemetryConfigSchema.safeParse(raw.telemetry); + if (result.success) return null; + const field = result.error.issues[0]?.path.join("."); + return `telemetry${field ? `.${field}` : ""} ignored: invalid telemetry consent configuration`; +} + +export function warnDegradedTelemetry(rawParsed: unknown): void { + const warning = malformedTelemetryWarning(rawParsed); + if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`); +} + /** * Warn once per load that the section was dropped. * diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index b57c3efa537..189344bebee 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -29,6 +29,7 @@ import { spendSchema, chatgptDesktopSchema, skillsConfigSchema, + telemetryConfigSchema, configuredCodexPoolAccountIds, apiKeyEntrySchema, asideProfileSyncSchema, @@ -120,6 +121,7 @@ export const configSchema = z.object({ skills: skillsConfigSchema.optional().catch(undefined), // Malformed hand edits disable this opt-in exporter. Live writes reject them in diagnostics.ts. metricsExport: z.object({ enabled: z.boolean().optional() }).strict().optional().catch(undefined), + telemetry: telemetryConfigSchema.optional().catch(undefined), // Same contract as metricsExport: a malformed hand edit leaves recovery off; live writes reject it. serviceHealthRecovery: z.object({ enabled: z.boolean().optional() }).strict().optional().catch(undefined), // Kept raw on purpose: `.catch(undefined)` would turn a mistyped `enabled` into "inherit", diff --git a/src/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts index 5df3213e410..d601679717b 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -1189,3 +1189,14 @@ export const spendSchema = z.object({ export const skillsConfigSchema = z.object({ catalog_refresh: z.enum(["per_session", "per_turn"]).optional(), }).strict(); + +/** + * Anonymous telemetry consent state. + * + * No endpoint is accepted here: the collector URL is a constant in src/telemetry/sender.ts. + */ +export const telemetryConfigSchema = z.object({ + decision: z.enum(["accepted", "declined"]).optional(), + decidedAt: clientTimestampSchema.optional(), + noticeShownAt: clientTimestampSchema.optional(), +}).strict(); diff --git a/src/server/background-lifecycle.ts b/src/server/background-lifecycle.ts index 8f51e2c9fcf..2661fff01d3 100644 --- a/src/server/background-lifecycle.ts +++ b/src/server/background-lifecycle.ts @@ -60,6 +60,15 @@ export type ServerBackgroundLifecycleLease = { const owners: LeaseOwner[] = []; let processLoops: ProcessLoops | null = null; let cleanupInProgress = false; +let telemetryLoopGeneration = 0; +let stopTelemetryLoop: (() => void) | null = null; + +function isTelemetryDisabledByEnv(env: NodeJS.ProcessEnv = process.env): boolean { + const ocx = env.OCX_TELEMETRY?.trim().toLowerCase(); + if (ocx === "0" || ocx === "false" || ocx === "off" || ocx === "no") return true; + const dnt = env.DO_NOT_TRACK?.trim().toLowerCase(); + return dnt === "1" || dnt === "true" || dnt === "yes"; +} function setLivePolicyOwner(applyPolicy: PolicyApply | null): void { setStorageCleanupPolicyLiveSink(applyPolicy); @@ -124,8 +133,22 @@ function startProcessLoops(applyPolicy: PolicyApply): ProcessLoops { .catch(() => { // The next poll tick retries. }); + const generation = ++telemetryLoopGeneration; + if (!isTelemetryDisabledByEnv()) { + void import("../telemetry/scheduler") + .then(scheduler => { + if (generation !== telemetryLoopGeneration) return; + stopTelemetryLoop = scheduler.startTelemetryScheduler(); + }) + .catch(() => { + // Optional background loop. + }); + } return { memoryWatchdog, stateStoreSweeper }; } catch (error) { + telemetryLoopGeneration += 1; + stopTelemetryLoop?.(); + stopTelemetryLoop = null; memoryWatchdog?.stop(); stateStoreSweeper?.stop(); stopStorageCleanupScheduler(); @@ -138,6 +161,10 @@ function startProcessLoops(applyPolicy: PolicyApply): ProcessLoops { } function stopProcessLoops(): void { + telemetryLoopGeneration += 1; + const stopTelemetry = stopTelemetryLoop; + stopTelemetryLoop = null; + stopTelemetry?.(); const loops = processLoops; processLoops = null; loops?.memoryWatchdog.stop(); diff --git a/src/telemetry/aggregate.ts b/src/telemetry/aggregate.ts new file mode 100644 index 00000000000..fc71f1030cb --- /dev/null +++ b/src/telemetry/aggregate.ts @@ -0,0 +1,321 @@ +import type { OcxUsage } from "../types"; +import { normalizeCostTokens } from "../usage/cost"; +import { scanUsageLedgerCooperatively } from "../usage/ledger-scanner"; +import { isKnownRequestFailureCause, type PersistedUsageEntry } from "../usage/log"; +import { classifyRequestOutcome } from "../usage/request-outcome"; +import { + TELEMETRY_MAX_MODELS, + parseCalendarUtcDayMs, + type TelemetryErrorsV1, + type TelemetryFailureCause, + type TelemetryModelFamily, + type TelemetryModelRowV1, + type TelemetryProviderKind, + type TelemetryRequestsV1, +} from "./contract"; + +export interface DisjointTelemetryTokens { + inputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + outputTokens: number; + reasoningTokens: number; +} + +export interface DailyTelemetryAggregate { + day: string; + activeMinutes: number; + requests: TelemetryRequestsV1; + models: TelemetryModelRowV1[]; + errors: TelemetryErrorsV1; +} + +const ACCOUNT_SUFFIX_RE = /-(?:main|[pko][0-9a-f]{6})$/i; + +const PRESET_TO_PROVIDER_KIND: Readonly> = Object.freeze({ + openai: "openai", + "openai-apikey": "openai", + "azure-openai": "openai", + chatgpt: "chatgpt", + "openai-multi": "chatgpt", + anthropic: "anthropic", + "anthropic-apikey": "anthropic", + "claude-subscription": "claude-subscription", + "claude-cli": "claude-subscription", + anthropic2: "claude-subscription", + google: "google", + "google-antigravity": "google", + vertex: "vertex", + "google-vertex": "vertex", + xai: "xai", + grok: "xai", + deepseek: "deepseek", + moonshot: "moonshot", + kimi: "moonshot", + "kimi-responses": "moonshot", + "kimi-code": "moonshot", + qwen: "qwen", + "qwen-cloud": "qwen", + alibaba: "qwen", + "alibaba-token-plan": "qwen", + "alibaba-token-plan-intl": "qwen", + zai: "zai", + "zhipu-bigmodel": "zai", + "zhipu-bigmodel-coding": "zai", + "zhipu-bigmodel-responses": "zai", + minimax: "minimax", + "minimax-cn": "minimax", + mistral: "mistral", + openrouter: "openrouter", + groq: "groq", + cerebras: "cerebras", + together: "together", + fireworks: "fireworks", + firepass: "fireworks", + cursor: "cursor", + kiro: "kiro", + copilot: "copilot", + "github-copilot": "copilot", + devin: "devin", + ollama: "ollama", + "ollama-cloud": "ollama", + lmstudio: "lmstudio", + "lm-studio": "lmstudio", + "local-other": "local-other", + vllm: "local-other", + litellm: "local-other", +}); + +/** + * Map a logged provider identifier to the closed vendor-level `TelemetryProviderKind` roster. + * Custom/unknown provider keys never enter the payload; they map to `"other"`. + */ +export function classifyProviderKind(provider: string): TelemetryProviderKind { + const trimmed = provider.trim().toLowerCase(); + if (!trimmed) return "other"; + const direct = PRESET_TO_PROVIDER_KIND[trimmed]; + if (direct) return direct; + const stripped = trimmed.replace(ACCOUNT_SUFFIX_RE, ""); + return PRESET_TO_PROVIDER_KIND[stripped] ?? "other"; +} + +/** + * Map a model identifier to the closed `TelemetryModelFamily` prefix roster. + */ +export function classifyModelFamily(model: string): TelemetryModelFamily { + const trimmed = model.trim().toLowerCase(); + if (!trimmed) return "other"; + const slash = trimmed.lastIndexOf("/"); + const tail = (slash >= 0 ? trimmed.slice(slash + 1) : trimmed).replace(/--[a-z0-9_-]+$/, ""); + if (!tail) return "other"; + if (tail.startsWith("gpt-6")) return "gpt-6"; + if (tail.startsWith("gpt-5")) return "gpt-5"; + if (tail.startsWith("gpt-4")) return "gpt-4"; + if (tail === "o-series" || /^o[134](?:$|[-._])/.test(tail)) return "o-series"; + if (tail.startsWith("codex")) return "codex"; + if (/^claude-(?:[\d.-]+-)?opus(?:$|[-._])/.test(tail) || tail === "claude-opus") return "claude-opus"; + if (/^claude-(?:[\d.-]+-)?sonnet(?:$|[-._])/.test(tail) || tail === "claude-sonnet") return "claude-sonnet"; + if (/^claude-(?:[\d.-]+-)?haiku(?:$|[-._])/.test(tail) || tail === "claude-haiku") return "claude-haiku"; + if (tail.startsWith("gemini-3")) return "gemini-3"; + if (tail.startsWith("gemini-2")) return "gemini-2"; + if (tail.startsWith("grok")) return "grok"; + if (tail.startsWith("deepseek")) return "deepseek"; + if (tail.startsWith("qwen") || tail.startsWith("qwq")) return "qwen"; + if (tail.startsWith("kimi") || tail.startsWith("moonshot")) return "kimi"; + if (tail.startsWith("glm") || tail.startsWith("chatglm")) return "glm"; + if (tail.startsWith("minimax") || tail.startsWith("abab")) return "minimax"; + if ( + tail.startsWith("mistral") + || tail.startsWith("mixtral") + || tail.startsWith("codestral") + || tail.startsWith("pixtral") + || tail.startsWith("ministral") + ) { + return "mistral"; + } + if (tail.startsWith("llama") || tail.startsWith("meta-llama")) return "llama"; + return "other"; +} + +function nonNegInt(value: number | undefined): number { + return typeof value === "number" && Number.isFinite(value) && value > 0 + ? Math.floor(value) + : 0; +} + +/** + * Decompose `OcxUsage` into five disjoint token buckets whose sum equals `usageDisplayTotalTokens(usage)`: + * - `inputTokens`: uncached input (`normalizeCostTokens`) + * - `cacheReadTokens` + * - `cacheWriteTokens` + * - `outputTokens`: output minus reasoning + * - `reasoningTokens` + */ +export function disjointTelemetryTokens(usage: OcxUsage | undefined): DisjointTelemetryTokens { + if (!usage) { + return { + inputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + }; + } + const normalized = normalizeCostTokens(usage); + let inputTokens: number; + let cacheReadTokens: number; + let cacheWriteTokens: number; + let totalOutput: number; + if (normalized) { + inputTokens = nonNegInt(normalized.input); + cacheReadTokens = nonNegInt(normalized.cacheRead); + cacheWriteTokens = nonNegInt(normalized.cacheWrite); + totalOutput = nonNegInt(normalized.output); + } else { + const rawInput = nonNegInt(usage.inputTokens); + cacheWriteTokens = Math.min(rawInput, nonNegInt(usage.cacheCreationInputTokens)); + cacheReadTokens = Math.min( + rawInput - cacheWriteTokens, + nonNegInt(usage.cacheReadInputTokens ?? usage.cachedInputTokens), + ); + inputTokens = Math.max(0, rawInput - cacheReadTokens - cacheWriteTokens); + totalOutput = nonNegInt(usage.outputTokens); + } + const reasoningTokens = Math.min(totalOutput, nonNegInt(usage.reasoningOutputTokens)); + const outputTokens = Math.max(0, totalOutput - reasoningTokens); + return { + inputTokens, + cacheReadTokens, + cacheWriteTokens, + outputTokens, + reasoningTokens, + }; +} + +export function aggregateTelemetryEntriesForDay( + day: string, + entries: Iterable, +): DailyTelemetryAggregate { + const dayStartMs = parseCalendarUtcDayMs(day); + if (dayStartMs === null) { + throw new Error(`Invalid UTC day: ${day}`); + } + const dayEndMs = dayStartMs + 86_400_000; + const minutes = new Set(); + const requests: TelemetryRequestsV1 = { + completed: 0, + failed: 0, + incomplete: 0, + aborted: 0, + }; + const modelMap = new Map(); + const errors: TelemetryErrorsV1 = {}; + + const addModelAttribution = (provider: string, model: string, usage: OcxUsage | undefined) => { + const providerKind = classifyProviderKind(provider); + const modelFamily = classifyModelFamily(model); + const key = `${providerKind}\0${modelFamily}`; + let row = modelMap.get(key); + if (!row) { + row = { + providerKind, + modelFamily, + requests: 0, + inputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + }; + modelMap.set(key, row); + } + row.requests += 1; + const tokens = disjointTelemetryTokens(usage); + row.inputTokens += tokens.inputTokens; + row.cacheReadTokens += tokens.cacheReadTokens; + row.cacheWriteTokens += tokens.cacheWriteTokens; + row.outputTokens += tokens.outputTokens; + row.reasoningTokens += tokens.reasoningTokens; + }; + + for (const entry of entries) { + if (typeof entry.timestamp !== "number" || entry.timestamp < dayStartMs || entry.timestamp >= dayEndMs) { + continue; + } + minutes.add(Math.floor(entry.timestamp / 60_000)); + const outcome = classifyRequestOutcome(entry); + requests[outcome] += 1; + + const cause = entry.failureCause ?? entry.attempts?.at(-1)?.failureCause; + if (cause && isKnownRequestFailureCause(cause)) { + errors[cause as TelemetryFailureCause] = (errors[cause as TelemetryFailureCause] ?? 0) + 1; + } + + if (entry.provider === "combo" && entry.attempts && entry.attempts.length > 0) { + for (const attempt of entry.attempts) { + addModelAttribution(attempt.provider, attempt.model, attempt.usage); + } + } else { + addModelAttribution(entry.provider, entry.model, entry.usage); + } + } + + let models = [...modelMap.values()].sort((a, b) => { + if (b.requests !== a.requests) return b.requests - a.requests; + const aTokens = a.inputTokens + a.cacheReadTokens + a.cacheWriteTokens + a.outputTokens + a.reasoningTokens; + const bTokens = b.inputTokens + b.cacheReadTokens + b.cacheWriteTokens + b.outputTokens + b.reasoningTokens; + if (bTokens !== aTokens) return bTokens - aTokens; + if (a.providerKind !== b.providerKind) return a.providerKind.localeCompare(b.providerKind); + return a.modelFamily.localeCompare(b.modelFamily); + }); + + if (models.length > TELEMETRY_MAX_MODELS) { + const kept = models.slice(0, TELEMETRY_MAX_MODELS - 1); + const overflow = models.slice(TELEMETRY_MAX_MODELS - 1); + let otherRow = kept.find(r => r.providerKind === "other" && r.modelFamily === "other"); + if (!otherRow) { + otherRow = { + providerKind: "other", + modelFamily: "other", + requests: 0, + inputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + }; + kept.push(otherRow); + } + for (const row of overflow) { + otherRow.requests += row.requests; + otherRow.inputTokens += row.inputTokens; + otherRow.cacheReadTokens += row.cacheReadTokens; + otherRow.cacheWriteTokens += row.cacheWriteTokens; + otherRow.outputTokens += row.outputTokens; + otherRow.reasoningTokens += row.reasoningTokens; + } + models = kept; + } + + return { + day, + activeMinutes: Math.min(1440, minutes.size), + requests, + models, + errors, + }; +} + +export async function aggregateTelemetryForDay( + day: string, + options: { signal?: AbortSignal } = {}, +): Promise { + const entries: PersistedUsageEntry[] = []; + await scanUsageLedgerCooperatively({ + signal: options.signal, + onEntry: entry => { + entries.push(entry); + }, + }); + return aggregateTelemetryEntriesForDay(day, entries); +} diff --git a/src/telemetry/consent.ts b/src/telemetry/consent.ts new file mode 100644 index 00000000000..5461028ba51 --- /dev/null +++ b/src/telemetry/consent.ts @@ -0,0 +1,127 @@ +import type { OcxTelemetryConfig, OcxTelemetryDecision } from "../types/telemetry"; + +export type TelemetryStatusReason = + | "env-ocx-telemetry" + | "env-do-not-track" + | "declined" + | "accepted" + | "default-on" + | "awaiting-notice"; + +export type TelemetryEnvOverride = "OCX_TELEMETRY" | "DO_NOT_TRACK" | null; + +export interface TelemetryConsentStatus { + effective: boolean; + reason: TelemetryStatusReason; + decision: OcxTelemetryDecision | null; + decidedAt: string | null; + noticeShownAt: string | null; + envOverride: TelemetryEnvOverride; +} + +const OCX_TELEMETRY_OFF_VALUES = new Set(["0", "false", "off", "no"]); +const DO_NOT_TRACK_ON_VALUES = new Set(["1", "true", "yes"]); + +function extractTelemetryConfig( + input: OcxTelemetryConfig | { telemetry?: OcxTelemetryConfig } | null | undefined, +): OcxTelemetryConfig | undefined { + if (!input || typeof input !== "object") return undefined; + if ("telemetry" in input) { + const nested = (input as { telemetry?: unknown }).telemetry; + return nested && typeof nested === "object" && !Array.isArray(nested) + ? (nested as OcxTelemetryConfig) + : undefined; + } + return input as OcxTelemetryConfig; +} + +function validIsoString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (!trimmed || Number.isNaN(Date.parse(trimmed))) return null; + return trimmed; +} + +export function isTelemetryEnvOptOut(env: Record = process.env): TelemetryEnvOverride { + const ocx = env.OCX_TELEMETRY?.trim().toLowerCase(); + if (ocx !== undefined && OCX_TELEMETRY_OFF_VALUES.has(ocx)) { + return "OCX_TELEMETRY"; + } + const dnt = env.DO_NOT_TRACK?.trim().toLowerCase(); + if (dnt !== undefined && DO_NOT_TRACK_ON_VALUES.has(dnt)) { + return "DO_NOT_TRACK"; + } + return null; +} + +export function resolveTelemetryStatus( + config: OcxTelemetryConfig | { telemetry?: OcxTelemetryConfig } | null | undefined, + env: Record = process.env, +): TelemetryConsentStatus { + const telemetry = extractTelemetryConfig(config); + const decision = telemetry?.decision === "accepted" || telemetry?.decision === "declined" + ? telemetry.decision + : null; + const decidedAt = validIsoString(telemetry?.decidedAt); + const noticeShownAt = validIsoString(telemetry?.noticeShownAt); + const envOverride = isTelemetryEnvOptOut(env); + + if (envOverride === "OCX_TELEMETRY") { + return { + effective: false, + reason: "env-ocx-telemetry", + decision, + decidedAt, + noticeShownAt, + envOverride, + }; + } + if (envOverride === "DO_NOT_TRACK") { + return { + effective: false, + reason: "env-do-not-track", + decision, + decidedAt, + noticeShownAt, + envOverride, + }; + } + if (decision === "declined") { + return { + effective: false, + reason: "declined", + decision, + decidedAt, + noticeShownAt, + envOverride: null, + }; + } + if (decision === "accepted") { + return { + effective: true, + reason: "accepted", + decision, + decidedAt, + noticeShownAt, + envOverride: null, + }; + } + if (noticeShownAt !== null) { + return { + effective: true, + reason: "default-on", + decision: null, + decidedAt, + noticeShownAt, + envOverride: null, + }; + } + return { + effective: false, + reason: "awaiting-notice", + decision: null, + decidedAt, + noticeShownAt: null, + envOverride: null, + }; +} diff --git a/src/telemetry/contract.ts b/src/telemetry/contract.ts new file mode 100644 index 00000000000..2e645963f9a --- /dev/null +++ b/src/telemetry/contract.ts @@ -0,0 +1,403 @@ +/** + * Shared wire contract for anonymous daily product telemetry (schemaVersion 1). + * + * This module is deliberately import-free so both the local proxy and the Cloudflare Worker + * collector (services/telemetry-collector) can import it directly without pulling runtime + * dependencies across project boundaries. + */ + +export const TELEMETRY_SCHEMA_VERSION = 1 as const; +export const TELEMETRY_MAX_MODELS = 64; +export const TELEMETRY_VERSION_RE = /^\d+\.\d+\.\d+(-preview\.\d+)?$/; + +export const PROVIDER_KINDS = Object.freeze([ + "openai", + "chatgpt", + "anthropic", + "claude-subscription", + "google", + "vertex", + "xai", + "deepseek", + "moonshot", + "qwen", + "zai", + "minimax", + "mistral", + "openrouter", + "groq", + "cerebras", + "together", + "fireworks", + "cursor", + "kiro", + "copilot", + "devin", + "ollama", + "lmstudio", + "local-other", + "other", +] as const); + +export type TelemetryProviderKind = typeof PROVIDER_KINDS[number]; +export type ProviderKind = TelemetryProviderKind; + +export const MODEL_FAMILIES = Object.freeze([ + "gpt-6", + "gpt-5", + "gpt-4", + "o-series", + "codex", + "claude-opus", + "claude-sonnet", + "claude-haiku", + "gemini-3", + "gemini-2", + "grok", + "deepseek", + "qwen", + "kimi", + "glm", + "minimax", + "mistral", + "llama", + "other", +] as const); + +export type TelemetryModelFamily = typeof MODEL_FAMILIES[number]; +export type ModelFamily = TelemetryModelFamily; + +export const INSTALL_CHANNELS = Object.freeze([ + "npm-global", + "pnpm-global", + "bun-global", + "mise", + "standalone", + "desktop", + "docker", + "source", + "unknown", +] as const); + +export type TelemetryInstallChannel = typeof INSTALL_CHANNELS[number]; +export type InstallChannel = TelemetryInstallChannel; + +export const RELEASE_CHANNELS = Object.freeze([ + "latest", + "preview", +] as const); + +export type TelemetryReleaseChannel = typeof RELEASE_CHANNELS[number]; +export type ReleaseChannel = TelemetryReleaseChannel; + +export const OS_VALUES = Object.freeze([ + "darwin", + "linux", + "win32", + "other", +] as const); + +export type TelemetryOs = typeof OS_VALUES[number]; + +export const ARCH_VALUES = Object.freeze([ + "x64", + "arm64", + "other", +] as const); + +export type TelemetryArch = typeof ARCH_VALUES[number]; + +export const REQUEST_OUTCOME_CLASSES = Object.freeze([ + "completed", + "failed", + "incomplete", + "aborted", +] as const); +export const TELEMETRY_OUTCOME_CLASSES = REQUEST_OUTCOME_CLASSES; +export type TelemetryOutcomeClass = typeof REQUEST_OUTCOME_CLASSES[number]; + +export const REQUEST_FAILURE_CAUSES = Object.freeze([ + "transport-unsent", + "transport-ambiguous", + "upstream-declined", + "rate-limit", + "quota-exhausted", + "credential-rejected", + "policy-refusal", + "parameter-rejected", + "ciphertext-refusal", + "payload-too-large", + "payload-rejected", + "upstream-fault", + "empty-output", + "client-cancelled", + "local-refusal", +] as const); +export const TELEMETRY_FAILURE_CAUSES = REQUEST_FAILURE_CAUSES; +export type TelemetryFailureCause = typeof REQUEST_FAILURE_CAUSES[number]; + +export interface TelemetryEnvV1 { + version: string; + releaseChannel: TelemetryReleaseChannel; + os: TelemetryOs; + arch: TelemetryArch; + installChannel: TelemetryInstallChannel; +} + +export interface TelemetryRequestsV1 { + completed: number; + failed: number; + incomplete: number; + aborted: number; +} + +export interface TelemetryModelRowV1 { + providerKind: TelemetryProviderKind; + modelFamily: TelemetryModelFamily; + requests: number; + inputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + outputTokens: number; + reasoningTokens: number; +} + +export type TelemetryErrorsV1 = Partial>; + +export interface TelemetryPayloadV1 { + schemaVersion: typeof TELEMETRY_SCHEMA_VERSION; + batchId: string; + day: string; + installDayHash: string; + newInstall: boolean; + env: TelemetryEnvV1; + activeMinutes: number; + requests: TelemetryRequestsV1; + models: TelemetryModelRowV1[]; + errors: TelemetryErrorsV1; +} + +const PROVIDER_KIND_SET: ReadonlySet = new Set(PROVIDER_KINDS); +const MODEL_FAMILY_SET: ReadonlySet = new Set(MODEL_FAMILIES); +const INSTALL_CHANNEL_SET: ReadonlySet = new Set(INSTALL_CHANNELS); +const RELEASE_CHANNEL_SET: ReadonlySet = new Set(RELEASE_CHANNELS); +const OS_VALUE_SET: ReadonlySet = new Set(OS_VALUES); +const ARCH_VALUE_SET: ReadonlySet = new Set(ARCH_VALUES); +const FAILURE_CAUSE_SET: ReadonlySet = new Set(REQUEST_FAILURE_CAUSES); + +const TOP_LEVEL_KEYS = [ + "schemaVersion", + "batchId", + "day", + "installDayHash", + "newInstall", + "env", + "activeMinutes", + "requests", + "models", + "errors", +] as const; +const ENV_KEYS = ["version", "releaseChannel", "os", "arch", "installChannel"] as const; +const REQUESTS_KEYS = ["completed", "failed", "incomplete", "aborted"] as const; +const MODEL_ROW_KEYS = [ + "providerKind", + "modelFamily", + "requests", + "inputTokens", + "cacheReadTokens", + "cacheWriteTokens", + "outputTokens", + "reasoningTokens", +] as const; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const HEX64_RE = /^[0-9a-f]{64}$/; +const DAY_RE = /^(\d{4})-(\d{2})-(\d{2})$/; +const DAY_MS = 86_400_000; +const MAX_REQUEST_COUNT = 10_000_000; +const MAX_TOKEN_COUNT = 1_000_000_000_000; + +function isPlainRecord(value: unknown): value is Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +function hasExactKeys(record: Record, expected: readonly string[]): boolean { + const keys = Object.keys(record); + if (keys.length !== expected.length) return false; + for (const key of expected) { + if (!Object.hasOwn(record, key)) return false; + } + return true; +} + +function isBoundedInt(value: unknown, max: number): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= max; +} + +export function parseCalendarUtcDayMs(day: string): number | null { + const match = DAY_RE.exec(day); + if (!match) return null; + const year = Number(match[1]); + const month = Number(match[2]); + const date = Number(match[3]); + const ms = Date.UTC(year, month - 1, date); + const check = new Date(ms); + if ( + check.getUTCFullYear() !== year + || check.getUTCMonth() !== month - 1 + || check.getUTCDate() !== date + ) { + return null; + } + return ms; +} + +export function validateTelemetryPayloadV1( + input: unknown, + now: number = Date.now(), +): { ok: true; payload: TelemetryPayloadV1 } | { ok: false; error: string } { + if (!isPlainRecord(input)) return { ok: false, error: "payload must be a plain object" }; + if (!hasExactKeys(input, TOP_LEVEL_KEYS)) { + return { ok: false, error: "payload has missing or unexpected top-level keys" }; + } + if (input.schemaVersion !== TELEMETRY_SCHEMA_VERSION) { + return { ok: false, error: "schemaVersion must be 1" }; + } + if (typeof input.batchId !== "string" || !UUID_RE.test(input.batchId)) { + return { ok: false, error: "batchId must be a UUID" }; + } + if (typeof input.day !== "string") { + return { ok: false, error: "day must be a YYYY-MM-DD string" }; + } + const dayMs = parseCalendarUtcDayMs(input.day); + if (dayMs === null) { + return { ok: false, error: "day must be a valid calendar date in YYYY-MM-DD format" }; + } + const nowDate = new Date(now); + const todayUtcMs = Date.UTC(nowDate.getUTCFullYear(), nowDate.getUTCMonth(), nowDate.getUTCDate()); + if (dayMs < todayUtcMs - 7 * DAY_MS || dayMs > todayUtcMs + DAY_MS) { + return { ok: false, error: "day must be within [today - 7d, today + 1d]" }; + } + if (typeof input.installDayHash !== "string" || !HEX64_RE.test(input.installDayHash)) { + return { ok: false, error: "installDayHash must be 64 lowercase hex characters" }; + } + if (typeof input.newInstall !== "boolean") { + return { ok: false, error: "newInstall must be a boolean" }; + } + if (!isPlainRecord(input.env) || !hasExactKeys(input.env, ENV_KEYS)) { + return { ok: false, error: "env has missing or unexpected keys" }; + } + const env = input.env; + if (typeof env.version !== "string" || !TELEMETRY_VERSION_RE.test(env.version)) { + return { ok: false, error: "env.version must match strict semver without build metadata" }; + } + if (typeof env.releaseChannel !== "string" || !RELEASE_CHANNEL_SET.has(env.releaseChannel)) { + return { ok: false, error: "env.releaseChannel is not in RELEASE_CHANNELS" }; + } + if (typeof env.os !== "string" || !OS_VALUE_SET.has(env.os)) { + return { ok: false, error: "env.os is not in OS_VALUES" }; + } + if (typeof env.arch !== "string" || !ARCH_VALUE_SET.has(env.arch)) { + return { ok: false, error: "env.arch is not in ARCH_VALUES" }; + } + if (typeof env.installChannel !== "string" || !INSTALL_CHANNEL_SET.has(env.installChannel)) { + return { ok: false, error: "env.installChannel is not in INSTALL_CHANNELS" }; + } + if (!isBoundedInt(input.activeMinutes, 1440)) { + return { ok: false, error: "activeMinutes must be an integer between 0 and 1440" }; + } + if (!isPlainRecord(input.requests) || !hasExactKeys(input.requests, REQUESTS_KEYS)) { + return { ok: false, error: "requests has missing or unexpected keys" }; + } + for (const key of REQUESTS_KEYS) { + if (!isBoundedInt(input.requests[key], MAX_REQUEST_COUNT)) { + return { ok: false, error: `requests.${key} must be a bounded non-negative integer` }; + } + } + if (!Array.isArray(input.models) || input.models.length > TELEMETRY_MAX_MODELS) { + return { ok: false, error: `models must be an array of at most ${TELEMETRY_MAX_MODELS} rows` }; + } + const seenPairs = new Set(); + const models: TelemetryModelRowV1[] = []; + for (const row of input.models) { + if (!isPlainRecord(row) || !hasExactKeys(row, MODEL_ROW_KEYS)) { + return { ok: false, error: "models row has missing or unexpected keys" }; + } + if (typeof row.providerKind !== "string" || !PROVIDER_KIND_SET.has(row.providerKind)) { + return { ok: false, error: "models row providerKind is not in PROVIDER_KINDS" }; + } + if (typeof row.modelFamily !== "string" || !MODEL_FAMILY_SET.has(row.modelFamily)) { + return { ok: false, error: "models row modelFamily is not in MODEL_FAMILIES" }; + } + const pairKey = `${row.providerKind}\0${row.modelFamily}`; + if (seenPairs.has(pairKey)) { + return { ok: false, error: "models rows must have unique (providerKind, modelFamily) pairs" }; + } + seenPairs.add(pairKey); + if (!isBoundedInt(row.requests, MAX_REQUEST_COUNT)) { + return { ok: false, error: "models row requests must be a bounded non-negative integer" }; + } + for (const tokenField of [ + "inputTokens", + "cacheReadTokens", + "cacheWriteTokens", + "outputTokens", + "reasoningTokens", + ] as const) { + if (!isBoundedInt(row[tokenField], MAX_TOKEN_COUNT)) { + return { ok: false, error: `models row ${tokenField} must be a bounded non-negative integer` }; + } + } + models.push({ + providerKind: row.providerKind as TelemetryProviderKind, + modelFamily: row.modelFamily as TelemetryModelFamily, + requests: row.requests, + inputTokens: row.inputTokens as number, + cacheReadTokens: row.cacheReadTokens as number, + cacheWriteTokens: row.cacheWriteTokens as number, + outputTokens: row.outputTokens as number, + reasoningTokens: row.reasoningTokens as number, + }); + } + if (!isPlainRecord(input.errors)) { + return { ok: false, error: "errors must be a plain object" }; + } + const errors: TelemetryErrorsV1 = {}; + for (const [cause, count] of Object.entries(input.errors)) { + if (!FAILURE_CAUSE_SET.has(cause)) { + return { ok: false, error: `errors key "${cause}" is not in REQUEST_FAILURE_CAUSES` }; + } + if (!isBoundedInt(count, MAX_REQUEST_COUNT)) { + return { ok: false, error: `errors["${cause}"] must be a bounded non-negative integer` }; + } + errors[cause as TelemetryFailureCause] = count; + } + return { + ok: true, + payload: { + schemaVersion: TELEMETRY_SCHEMA_VERSION, + batchId: input.batchId.toLowerCase(), + day: input.day, + installDayHash: input.installDayHash, + newInstall: input.newInstall, + env: { + version: env.version, + releaseChannel: env.releaseChannel as TelemetryReleaseChannel, + os: env.os as TelemetryOs, + arch: env.arch as TelemetryArch, + installChannel: env.installChannel as TelemetryInstallChannel, + }, + activeMinutes: input.activeMinutes, + requests: { + completed: input.requests.completed as number, + failed: input.requests.failed as number, + incomplete: input.requests.incomplete as number, + aborted: input.requests.aborted as number, + }, + models, + errors, + }, + }; +} diff --git a/src/telemetry/install-id.ts b/src/telemetry/install-id.ts new file mode 100644 index 00000000000..a69e3882c37 --- /dev/null +++ b/src/telemetry/install-id.ts @@ -0,0 +1,197 @@ +import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { + closeSync, + constants as fsConstants, + fchmodSync, + fsyncSync, + linkSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + unlinkSync, + writeSync, +} from "node:fs"; +import { dirname, join } from "node:path"; +import { getConfigDir } from "../config/paths"; +import { recordOwnedConfigPath } from "../lib/config-ownership"; + +export const TELEMETRY_ID_FILE = "telemetry-id.json"; +export const TELEMETRY_PREVIEW_HASH_PLACEHOLDER = "0".repeat(64); + +export interface TelemetryInstallIdentity { + installId: string; + createdAt: string; +} + +const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const NOFOLLOW = process.platform === "win32" ? 0 : fsConstants.O_NOFOLLOW; +const PUBLISHED_READ_ATTEMPTS = 50; +const PUBLISHED_READ_INTERVAL_MS = 10; + +let memo: { path: string; identity: TelemetryInstallIdentity } | null = null; + +export function telemetryIdPath(configDir = getConfigDir()): string { + return join(configDir, TELEMETRY_ID_FILE); +} + +function parseTelemetryId(text: string): TelemetryInstallIdentity | null { + try { + const raw = JSON.parse(text) as Record; + if (raw?.version !== 1 || typeof raw.installId !== "string" || typeof raw.createdAt !== "string") { + return null; + } + if (!UUID_V4_RE.test(raw.installId) || Number.isNaN(Date.parse(raw.createdAt))) { + return null; + } + return { installId: raw.installId.toLowerCase(), createdAt: raw.createdAt }; + } catch { + return null; + } +} + +/** Read-only: returns the persisted telemetry install identity, or null when absent/invalid. */ +export function readTelemetryInstallId(configDir = getConfigDir()): TelemetryInstallIdentity | null { + const path = telemetryIdPath(configDir); + if (memo?.path === path) return memo.identity; + try { + const stat = lstatSync(path); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024) return null; + } catch { + return null; + } + let identity: TelemetryInstallIdentity | null; + try { + identity = parseTelemetryId(readFileSync(path, "utf8")); + } catch { + return null; + } + if (identity) memo = { path, identity }; + return identity; +} + +function errorCode(error: unknown): string | undefined { + return (error as NodeJS.ErrnoException | null)?.code; +} + +function createExclusive(path: string, content: string): void { + const fd = openSync(path, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | NOFOLLOW, 0o600); + try { + if (process.platform !== "win32") { + try { fchmodSync(fd, 0o600); } catch { /* best-effort */ } + } + writeSync(fd, content); + fsyncSync(fd); + } finally { + closeSync(fd); + } +} + +function syncParentDirectory(path: string): void { + if (process.platform === "win32") return; + try { + const fd = openSync(dirname(path), fsConstants.O_RDONLY); + try { fsyncSync(fd); } finally { closeSync(fd); } + } catch { /* best-effort */ } +} + +function pause(ms: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +function adoptPublishedIdentity(configDir: string, path: string): TelemetryInstallIdentity { + for (let attempt = 0; attempt < PUBLISHED_READ_ATTEMPTS; attempt++) { + let stat; + try { + stat = lstatSync(path); + } catch (error) { + if (errorCode(error) !== "ENOENT") throw error; + stat = null; + } + if (stat && (stat.isSymbolicLink() || !stat.isFile())) break; + const identity = readTelemetryInstallId(configDir); + if (identity) return identity; + if (attempt + 1 < PUBLISHED_READ_ATTEMPTS) pause(PUBLISHED_READ_INTERVAL_MS); + } + throw new Error("telemetry identity file is not a valid regular file; remove it to rotate the identity"); +} + +/** + * Create `/telemetry-id.json` (`0o600`, exclusive no-follow) on the first send. + * Returns `{ identity, freshlyCreated }` so the caller knows when a brand-new id was minted. + */ +export function ensureTelemetryInstallId( + configDir = getConfigDir(), +): { identity: TelemetryInstallIdentity; freshlyCreated: boolean } { + const existing = readTelemetryInstallId(configDir); + if (existing) return { identity: existing, freshlyCreated: false }; + mkdirSync(configDir, { recursive: true, mode: 0o700 }); + const path = telemetryIdPath(configDir); + try { recordOwnedConfigPath(configDir, path); } catch { /* best-effort */ } + let present = true; + try { + lstatSync(path); + } catch (error) { + if (errorCode(error) !== "ENOENT") throw error; + present = false; + } + if (present) { + return { identity: adoptPublishedIdentity(configDir, path), freshlyCreated: false }; + } + const identity: TelemetryInstallIdentity = { + installId: randomUUID().toLowerCase(), + createdAt: new Date().toISOString(), + }; + const content = `${JSON.stringify({ version: 1, ...identity })}\n`; + const temp = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`; + let published = false; + try { + createExclusive(temp, content); + try { + linkSync(temp, path); + published = true; + } catch (error) { + const code = errorCode(error); + if (code === "EEXIST") { + return { identity: adoptPublishedIdentity(configDir, path), freshlyCreated: false }; + } + if (code !== "EPERM" && code !== "ENOTSUP" && code !== "EXDEV" && code !== "ENOSYS") throw error; + } + } finally { + try { unlinkSync(temp); } catch { /* best-effort */ } + } + if (!published) { + try { + createExclusive(path, content); + } catch (error) { + if (errorCode(error) !== "EEXIST") throw error; + return { identity: adoptPublishedIdentity(configDir, path), freshlyCreated: false }; + } + } + syncParentDirectory(path); + memo = { path, identity }; + return { identity, freshlyCreated: true }; +} + +/** Delete `/telemetry-id.json` when telemetry is declined. */ +export function deleteTelemetryInstallId(configDir = getConfigDir()): boolean { + const path = telemetryIdPath(configDir); + if (memo?.path === path) memo = null; + try { + const stat = lstatSync(path); + if (stat.isSymbolicLink() || !stat.isFile()) return false; + unlinkSync(path); + return true; + } catch (error) { + if (errorCode(error) === "ENOENT") return false; + return false; + } +} + +export function computeInstallDayHash(installId: string, day: string): string { + return createHash("sha256").update(`${installId}:${day}`).digest("hex"); +} + +export function resetTelemetryInstallIdMemoForTests(): void { + memo = null; +} diff --git a/src/telemetry/payload.ts b/src/telemetry/payload.ts new file mode 100644 index 00000000000..628f434f2af --- /dev/null +++ b/src/telemetry/payload.ts @@ -0,0 +1,129 @@ +import { randomUUID } from "node:crypto"; +import { existsSync } from "node:fs"; +import { packageVersion } from "../lib/package-version"; +import { detectInstallOwnership } from "../update"; +import type { DailyTelemetryAggregate } from "./aggregate"; +import { + ARCH_VALUES, + OS_VALUES, + TELEMETRY_SCHEMA_VERSION, + TELEMETRY_VERSION_RE, + validateTelemetryPayloadV1, + type TelemetryArch, + type TelemetryInstallChannel, + type TelemetryOs, + type TelemetryPayloadV1, + type TelemetryReleaseChannel, +} from "./contract"; + +export interface ResolveInstallChannelOptions { + env?: Record; + exists?: (path: string) => boolean; + detectOwnership?: () => { installer?: string }; +} + +export function resolveInstallChannel(options: ResolveInstallChannelOptions = {}): TelemetryInstallChannel { + const env = options.env ?? process.env; + if (env.OCX_DESKTOP_SUPERVISED === "1") return "desktop"; + const exists = options.exists ?? existsSync; + try { + if (exists("/.dockerenv") || exists("/run/.containerenv")) return "docker"; + } catch { /* ignore fs errors */ } + try { + const installer = (options.detectOwnership ?? detectInstallOwnership)()?.installer; + switch (installer) { + case "npm": + return "npm-global"; + case "pnpm": + return "pnpm-global"; + case "bun": + return "bun-global"; + case "mise": + return "mise"; + case "standalone": + return "standalone"; + case "source": + return "source"; + default: + return "unknown"; + } + } catch { + return "unknown"; + } +} + +export function normalizeTelemetryVersion(raw = packageVersion("0.0.0")): { + version: string; + releaseChannel: TelemetryReleaseChannel; +} { + const stripped = raw.trim().replace(/^v/, "").split("+")[0] ?? "0.0.0"; + const version = TELEMETRY_VERSION_RE.test(stripped) ? stripped : "0.0.0"; + const releaseChannel: TelemetryReleaseChannel = version.includes("-preview.") ? "preview" : "latest"; + return { version, releaseChannel }; +} + +export function resolveTelemetryOs(platform: string = process.platform): TelemetryOs { + return (OS_VALUES as readonly string[]).includes(platform) && platform !== "other" + ? (platform as TelemetryOs) + : "other"; +} + +export function resolveTelemetryArch(arch: string = process.arch): TelemetryArch { + return (ARCH_VALUES as readonly string[]).includes(arch) && arch !== "other" + ? (arch as TelemetryArch) + : "other"; +} + +export interface BuildTelemetryPayloadOptions { + aggregate: DailyTelemetryAggregate; + installDayHash: string; + newInstall: boolean; + batchId?: string; + version?: string; + releaseChannel?: TelemetryReleaseChannel; + os?: TelemetryOs; + arch?: TelemetryArch; + installChannel?: TelemetryInstallChannel; + now?: number; +} + +export function buildTelemetryPayloadV1(options: BuildTelemetryPayloadOptions): TelemetryPayloadV1 { + const versionInfo = normalizeTelemetryVersion(options.version); + const candidate: TelemetryPayloadV1 = { + schemaVersion: TELEMETRY_SCHEMA_VERSION, + batchId: options.batchId ?? randomUUID(), + day: options.aggregate.day, + installDayHash: options.installDayHash, + newInstall: options.newInstall, + env: { + version: options.version ?? versionInfo.version, + releaseChannel: options.releaseChannel ?? versionInfo.releaseChannel, + os: options.os ?? resolveTelemetryOs(), + arch: options.arch ?? resolveTelemetryArch(), + installChannel: options.installChannel ?? resolveInstallChannel(), + }, + activeMinutes: options.aggregate.activeMinutes, + requests: { + completed: options.aggregate.requests.completed, + failed: options.aggregate.requests.failed, + incomplete: options.aggregate.requests.incomplete, + aborted: options.aggregate.requests.aborted, + }, + models: options.aggregate.models.map(row => ({ + providerKind: row.providerKind, + modelFamily: row.modelFamily, + requests: row.requests, + inputTokens: row.inputTokens, + cacheReadTokens: row.cacheReadTokens, + cacheWriteTokens: row.cacheWriteTokens, + outputTokens: row.outputTokens, + reasoningTokens: row.reasoningTokens, + })), + errors: { ...options.aggregate.errors }, + }; + const validated = validateTelemetryPayloadV1(candidate, options.now); + if (!validated.ok) { + throw new Error(`Invalid telemetry payload: ${validated.error}`); + } + return validated.payload; +} diff --git a/src/telemetry/scheduler.ts b/src/telemetry/scheduler.ts new file mode 100644 index 00000000000..604c535d0ec --- /dev/null +++ b/src/telemetry/scheduler.ts @@ -0,0 +1,246 @@ +import { lstatSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { readConfigDiagnostics } from "../config/diagnostics"; +import { getConfigDir } from "../config/paths"; +import { recordOwnedConfigPath } from "../lib/config-ownership"; +import { registerOptionalShutdownHook } from "../lib/optional-shutdown-hooks"; +import type { OcxConfig } from "../types"; +import { aggregateTelemetryForDay, type DailyTelemetryAggregate } from "./aggregate"; +import { isTelemetryEnvOptOut, resolveTelemetryStatus } from "./consent"; +import { computeInstallDayHash, ensureTelemetryInstallId } from "./install-id"; +import { buildTelemetryPayloadV1, resolveInstallChannel, type ResolveInstallChannelOptions } from "./payload"; +import { sendTelemetryPayload, type TelemetrySendOptions, type TelemetrySendResult } from "./sender"; + +export const TELEMETRY_STATE_FILE = "telemetry-state.json"; +export const TELEMETRY_TICK_INTERVAL_MS = 6 * 60 * 60 * 1000; +const DAY_MS = 86_400_000; +const MAX_BACKLOG_DAYS = 7; +const STATE_RETENTION_DAYS = 30; + +export interface TelemetryState { + version: 1; + sentDays: Record; + lastSentDay: string | null; + lastSentAt: string | null; + firstReportSentForInstallId: string | null; +} + +export function telemetryStatePath(configDir = getConfigDir()): string { + return join(configDir, TELEMETRY_STATE_FILE); +} + +function emptyState(): TelemetryState { + return { + version: 1, + sentDays: {}, + lastSentDay: null, + lastSentAt: null, + firstReportSentForInstallId: null, + }; +} + +export function readTelemetryState(configDir = getConfigDir()): TelemetryState { + const path = telemetryStatePath(configDir); + try { + const stat = lstatSync(path); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 64 * 1024) return emptyState(); + const raw = JSON.parse(readFileSync(path, "utf8")) as Record; + if (raw?.version !== 1 || !raw.sentDays || typeof raw.sentDays !== "object" || Array.isArray(raw.sentDays)) { + return emptyState(); + } + const sentDays: Record = {}; + for (const [day, at] of Object.entries(raw.sentDays as Record)) { + if (/^\d{4}-\d{2}-\d{2}$/.test(day) && typeof at === "string") { + sentDays[day] = at; + } + } + return { + version: 1, + sentDays, + lastSentDay: typeof raw.lastSentDay === "string" ? raw.lastSentDay : null, + lastSentAt: typeof raw.lastSentAt === "string" ? raw.lastSentAt : null, + firstReportSentForInstallId: + typeof raw.firstReportSentForInstallId === "string" ? raw.firstReportSentForInstallId : null, + }; + } catch { + return emptyState(); + } +} + +export function writeTelemetryState(state: TelemetryState, configDir = getConfigDir(), now = Date.now()): void { + mkdirSync(configDir, { recursive: true, mode: 0o700 }); + const path = telemetryStatePath(configDir); + try { recordOwnedConfigPath(configDir, path); } catch { /* best-effort */ } + const minKeepDay = formatUtcDay(utcMidnightMs(now) - STATE_RETENTION_DAYS * DAY_MS); + const prunedSentDays: Record = {}; + for (const [day, at] of Object.entries(state.sentDays)) { + if (day >= minKeepDay) prunedSentDays[day] = at; + } + const payload: TelemetryState = { + version: 1, + sentDays: prunedSentDays, + lastSentDay: state.lastSentDay, + lastSentAt: state.lastSentAt, + firstReportSentForInstallId: state.firstReportSentForInstallId, + }; + writeFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); +} + +export function utcMidnightMs(epochMs: number): number { + const d = new Date(epochMs); + return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); +} + +export function formatUtcDay(epochMs: number): string { + return new Date(epochMs).toISOString().slice(0, 10); +} + +export function latestCompletedUtcDay(now = Date.now()): string { + return formatUtcDay(utcMidnightMs(now) - DAY_MS); +} + +export interface TelemetryTickOptions extends TelemetrySendOptions, ResolveInstallChannelOptions { + configDir?: string; + config?: Pick + readConfig?: () => Pick; + aggregateDay?: (day: string) => Promise; +} + +export interface TelemetryTickResult { + sent: boolean; + day?: string; + reason: string; + sendResult?: TelemetrySendResult; +} + +/** + * Run one telemetry tick: + * - Re-reads config passively from disk and re-evaluates `resolveTelemetryStatus`. + * - Sends only completed UTC days on or after the UTC day of `decidedAt ?? noticeShownAt` + * (no pre-consent backfill). + * - Sends at most ONE day per tick, oldest first, at most the last 7 completed days. + */ +export async function runTelemetryTick(options: TelemetryTickOptions = {}): Promise { + const now = options.now ?? Date.now(); + const env = options.env ?? process.env; + const configDir = options.configDir ?? getConfigDir(); + const config = options.config + ?? (options.readConfig ? options.readConfig() : readConfigDiagnostics({ passive: true }).config); + const status = resolveTelemetryStatus(config, env); + if (!status.effective) { + return { sent: false, reason: status.reason }; + } + const consentTimestamp = status.decidedAt ?? status.noticeShownAt; + if (!consentTimestamp) { + return { sent: false, reason: "missing-consent-timestamp" }; + } + const consentMs = Date.parse(consentTimestamp); + if (Number.isNaN(consentMs)) { + return { sent: false, reason: "invalid-consent-timestamp" }; + } + const consentDay = formatUtcDay(consentMs); + const todayMs = utcMidnightMs(now); + const state = readTelemetryState(configDir); + + let targetDay: string | null = null; + for (let offset = MAX_BACKLOG_DAYS; offset >= 1; offset--) { + const candidateDay = formatUtcDay(todayMs - offset * DAY_MS); + if (candidateDay < consentDay) continue; + if (state.sentDays[candidateDay]) continue; + targetDay = candidateDay; + break; + } + if (!targetDay) { + return { sent: false, reason: "up-to-date" }; + } + + const { identity } = ensureTelemetryInstallId(configDir); + const newInstall = state.firstReportSentForInstallId !== identity.installId; + const installDayHash = computeInstallDayHash(identity.installId, targetDay); + const aggregate = await (options.aggregateDay + ? options.aggregateDay(targetDay) + : aggregateTelemetryForDay(targetDay, { signal: options.signal })); + const payload = buildTelemetryPayloadV1({ + aggregate, + installDayHash, + newInstall, + now, + installChannel: resolveInstallChannel(options), + }); + const sendResult = await sendTelemetryPayload(payload, { ...options, now }); + if (!sendResult.ok) { + return { sent: false, day: targetDay, reason: "send-failed", sendResult }; + } + const nowIso = new Date(now).toISOString(); + state.sentDays[targetDay] = nowIso; + state.lastSentDay = targetDay; + state.lastSentAt = nowIso; + state.firstReportSentForInstallId = identity.installId; + writeTelemetryState(state, configDir, now); + return { sent: true, day: targetDay, reason: "sent", sendResult }; +} + +let startupTimer: ReturnType | null = null; +let intervalTimer: ReturnType | null = null; +let detachShutdownHook: (() => void) | null = null; +let activeTick: Promise | null = null; +let running = false; + +function triggerBackgroundTick(): void { + if (!running || activeTick) return; + activeTick = runTelemetryTick() + .catch((): TelemetryTickResult => ({ sent: false, reason: "tick-error" })) + .finally(() => { + activeTick = null; + }); +} + +export function startTelemetryScheduler(options: { startupDelayMs?: number; intervalMs?: number } = {}): () => void { + if (isTelemetryEnvOptOut(process.env) !== null) { + return () => {}; + } + if (running) return stopTelemetryScheduler; + running = true; + detachShutdownHook = registerOptionalShutdownHook("telemetry", stopTelemetryScheduler); + const startupDelay = options.startupDelayMs ?? (30_000 + Math.floor(Math.random() * 30_000)); + const intervalMs = options.intervalMs ?? TELEMETRY_TICK_INTERVAL_MS; + startupTimer = setTimeout(() => { + startupTimer = null; + triggerBackgroundTick(); + }, startupDelay); + startupTimer.unref?.(); + intervalTimer = setInterval(() => { + triggerBackgroundTick(); + }, intervalMs); + intervalTimer.unref?.(); + return stopTelemetryScheduler; +} + +export function stopTelemetryScheduler(): void { + running = false; + if (startupTimer) { + clearTimeout(startupTimer); + startupTimer = null; + } + if (intervalTimer) { + clearInterval(intervalTimer); + intervalTimer = null; + } + if (detachShutdownHook) { + const detach = detachShutdownHook; + detachShutdownHook = null; + detach(); + } +} + +export function nudgeTelemetryScheduler(): void { + if (!running) return; + if (startupTimer) { + clearTimeout(startupTimer); + startupTimer = null; + } + const timer = setTimeout(() => { + triggerBackgroundTick(); + }, 10); + timer.unref?.(); +} diff --git a/src/telemetry/sender.ts b/src/telemetry/sender.ts new file mode 100644 index 00000000000..d7a4be4b4a8 --- /dev/null +++ b/src/telemetry/sender.ts @@ -0,0 +1,63 @@ +import { signalWithTimeout } from "../lib/abort"; +import { validateTelemetryPayloadV1, type TelemetryPayloadV1 } from "./contract"; + +export const TELEMETRY_COLLECTOR_ENDPOINT = "https://telemetry.opencodex.me/v1/events"; +export const TELEMETRY_SEND_TIMEOUT_MS = 10_000; + +export interface TelemetrySendOptions { + endpoint?: string; + fetch?: typeof fetch; + timeoutMs?: number; + signal?: AbortSignal; + now?: number; +} + +export interface TelemetrySendResult { + ok: boolean; + status?: number; + error?: string; +} + +let testOverrides: TelemetrySendOptions | null = null; + +export function setTelemetrySenderOverridesForTests(overrides: TelemetrySendOptions | null): void { + testOverrides = overrides; +} + +export async function sendTelemetryPayload( + payload: TelemetryPayloadV1, + options: TelemetrySendOptions = {}, +): Promise { + const now = options.now ?? testOverrides?.now ?? Date.now(); + const validated = validateTelemetryPayloadV1(payload, now); + if (!validated.ok) { + return { ok: false, error: validated.error }; + } + const endpoint = options.endpoint ?? testOverrides?.endpoint ?? TELEMETRY_COLLECTOR_ENDPOINT; + const fetchImpl = options.fetch ?? testOverrides?.fetch ?? globalThis.fetch; + const timeoutMs = options.timeoutMs ?? testOverrides?.timeoutMs ?? TELEMETRY_SEND_TIMEOUT_MS; + const parentSignal = options.signal ?? testOverrides?.signal; + + const deadline = signalWithTimeout(timeoutMs, parentSignal); + try { + const response = await fetchImpl(endpoint, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify(validated.payload), + signal: deadline.signal, + }); + if (!response.ok) { + return { ok: false, status: response.status, error: `collector responded ${response.status}` }; + } + return { ok: true, status: response.status }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } finally { + deadline.cleanup(); + } +} diff --git a/src/types.ts b/src/types.ts index 8da67a3db8b..9937eeb1465 100644 --- a/src/types.ts +++ b/src/types.ts @@ -146,3 +146,5 @@ export type { CodexAccountCredentials, CodexAccountCredentialRecord, } from "./types/accounts"; + +export type { OcxTelemetryConfig, OcxTelemetryDecision } from "./types/telemetry"; diff --git a/src/types/config.ts b/src/types/config.ts index 1bc480ef5c4..87dfc40cd36 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -3,6 +3,7 @@ import type { CodexAccount } from "./accounts"; import type { AnthropicAccountPoolConfig, OcxAccountPoolRotationStrategy } from "./anthropic-account-pool"; import type { AnthropicInstanceId } from "../providers/anthropic-instance-id"; import type { OcxFallbackConfig } from "./fallback"; +import type { OcxTelemetryConfig } from "./telemetry"; export type { AnthropicAccountPoolConfig, AnthropicModelRoute, OcxAccountPoolRotationStrategy, OcxAccountPoolQuotaWindow } from "./anthropic-account-pool"; export type { OcxFallbackConfig, OcxFallbackDeviceConfig, OcxFallbackKind, OcxFallbackSwitches } from "./fallback"; @@ -574,6 +575,7 @@ export interface OcxConfig { skills?: OcxSkillsConfig; /** Opt-in process-local aggregate request metrics on the authenticated management plane. */ metricsExport?: { enabled?: boolean }; + telemetry?: OcxTelemetryConfig; /** * Opt-in recovery of a managed service proxy that stays alive but stops answering /healthz: * a separate sentinel process terminates it so its supervisor restarts it (#6671). diff --git a/src/types/telemetry.ts b/src/types/telemetry.ts new file mode 100644 index 00000000000..e9ea8039b7c --- /dev/null +++ b/src/types/telemetry.ts @@ -0,0 +1,15 @@ +export type OcxTelemetryDecision = "accepted" | "declined"; + +/** + * Anonymous product telemetry consent state. + * + * Absence of `decision` means undecided. No payload is sent until either `decision` is + * `"accepted"` or a human has seen the disclosure (`noticeShownAt` recorded via interactive + * TTY or dashboard consent session). The collector endpoint is a code constant and is never + * persisted here. + */ +export interface OcxTelemetryConfig { + decision?: OcxTelemetryDecision; + decidedAt?: string; + noticeShownAt?: string; +} diff --git a/tests/preload.ts b/tests/preload.ts index 0e66597830a..6f94c5eac4a 100644 --- a/tests/preload.ts +++ b/tests/preload.ts @@ -76,6 +76,7 @@ process.env.OCX_TEST_HOME_GUARD = "1"; // Lets a test assert one preload per process rather than assuming Bun's scheduling. process.env.OCX_TEST_PRELOAD_PID = String(process.pid); process.env.OCX_DISABLE_UPDATE_CHECK = "1"; +process.env.OCX_TELEMETRY = "0"; process.env.OPENCODEX_KIRO_MODEL_DISCOVERY = "0"; if (!isTestHomeGuardArmed() || !protectedHomeForTests()) { From 643dd4f259bc45237cfee1ad1ad643f1eec5047b Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 7 Nov 2026 23:30:56 +0900 Subject: [PATCH 03/37] feat(telemetry): add management API routes, CLI commands, and consent prompts --- AGENTS_INSTALL.md | 10 + scripts/generate-ocx-skill-surface.ts | 2 +- .../ocx/references/01_management_surface.md | 18 +- .../references/01_surface_observe-system.md | 55 ++++- src/cli/capabilities-observe-system.ts | 30 +++ src/cli/config-command.ts | 11 +- src/cli/dispatch.ts | 4 + src/cli/help.ts | 1 + src/cli/init.ts | 4 + src/cli/registry.ts | 10 + src/cli/star-prompt.ts | 2 + src/cli/telemetry.ts | 188 ++++++++++++++++++ src/server/management-api.ts | 7 + src/server/management/route-registry.ts | 4 + src/server/management/telemetry-routes.ts | 20 ++ src/service/cli.ts | 1 + src/telemetry/routes.ts | 172 ++++++++++++++++ 17 files changed, 533 insertions(+), 6 deletions(-) create mode 100644 src/cli/telemetry.ts create mode 100644 src/server/management/telemetry-routes.ts create mode 100644 src/telemetry/routes.ts diff --git a/AGENTS_INSTALL.md b/AGENTS_INSTALL.md index c4a80069fd7..e5489ddc853 100644 --- a/AGENTS_INSTALL.md +++ b/AGENTS_INSTALL.md @@ -52,6 +52,16 @@ The following instructions concern **starring the repository on GitHub**. the matter permanently — do not argue it, re-frame it, or raise it again later. +### Telemetry + +Anonymous daily usage telemetry defaults to on only after a human has seen the +disclosure notice (in the GUI or an interactive TTY prompt). An agent must never +enable telemetry or acknowledge the telemetry disclosure on the user's behalf: +`PUT /api/telemetry`, `POST /api/telemetry/notice`, `ocx telemetry enable`, and +`ocx config set telemetry` refuse agent-driven callers with +`agent_consent_required`. Opting out (`ocx telemetry disable` or +`OCX_TELEMETRY=0`) is always permitted. + ## Where things are installed An agent asked to install, inspect, repair, or remove opencodex needs to know what is written diff --git a/scripts/generate-ocx-skill-surface.ts b/scripts/generate-ocx-skill-surface.ts index 750ea21e566..155e866656b 100644 --- a/scripts/generate-ocx-skill-surface.ts +++ b/scripts/generate-ocx-skill-surface.ts @@ -21,7 +21,7 @@ const DOMAINS = [ { name: "accounts", roots: ["account", "auth", "login", "logout"] }, { name: "agents-routing", roots: ["agent", "combo", "route", "fallback", "v2", "effort", "memory", "message", "advisor"] }, { name: "integrations", roots: ["claude", "integration", "commandcode", "grok", "codex-shim"] }, - { name: "observe-system", roots: ["companion", "usage", "logs", "storage", "inspect", "system", "observe", "debug", "export", "import", "cost", "update", "config", "tray"] }, + { name: "observe-system", roots: ["companion", "usage", "logs", "storage", "inspect", "system", "observe", "debug", "export", "import", "cost", "update", "config", "tray", "telemetry"] }, { name: "access-remote", roots: ["link", "remote-workspace", "hub", "connect", "disconnect", "api", "access"] }, { name: "lab", roots: ["lab"] }, ]; diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index d49db6cfa0b..ab478e6c196 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -30,7 +30,7 @@ These answer in the CLI head and never reach the proxy, so they work with nothin | [accounts](01_surface_accounts.md) | 43 | | [agents-routing](01_surface_agents-routing.md) | 68 | | [integrations](01_surface_integrations.md) | 41 | -| [observe-system](01_surface_observe-system.md) | 99 | +| [observe-system](01_surface_observe-system.md) | 102 | | [access-remote](01_surface_access-remote.md) | 34 | | [lab](01_surface_lab.md) | 21 | @@ -1371,6 +1371,18 @@ Original invocation order. These headings preserve links to the previous single- [Read-oriented task](01_surface_observe-system.md#ocx-companion-timeline) +### `ocx telemetry status` + +[Read-oriented task](01_surface_observe-system.md#ocx-telemetry-status) + +### `ocx telemetry enable` + +[State-changing task](01_surface_observe-system.md#ocx-telemetry-enable) + +### `ocx telemetry disable` + +[State-changing task](01_surface_observe-system.md#ocx-telemetry-disable) + ### `ocx disconnect` [State-changing task](01_surface_access-remote.md#ocx-disconnect) @@ -1529,6 +1541,6 @@ Original invocation order. These headings preserve links to the previous single- ## Counts -- declared capabilities: 370 -- of those, state-changing: 228 +- declared capabilities: 373 +- of those, state-changing: 230 - head-resolved invocations: 2 diff --git a/skills/ocx/references/01_surface_observe-system.md b/skills/ocx/references/01_surface_observe-system.md index 363e5d2b026..2e132e543d2 100644 --- a/skills/ocx/references/01_surface_observe-system.md +++ b/skills/ocx/references/01_surface_observe-system.md @@ -8,7 +8,7 @@ Use these declarations to choose a task, then check its flags and authority before execution. Non-mutating probes may still contact providers, consume quota or refresh caches. -Declared capabilities: 99. +Declared capabilities: 102. ### `ocx update` @@ -1871,3 +1871,56 @@ State-changing: no. JSON mode: `payload`. - start/end are epoch seconds. The end must match the request-time bucket or its immediate successor after an observed rollover; stale windows are refused. Empty series still includes metadata; missingMeasurements and truncated remain visible instead of implying measured complete zero traffic. - Uses existing query validation and checks applied model/provider-exclusion scope. It does not change companion settings or request inference. + +### `ocx telemetry status` + +Usage: `ocx telemetry status [--json]` + +Inspect anonymous telemetry consent status and the next daily payload preview. + +State-changing: no. JSON mode: `payload`. + +| Method | Route | +|---|---| +| GET | `/api/telemetry` | + +| Flag | Value | Meaning | +|---|---|---| +| `--json` | boolean | Emit the telemetry status and preview as JSON. | + +- Reads live status from the running proxy and falls back to local configuration and ledger preview when the proxy is stopped. +- Never creates or transmits a telemetry install identifier. + +### `ocx telemetry enable` + +Usage: `ocx telemetry enable [--json]` + +Opt in to anonymous daily usage telemetry. + +State-changing: yes. JSON mode: `envelope`. + +Drives no management route. + +| Flag | Value | Meaning | +|---|---|---| +| `--json` | boolean | Emit the updated telemetry status as JSON. | + +- Writes telemetry.decision = accepted and telemetry.noticeShownAt in local config.json. +- Refuses with agent_consent_required when called from an agent-driven session. + +### `ocx telemetry disable` + +Usage: `ocx telemetry disable [--json]` + +Opt out of anonymous daily usage telemetry and delete the local telemetry identifier. + +State-changing: yes. JSON mode: `envelope`. + +Drives no management route. + +| Flag | Value | Meaning | +|---|---|---| +| `--json` | boolean | Emit the updated telemetry status as JSON. | + +- Writes telemetry.decision = declined in local config.json and removes telemetry-id.json. +- Always permitted, including in agent-driven sessions. diff --git a/src/cli/capabilities-observe-system.ts b/src/cli/capabilities-observe-system.ts index 9f413835d54..4692f318666 100644 --- a/src/cli/capabilities-observe-system.ts +++ b/src/cli/capabilities-observe-system.ts @@ -729,4 +729,34 @@ export const OBSERVE_SYSTEM_CAPABILITIES: readonly Capability[] = [ json: "payload", details: ["start/end are epoch seconds. The end must match the request-time bucket or its immediate successor after an observed rollover; stale windows are refused. Empty series still includes metadata; missingMeasurements and truncated remain visible instead of implying measured complete zero traffic.","Uses existing query validation and checks applied model/provider-exclusion scope. It does not change companion settings or request inference."], }, + { + command: ["telemetry", "status"], + usage: "ocx telemetry status [--json]", + summary: "Inspect anonymous telemetry consent status and the next daily payload preview.", + routes: [{"method": "GET", "path": "/api/telemetry"}], + flags: [{"name": "--json", "value": "boolean", "summary": "Emit the telemetry status and preview as JSON."}], + mutates: false, + json: "payload", + details: ["Reads live status from the running proxy and falls back to local configuration and ledger preview when the proxy is stopped.", "Never creates or transmits a telemetry install identifier."], + }, + { + command: ["telemetry", "enable"], + usage: "ocx telemetry enable [--json]", + summary: "Opt in to anonymous daily usage telemetry.", + routes: [], + flags: [{"name": "--json", "value": "boolean", "summary": "Emit the updated telemetry status as JSON."}], + mutates: true, + json: "envelope", + details: ["Writes telemetry.decision = accepted and telemetry.noticeShownAt in local config.json.", "Refuses with agent_consent_required when called from an agent-driven session."], + }, + { + command: ["telemetry", "disable"], + usage: "ocx telemetry disable [--json]", + summary: "Opt out of anonymous daily usage telemetry and delete the local telemetry identifier.", + routes: [], + flags: [{"name": "--json", "value": "boolean", "summary": "Emit the updated telemetry status as JSON."}], + mutates: true, + json: "envelope", + details: ["Writes telemetry.decision = declined in local config.json and removes telemetry-id.json.", "Always permitted, including in agent-driven sessions."], + }, ]; diff --git a/src/cli/config-command.ts b/src/cli/config-command.ts index 8cd643a8d8e..1a03189d27b 100644 --- a/src/cli/config-command.ts +++ b/src/cli/config-command.ts @@ -9,6 +9,7 @@ import type { OcxConfig } from "../types"; import { normalizeVisionReasoningForModel } from "../vision/reasoning"; import type { ServiceApiTokenState } from "../lib/service-secrets"; import { redactUrlForLog } from "../lib/redact"; +import { isAgentDriven } from "./agent-driven"; import { CliUsageError, printData, rejectArgs, runCliAction, takeFlag } from "./runtime-api"; const USAGE = `Usage: @@ -258,6 +259,9 @@ export async function handleConfigCommand(argv: string[]): Promise { const raw = action === "set" ? args.shift() : undefined; if (!path || (action === "set" && raw === undefined)) throw new CliUsageError(action === "unset" ? "config path is required" : "config path and value are required", USAGE); rejectArgs(args, USAGE); + if (pathSegments(path)[0] === "telemetry") { + throw new CliUsageError("telemetry cannot be modified via ocx config; use `ocx telemetry enable` or `ocx telemetry disable`", USAGE); + } // #1835/#1838: the read used to happen OUTSIDE the mutation lock, so a concurrent // edit landing between it and the save was reverted by this whole-snapshot write. // `mutatePersistedConfig` reruns this callback against the latest validated disk @@ -332,7 +336,12 @@ export async function handleConfigCommand(argv: string[]): Promise { rejectArgs(args, USAGE); await withConfigDirHardeningSettled(getConfigDir(), () => { readCliConfigForMutation(); - const imported = validate(loadInput(path)); + const rawInput = loadInput(path); + if (isAgentDriven() && rawInput && typeof rawInput === "object" && !Array.isArray(rawInput) && Object.hasOwn(rawInput, "telemetry")) { + delete (rawInput as Record).telemetry; + console.warn("Note: dropped telemetry block during agent-driven config import; use `ocx telemetry` to manage telemetry consent."); + } + const imported = validate(rawInput); withConfigMutationLockSync(() => { const client = readClientConnectionState(); if (client.kind === "connected" && (imported.runtimeRole !== "client" diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 1a9fd889871..6cbe047f2a1 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -913,6 +913,10 @@ const commandRunners: Record = { const { handleCommandcodeCommand } = await import("./integrations"); return await handleCommandcodeCommand(deps.args.slice(1)); }, + telemetry: async deps => { + const { handleTelemetryCommand } = await import("./telemetry"); + return await handleTelemetryCommand(deps.args.slice(1)); + }, help: async () => { printUsage(); return 0; diff --git a/src/cli/help.ts b/src/cli/help.ts index 67780c76753..ffa5a3b711f 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -102,6 +102,7 @@ Usage: ocx config [sub] Validated configuration show/get/set/import/export ocx advisor Advisor sidecar settings and context-sharing consent ocx companion Menu-bar and widget companion usage settings + ocx telemetry Anonymous usage telemetry status, preview, enable, and disable ocx lab Inspect Lab evidence and control local automation ocx codex-provider Confirm custom Codex provider routing: status|allow|revoke ocx chatgpt Experimental app-server shim: launch|restore|status (macOS) diff --git a/src/cli/init.ts b/src/cli/init.ts index b8866eccb3e..9bb41103e09 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -9,6 +9,7 @@ import { redactUserPath } from "../lib/redact"; import { enrichProviderFromCatalog } from "../oauth/key-providers"; import { deriveInitProviders } from "../providers/derive"; import { pinSponsorsWithinKind } from "../providers/sponsor-order"; +import { maybeShowTelemetryPrompt } from "./telemetry"; import type { OcxConfig, OcxProviderConfig } from "../types"; /** Parse a setup TCP port; only an empty answer selects the default. */ @@ -293,4 +294,7 @@ export async function runInit(): Promise { } finally { prompt.close(); } + if (configCreated && !process.exitCode) { + await maybeShowTelemetryPrompt(); + } } diff --git a/src/cli/registry.ts b/src/cli/registry.ts index a9f48283878..749de00e31e 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -834,6 +834,16 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ usage: "ocx __gui-update-worker [channel] [restart]", summary: "Hidden internal: run the GUI update worker.", }, + { + name: "telemetry", + usage: "ocx telemetry [--json]", + summary: "Inspect or change anonymous daily usage telemetry consent and preview the payload.", + details: [ + "status [--json] Show effective state, reason, install channel, last sent day, and exact preview payload.", + "enable [--json] Opt in to anonymous telemetry (refused with agent_consent_required when agent-driven).", + "disable [--json] Opt out of anonymous telemetry and delete telemetry-id.json.", + ], + }, ]; export function findCommand(name: string): CliCommandEntry | undefined { diff --git a/src/cli/star-prompt.ts b/src/cli/star-prompt.ts index 3135e86bc6b..46131ec4124 100644 --- a/src/cli/star-prompt.ts +++ b/src/cli/star-prompt.ts @@ -177,6 +177,8 @@ function printAgentDeferral(): void { */ export async function maybeShowStarPrompt(): Promise { try { + const { maybeShowTelemetryPrompt } = await import("./telemetry"); + await maybeShowTelemetryPrompt(); let isTty = false; try { isTty = depsForTests?.isTty ? depsForTests.isTty() : isatty(0) && isatty(1); diff --git a/src/cli/telemetry.ts b/src/cli/telemetry.ts new file mode 100644 index 00000000000..60ffd2f652d --- /dev/null +++ b/src/cli/telemetry.ts @@ -0,0 +1,188 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { isatty } from "node:tty"; +import { getConfigDir, mutatePersistedConfig, readConfigDiagnostics } from "../config"; +import { withConfigDirHardeningSettled } from "../config/paths"; +import { recordOwnedConfigPath } from "../lib/config-ownership"; +import { resolveTelemetryStatus } from "../telemetry/consent"; +import { deleteTelemetryInstallId } from "../telemetry/install-id"; +import { buildTelemetryStatusSnapshot, type TelemetryStatusSnapshot } from "../telemetry/routes"; +import type { OcxTelemetryConfig, OcxTelemetryDecision } from "../types"; +import { currentVersion } from "../update/index"; +import { isAgentDriven } from "./agent-driven"; +import { interactiveConfirm } from "./interactive-confirm"; +import { + CliUsageError, + printData, + rejectArgs, + runCliAction, + runtimeRequest, + takeFlag, + type RuntimeApiDeps, +} from "./runtime-api"; + +const USAGE = `Usage: + ocx telemetry [status] [--json] + ocx telemetry enable [--json] + ocx telemetry disable [--json]`; + +export const TELEMETRY_DEFERRAL_FILE = ".telemetry-deferred"; +const DEFERRAL_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; + +export function isTelemetryDeferralCurrent(record: string | null, version: string, now: number): boolean { + if (!record) return false; + const match = /^(\S+)\s+(\S+)\s*$/.exec(record.trim()); + if (!match) return false; + const at = Date.parse(match[1]!); + if (Number.isNaN(at)) return false; + const age = now - at; + if (version !== "?" && match[2] === version) return age >= 0; + return age >= 0 && age < DEFERRAL_MAX_AGE_MS; +} + +export interface TelemetryPromptTestDeps { + interactiveConfirm?: typeof interactiveConfirm; + isTty?: () => boolean; +} + +let promptDepsForTests: TelemetryPromptTestDeps | null = null; + +export function setTelemetryPromptDepsForTests(deps: TelemetryPromptTestDeps | null): void { + promptDepsForTests = deps; +} + +async function persistTelemetryDecision(decision: OcxTelemetryDecision): Promise { + const nowIso = new Date().toISOString(); + const dir = getConfigDir(); + await withConfigDirHardeningSettled(dir, () => + mutatePersistedConfig(fresh => { + const prev = fresh.telemetry; + const next: OcxTelemetryConfig = { + decision, + decidedAt: nowIso, + noticeShownAt: prev?.noticeShownAt ?? nowIso, + }; + const changed = JSON.stringify(prev) !== JSON.stringify(next); + fresh.telemetry = next; + return { changed, value: next }; + }), + ); + if (decision === "declined") { + deleteTelemetryInstallId(dir); + } +} + +/** + * One-time telemetry consent prompt for interactive starts (`ocx start`, `ocx service install`, + * `ocx init`). Under an agent-driven session, non-TTY, or `OCX_SERVICE=1`, prints a one-line + * notice at most once per version and records neither `noticeShownAt` nor `decision`. + */ +export async function maybeShowTelemetryPrompt(): Promise { + try { + const diagnostics = readConfigDiagnostics({ passive: true }); + const status = resolveTelemetryStatus(diagnostics.config, process.env); + if (status.envOverride !== null || status.decision !== null || status.noticeShownAt !== null) { + return; + } + let isTty = false; + try { + isTty = promptDepsForTests?.isTty ? promptDepsForTests.isTty() : isatty(0) && isatty(1); + } catch { + /* best-effort */ + } + const dir = getConfigDir(); + if (isAgentDriven() || !isTty || Boolean(process.env.OCX_SERVICE)) { + const deferralPath = join(dir, TELEMETRY_DEFERRAL_FILE); + let record: string | null = null; + try { + if (existsSync(deferralPath)) record = readFileSync(deferralPath, "utf8"); + } catch { /* best-effort */ } + const version = currentVersion(); + if (isTelemetryDeferralCurrent(record, version, Date.now())) return; + console.log( + " ℹ️ Anonymous telemetry is awaiting human consent (run 'ocx telemetry status' or open the dashboard; disable anytime with 'ocx telemetry disable' or OCX_TELEMETRY=0).", + ); + try { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + recordOwnedConfigPath(dir, deferralPath); + writeFileSync(deferralPath, `${new Date().toISOString()} ${version}`, { encoding: "utf8", mode: 0o600 }); + } catch { /* best-effort */ } + return; + } + const ask = promptDepsForTests?.interactiveConfirm ?? interactiveConfirm; + const yes = await ask({ + question: "\n Share anonymous usage statistics? [Y/n]", + defaultYes: true, + }); + await persistTelemetryDecision(yes ? "accepted" : "declined"); + } catch { + /* never disrupt startup or setup */ + } +} + +function formatStatusLines(snapshot: TelemetryStatusSnapshot): string[] { + return [ + `Telemetry: ${snapshot.effective ? "enabled" : "disabled"} (${snapshot.reason})`, + `Decision: ${snapshot.decision ?? "undecided"}`, + ...(snapshot.envOverride ? [`Environment override: ${snapshot.envOverride}`] : []), + `Install channel: ${snapshot.installChannel}`, + `Last sent day: ${snapshot.lastSentDay ?? "never"}`, + `Preview (${snapshot.preview.day}):`, + JSON.stringify(snapshot.preview, null, 2), + ]; +} + +async function readLiveOrLocalSnapshot(deps: RuntimeApiDeps): Promise { + try { + return await runtimeRequest("/api/telemetry", {}, deps) as TelemetryStatusSnapshot; + } catch { + const config = readConfigDiagnostics({ passive: true }).config; + return await buildTelemetryStatusSnapshot(config); + } +} + +export async function handleTelemetryCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise { + const args = [...argv]; + const wantsJson = takeFlag(args, "--json"); + const sub = (args.shift() ?? "status").toLowerCase(); + if (sub === "enable" && isAgentDriven()) { + const error = { + ok: false, + code: "agent_consent_required", + error: "Refused: telemetry consent must be enabled by a human user in their own terminal or dashboard.", + }; + if (wantsJson) console.log(JSON.stringify(error, null, 2)); + else console.error(`${error.code}: ${error.error}`); + return 1; + } + return runCliAction(async () => { + if (args.includes("--json")) { + throw new CliUsageError("telemetry flags may only be specified once", USAGE); + } + if (sub === "status") { + rejectArgs(args, USAGE); + const snapshot = await readLiveOrLocalSnapshot(deps); + printData(snapshot, wantsJson, formatStatusLines(snapshot)); + return; + } + if (sub === "enable") { + rejectArgs(args, USAGE); + await persistTelemetryDecision("accepted"); + const snapshot = await buildTelemetryStatusSnapshot(readConfigDiagnostics({ passive: true }).config); + printData({ ok: true, ...snapshot }, wantsJson, ["Anonymous telemetry enabled."]); + return; + } + if (sub === "disable") { + rejectArgs(args, USAGE); + await persistTelemetryDecision("declined"); + const snapshot = await buildTelemetryStatusSnapshot(readConfigDiagnostics({ passive: true }).config); + printData({ ok: true, ...snapshot }, wantsJson, [ + "Anonymous telemetry disabled and local telemetry identifier removed.", + ]); + return; + } + throw new CliUsageError(`unknown telemetry command ${sub}`, USAGE); + }); +} + +export const TELEMETRY_USAGE = USAGE; diff --git a/src/server/management-api.ts b/src/server/management-api.ts index 58cb7d08c7a..5568ecaa2c2 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -238,6 +238,12 @@ async function handleLinkRoutesOnDemand(ctx: ManagementContext): Promise { + if (!pathInManagementNamespace(ctx.url.pathname, "/api/telemetry")) return null; + const { handleTelemetryRoutes } = await import("./management/telemetry-routes"); + return handleTelemetryRoutes(ctx); +} + export async function handleManagementAPI( req: Request, url: URL, @@ -393,6 +399,7 @@ export async function handleManagementAPI( ?? (await handleComboRoutes(ctx)) ?? (await handleSystemRoutes(ctx)) ?? (await handleLabRoutesOnDemand(ctx)) + ?? (await handleTelemetryRoutesOnDemand(ctx)) ?? (await handleUsageTimelineRoutes(ctx)) ?? (await handleCompanionRoutes(ctx)) ?? (await handleSidebarRoutes(ctx)); diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index 6655a587d94..119c45f0156 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -451,6 +451,10 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "GET", path: "/api/system/memory", module: "server/management/system-routes", mutates: false }, { method: "GET", path: "/api/system/windows-replace-retries", module: "server/management/system-routes", mutates: false }, { method: "POST", path: "/api/system/restart", module: "server/management/system-routes", mutates: true }, + // server/management/telemetry-routes + { method: "GET", path: "/api/telemetry", module: "server/management/telemetry-routes", mutates: false }, + { method: "PUT", path: "/api/telemetry", module: "server/management/telemetry-routes", mutates: true, exempt: { reason: "session-only", why: "User-consent boundary in AGENTS_INSTALL.md: changing telemetry consent over HTTP requires a human dashboard session; CLI callers use ocx telemetry enable/disable." } }, + { method: "POST", path: "/api/telemetry/notice", module: "server/management/telemetry-routes", mutates: true, exempt: { reason: "session-only", why: "Recording that the disclosure notice was seen activates default-on telemetry and requires a human dashboard session." } }, // --- Further routes an equality scan of their own file cannot see. --- // Each carries `mechanism`; the reconciliation test counts these separately. { method: "GET", path: "/api/storage", module: "server/management/storage-log-guard-routes", mutates: false, mechanism: "negated-guard" }, diff --git a/src/server/management/telemetry-routes.ts b/src/server/management/telemetry-routes.ts new file mode 100644 index 00000000000..3739c5380b7 --- /dev/null +++ b/src/server/management/telemetry-routes.ts @@ -0,0 +1,20 @@ +import type { ManagementContext } from "./context"; +import { + handleGetTelemetryRoute, + handlePostTelemetryNoticeRoute, + handlePutTelemetryRoute, +} from "../../telemetry/routes"; + +export async function handleTelemetryRoutes(ctx: ManagementContext): Promise { + const { req, url } = ctx; + if (url.pathname === "/api/telemetry" && req.method === "GET") { + return handleGetTelemetryRoute(ctx); + } + if (url.pathname === "/api/telemetry" && req.method === "PUT") { + return handlePutTelemetryRoute(ctx); + } + if (url.pathname === "/api/telemetry/notice" && req.method === "POST") { + return handlePostTelemetryNoticeRoute(ctx); + } + return null; +} diff --git a/src/service/cli.ts b/src/service/cli.ts index 3a1c198055d..4daf72dce17 100644 --- a/src/service/cli.ts +++ b/src/service/cli.ts @@ -362,6 +362,7 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise | null | undefined, + options: { now?: number; env?: Record; configDir?: string } = {}, +): Promise { + const now = options.now ?? Date.now(); + const env = options.env ?? process.env; + const configDir = options.configDir ?? getConfigDir(); + const status = resolveTelemetryStatus(config, env); + const installChannel = resolveInstallChannel({ env }); + const state = readTelemetryState(configDir); + const day = latestCompletedUtcDay(now); + const aggregate = await aggregateTelemetryForDay(day); + const identity = readTelemetryInstallId(configDir); + const installDayHash = identity + ? computeInstallDayHash(identity.installId, day) + : TELEMETRY_PREVIEW_HASH_PLACEHOLDER; + const newInstall = identity + ? state.firstReportSentForInstallId !== identity.installId + : true; + const preview = buildTelemetryPayloadV1({ + aggregate, + installDayHash, + newInstall, + installChannel, + now, + }); + return { + ...status, + installChannel, + lastSentDay: state.lastSentDay, + lastSentAt: state.lastSentAt, + preview, + }; +} + +function consentRefusedResponse(ctx: ManagementContext): Response { + return jsonResponse( + { + ok: false, + code: "agent_consent_required", + error: "Telemetry consent requires a human dashboard session.", + message: + "Refused: this request carries no human dashboard session. Telemetry consent must be chosen by the user in the dashboard or an interactive terminal.", + }, + 403, + ctx.req, + ctx.config, + ); +} + +export async function handleGetTelemetryRoute(ctx: ManagementContext): Promise { + const snapshot = await buildTelemetryStatusSnapshot(ctx.config); + return jsonResponse(snapshot, 200, ctx.req, ctx.config); +} + +export async function handlePutTelemetryRoute(ctx: ManagementContext): Promise { + if (!hasHumanDashboardSession(ctx)) { + return consentRefusedResponse(ctx); + } + let body: unknown; + try { + body = await ctx.req.json(); + } catch { + return jsonResponse({ error: "invalid JSON body", code: "invalid_json" }, 400, ctx.req, ctx.config); + } + const rawDecision = body && typeof body === "object" && !Array.isArray(body) + ? (body as { decision?: unknown }).decision + : undefined; + if (rawDecision !== "accepted" && rawDecision !== "declined") { + return jsonResponse( + { error: "decision must be \"accepted\" or \"declined\"", code: "invalid_decision" }, + 400, + ctx.req, + ctx.config, + ); + } + const decision: OcxTelemetryDecision = rawDecision; + const nowIso = new Date().toISOString(); + let committed: OcxTelemetryConfig | undefined; + const outcome = await withConfigDirHardeningSettled(getConfigDir(), () => + mutatePersistedConfig(fresh => { + const prev = fresh.telemetry; + const next: OcxTelemetryConfig = { + decision, + decidedAt: nowIso, + noticeShownAt: prev?.noticeShownAt ?? nowIso, + }; + const changed = JSON.stringify(prev) !== JSON.stringify(next); + fresh.telemetry = next; + committed = structuredClone(next); + return { changed, value: next }; + }), + ); + if (outcome.status === "unavailable") { + return jsonResponse( + { error: `Configuration is ${outcome.reason}`, code: "config_unavailable" }, + 503, + ctx.req, + ctx.config, + ); + } + adoptPersistedTelemetry(ctx.config, committed); + if (decision === "declined") { + deleteTelemetryInstallId(); + } + nudgeTelemetryScheduler(); + const snapshot = await buildTelemetryStatusSnapshot(ctx.config); + return jsonResponse({ ok: true, ...snapshot }, 200, ctx.req, ctx.config); +} + +export async function handlePostTelemetryNoticeRoute(ctx: ManagementContext): Promise { + if (!hasHumanDashboardSession(ctx)) { + return consentRefusedResponse(ctx); + } + const nowIso = new Date().toISOString(); + let committed: OcxTelemetryConfig | undefined; + const outcome = await withConfigDirHardeningSettled(getConfigDir(), () => + mutatePersistedConfig(fresh => { + const prev = fresh.telemetry; + if (prev?.noticeShownAt && !Number.isNaN(Date.parse(prev.noticeShownAt))) { + committed = structuredClone(prev); + return { changed: false, value: prev }; + } + const next: OcxTelemetryConfig = { + ...(prev?.decision ? { decision: prev.decision } : {}), + ...(prev?.decidedAt ? { decidedAt: prev.decidedAt } : {}), + noticeShownAt: nowIso, + }; + fresh.telemetry = next; + committed = structuredClone(next); + return { changed: true, value: next }; + }), + ); + if (outcome.status === "unavailable") { + return jsonResponse( + { error: `Configuration is ${outcome.reason}`, code: "config_unavailable" }, + 503, + ctx.req, + ctx.config, + ); + } + adoptPersistedTelemetry(ctx.config, committed); + nudgeTelemetryScheduler(); + const snapshot = await buildTelemetryStatusSnapshot(ctx.config); + return jsonResponse({ ok: true, ...snapshot }, 200, ctx.req, ctx.config); +} From c47ff3de3714352a512f452829afd3f564cd822c Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 7 Nov 2026 23:30:56 +0900 Subject: [PATCH 04/37] test(telemetry): add client telemetry test suite and structure ownership --- scripts/test-layout/layout-usage.json | 6 +- structure/INDEX.md | 5 + structure/manifest.json | 12 + structure/telemetry.md | 54 ++ .../ci-workflows/skill-ocx-generated.test.ts | 2 +- tests/cli/cli-telemetry.test.ts | 200 ++++++++ tests/fixtures/test-layout-expected.json | 3 +- tests/server/server-telemetry-routes.test.ts | 177 +++++++ tests/usage/usage-telemetry-client.test.ts | 468 ++++++++++++++++++ .../usage-telemetry-core-boundary.test.ts | 62 +++ 10 files changed, 986 insertions(+), 3 deletions(-) create mode 100644 structure/telemetry.md create mode 100644 tests/cli/cli-telemetry.test.ts create mode 100644 tests/server/server-telemetry-routes.test.ts create mode 100644 tests/usage/usage-telemetry-client.test.ts create mode 100644 tests/usage/usage-telemetry-core-boundary.test.ts diff --git a/scripts/test-layout/layout-usage.json b/scripts/test-layout/layout-usage.json index 0aa98678b73..29b328c527a 100644 --- a/scripts/test-layout/layout-usage.json +++ b/scripts/test-layout/layout-usage.json @@ -10,5 +10,9 @@ "request-log-identity-bounds.test.ts": "usage", "safe-response-headers.test.ts": "server", "turn-state-handles.test.ts": "server", - "turn-state-handles-e2e.test.ts": "server" + "turn-state-handles-e2e.test.ts": "server", + "usage-telemetry-client.test.ts": "usage", + "usage-telemetry-core-boundary.test.ts": "usage", + "server-telemetry-routes.test.ts": "server", + "cli-telemetry.test.ts": "cli" } diff --git a/structure/INDEX.md b/structure/INDEX.md index bab3fd26a1b..c2c508ce8a8 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -147,6 +147,7 @@ The dashboard, the management API, and third-party client config ownership. | [`clients/t3code.md`](clients/t3code.md) | T3 Code settings.json Claude provider-instance ownership, proxy environment, custom models and loopback marker rules. | | [`companion.md`](companion.md) | Shared timeline filtering, usage/quotas, native and web tray title, and WidgetKit display contracts. | | [`codex-account-controls.md`](codex-account-controls.md) | Account selection order, custom usage thresholds, and stable account-card editing. | +| [`telemetry.md`](telemetry.md) | Anonymous daily usage telemetry contract, consent state, install identity, ledger aggregation, scheduler, management API, and CLI. | ### Tier 6 — Operations and process @@ -256,6 +257,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/cli/models-runtime.ts` | [`pricing.md`](pricing.md) | | `src/cli/status-codex-settings.ts` | [`remote-link.md`](remote-link.md) | | `src/cli/status-link.ts` | [`remote-link.md`](remote-link.md) | +| `src/cli/telemetry.ts` | [`telemetry.md`](telemetry.md) | | `src/cli/update-restart.ts` | [`ops/desktop-supervision.md`](ops/desktop-supervision.md) | | `src/client/` | [`runtime.md`](runtime.md)
[`fallback-policy.md`](fallback-policy.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md) | | `src/client/hub-client.ts` | [`usage-client-attribution.md`](usage-client-attribution.md) | @@ -448,6 +450,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/server/management/logs-usage-routes.ts` | [`usage-client-attribution.md`](usage-client-attribution.md) | | `src/server/management/model-routes.ts` | [`pricing.md`](pricing.md) | | `src/server/management/quota-pool-routes.ts` | [`quota-pool.md`](quota-pool.md) | +| `src/server/management/telemetry-routes.ts` | [`telemetry.md`](telemetry.md) | | `src/server/management/usage-aggregate-cache.ts` | [`usage-client-attribution.md`](usage-client-attribution.md) | | `src/server/messages-native-tool-guard.ts` | [`data-planes/error-detail.md`](data-planes/error-detail.md) | | `src/server/messages-native.ts` | [`data-planes/error-detail.md`](data-planes/error-detail.md) | @@ -507,6 +510,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/sidecar/anthropic-binding.ts` | [`providers/anthropic-account-pool.md`](providers/anthropic-account-pool.md) | | `src/stall-timeout.ts` | [`transports/timeouts.md`](transports/timeouts.md)
[`runtime.md`](runtime.md) | | `src/storage/` | [`runtime.md`](runtime.md) | +| `src/telemetry/` | [`telemetry.md`](telemetry.md) | | `src/tray/` | [`runtime.md`](runtime.md) | | `src/types.ts` | [`runtime.md`](runtime.md)
[`config.md`](config.md) | | `src/types/` | [`providers/generic-account-pool.md`](providers/generic-account-pool.md)
[`transports/native-reasoning-retention.md`](transports/native-reasoning-retention.md)
[`providers/anthropic-progress.md`](providers/anthropic-progress.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/lm-studio-chat.md`](providers/lm-studio-chat.md)
[`fallback-policy.md`](fallback-policy.md) | @@ -514,6 +518,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/types/admission-policy.ts` | [`advisor.md`](advisor.md) | | `src/types/config.ts` | [`transports/timeouts.md`](transports/timeouts.md)
[`config.md`](config.md)
[`transports/max-output-completion.md`](transports/max-output-completion.md)
[`clients/omo-export-dialect.md`](clients/omo-export-dialect.md) | | `src/types/provider.ts` | [`pricing.md`](pricing.md) | +| `src/types/telemetry.ts` | [`telemetry.md`](telemetry.md) | | `src/update/` | [`runtime.md`](runtime.md)
[`ops/service-and-sidecars.md`](ops/service-and-sidecars.md) | | `src/update/guarded-port-reclaim.ts` | [`ops/update-supervised-recovery.md`](ops/update-supervised-recovery.md) | | `src/update/guarded-runtime-ops.ts` | [`ops/update-supervised-recovery.md`](ops/update-supervised-recovery.md) | diff --git a/structure/manifest.json b/structure/manifest.json index b6da2b17b1a..9252acff98c 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -1426,6 +1426,18 @@ "src/oauth/api-key-pool-preserve.ts", "gui/src/account-pause-display.ts" ] + }, + { + "path": "telemetry.md", + "tier": 5, + "title": "Telemetry", + "scope": "Anonymous daily usage telemetry contract, consent state, install identity, ledger aggregation, scheduler, management API, and CLI.", + "documents": [ + "src/telemetry/", + "src/types/telemetry.ts", + "src/server/management/telemetry-routes.ts", + "src/cli/telemetry.ts" + ] } ], "contracts": { diff --git a/structure/telemetry.md b/structure/telemetry.md new file mode 100644 index 00000000000..6adefcb16db --- /dev/null +++ b/structure/telemetry.md @@ -0,0 +1,54 @@ +# Telemetry + +Anonymous, default-on daily usage telemetry owned by `src/telemetry/`, `src/types/telemetry.ts`, +`src/server/management/telemetry-routes.ts`, and `src/cli/telemetry.ts`. + +## Wire contract and vocabularies + +`src/telemetry/contract.ts` is an import-free module defining `TELEMETRY_SCHEMA_VERSION = 1`, +the closed rosters (`PROVIDER_KINDS`, `MODEL_FAMILIES`, `INSTALL_CHANNELS`, `RELEASE_CHANNELS`, +`OS_VALUES`, `ARCH_VALUES`, `REQUEST_OUTCOME_CLASSES`, `REQUEST_FAILURE_CAUSES`), and +`validateTelemetryPayloadV1`. Every level of the payload rejects unknown keys, non-roster strings, +out-of-window or non-calendar `day` values, semver build metadata, and more than 64 model rows. + +## Consent and agent boundary + +`src/types/telemetry.ts` defines `OcxTelemetryConfig` (`decision`, `decidedAt`, `noticeShownAt`), +validated in `src/config/schema/leaf-validators.ts`, `src/config/schema/config-schema.ts`, +`src/config/diagnostics.ts`, `src/config/load-degrade.ts`, and adopted into live server state +via `adoptPersistedTelemetry` in `src/config/live-reconcile.ts`. + +`src/telemetry/consent.ts` (`resolveTelemetryStatus`) evaluates precedence in order: +`OCX_TELEMETRY` (`0`, `false`, `off`, `no`), `DO_NOT_TRACK` (`1`, `true`, `yes`), +`decision === "declined"`, `decision === "accepted"`, undecided with `noticeShownAt` +(`default-on`), and undecided without notice (`awaiting-notice`). + +Only a human can transition an undecided install to active sending: + +- `PUT /api/telemetry` and `POST /api/telemetry/notice` in `src/telemetry/routes.ts` (delegated + from `src/server/management/telemetry-routes.ts` and lazily mounted in `src/server/management-api.ts`) + require `hasHumanDashboardSession` from `src/server/management/consent-session.ts`. +- `ocx telemetry enable` in `src/cli/telemetry.ts` and `ocx config set telemetry` in + `src/cli/config-command.ts` refuse agent-driven callers; `ocx telemetry disable` is always + permitted. +- Interactive prompts run from `src/cli/star-prompt.ts`, `src/service/cli.ts`, and + `src/cli/init.ts`; agent-driven, non-TTY, or service starts print a once-per-version notice + and record neither `noticeShownAt` nor `decision`. + +## Identity, aggregation, and scheduling + +`src/telemetry/install-id.ts` manages `/telemetry-id.json` (`0o600`, exclusive +no-follow creation) on the first send and deletes it when telemetry is declined. Only +`sha256(installId + ":" + day)` leaves the machine. + +`src/telemetry/aggregate.ts` scans `usage.jsonl` through `src/usage/ledger-scanner.ts` for one +completed UTC day, mapping providers to `PROVIDER_KINDS`, models to `MODEL_FAMILIES`, outcomes +via `src/usage/request-outcome.ts`, and decomposing usage into five disjoint token buckets +(`inputTokens`, `cacheReadTokens`, `cacheWriteTokens`, `outputTokens`, `reasoningTokens`) whose +sum matches `usageDisplayTotalTokens` in `src/usage/totals.ts`. + +`src/telemetry/payload.ts` resolves the install channel and validates the built payload with +`validateTelemetryPayloadV1`. `src/telemetry/sender.ts` posts to the constant collector URL +with a 10-second `signalWithTimeout`. `src/telemetry/scheduler.ts` runs an unref'd background +timer started lazily from `src/server/background-lifecycle.ts`, re-reading config from disk on +each tick and sending at most one completed post-consent day per tick. diff --git a/tests/ci-workflows/skill-ocx-generated.test.ts b/tests/ci-workflows/skill-ocx-generated.test.ts index bfa715af2e0..a82ecb33381 100644 --- a/tests/ci-workflows/skill-ocx-generated.test.ts +++ b/tests/ci-workflows/skill-ocx-generated.test.ts @@ -14,7 +14,7 @@ const GROUPS: Record = { lifecycle: ["codex-provider", "chatgpt", "status", "resolve", "capabilities", "sync", "start", "stop", "restart", "service", "gui"], "providers-models": ["provider", "models", "alias"], accounts: ["account", "auth", "login", "logout"], "agents-routing": ["agent", "combo", "route", "fallback", "v2", "effort", "memory", "message", "advisor"], integrations: ["claude", "integration", "commandcode", "grok", "codex-shim"], - "observe-system": ["companion", "usage", "logs", "storage", "inspect", "system", "observe", "debug", "export", "import", "cost", "update", "config", "tray"], + "observe-system": ["companion", "usage", "logs", "storage", "inspect", "system", "observe", "debug", "export", "import", "cost", "update", "config", "tray", "telemetry"], "access-remote": ["link", "remote-workspace", "hub", "connect", "disconnect", "api", "access"], lab: ["lab"], }; const FILENAMES = [INDEX, ...Object.keys(GROUPS).map(domain => `01_surface_${domain}.md`)]; diff --git a/tests/cli/cli-telemetry.test.ts b/tests/cli/cli-telemetry.test.ts new file mode 100644 index 00000000000..ece7f41245a --- /dev/null +++ b/tests/cli/cli-telemetry.test.ts @@ -0,0 +1,200 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleConfigCommand } from "../../src/cli/config-command"; +import { + TELEMETRY_DEFERRAL_FILE, + handleTelemetryCommand, + maybeShowTelemetryPrompt, + setTelemetryPromptDepsForTests, +} from "../../src/cli/telemetry"; +import { readConfigDiagnostics } from "../../src/config/diagnostics"; +import { + ensureTelemetryInstallId, + resetTelemetryInstallIdMemoForTests, + telemetryIdPath, +} from "../../src/telemetry/install-id"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const AGENT_ENV_VARS = [ + "CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "CLAUDE_CODE_SSE_PORT", + "CODEX_THREAD_ID", "CODEX_SHELL", "CODEX_CI", "CODEX_SANDBOX", "CODEX_SANDBOX_NETWORK_DISABLED", + "CURSOR_TRACE_ID", "CURSOR_SESSION_TOKEN", "CURSOR_AGENT", + "AIDER_CHAT", "OPENCODE_BIN_PATH", "GEMINI_CLI", + "REPL_ID", "CI", "GITHUB_ACTIONS", "GITLAB_CI", "BUILDKITE", "JENKINS_URL", "TEAMCITY_VERSION", "CODESPACES", +] as const; + +describe("ocx telemetry CLI and interactive prompt", () => { + let home: string; + const prevHome = process.env.OPENCODEX_HOME; + const prevTelemetry = process.env.OCX_TELEMETRY; + const prevDnt = process.env.DO_NOT_TRACK; + const savedAgentEnv = new Map(); + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-cli-telemetry-")); + process.env.OPENCODEX_HOME = home; + delete process.env.OCX_TELEMETRY; + delete process.env.DO_NOT_TRACK; + for (const key of AGENT_ENV_VARS) { + savedAgentEnv.set(key, process.env[key]); + delete process.env[key]; + } + resetTelemetryInstallIdMemoForTests(); + setTelemetryPromptDepsForTests(null); + writeFileSync( + join(home, "config.json"), + JSON.stringify({ + port: 10100, + defaultProvider: "openai", + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + }, + }, null, 2) + "\n", + "utf8", + ); + }); + + afterEach(() => { + setTelemetryPromptDepsForTests(null); + resetTelemetryInstallIdMemoForTests(); + for (const [key, value] of savedAgentEnv) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + if (prevHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = prevHome; + if (prevTelemetry === undefined) delete process.env.OCX_TELEMETRY; + else process.env.OCX_TELEMETRY = prevTelemetry; + if (prevDnt === undefined) delete process.env.DO_NOT_TRACK; + else process.env.DO_NOT_TRACK = prevDnt; + removeTreeWithRetry(home); + }); + + test("status --json falls back to local snapshot when proxy is unreachable", async () => { + const lines: string[] = []; + const log = spyOn(console, "log").mockImplementation((...args: unknown[]) => { + lines.push(args.map(String).join(" ")); + }); + try { + const code = await handleTelemetryCommand(["status", "--json"], { + fetch: (async () => { throw new Error("connect ECONNREFUSED"); }) as unknown as typeof fetch, + }); + expect(code).toBe(0); + const parsed = JSON.parse(lines.join("\n")) as { effective: boolean; reason: string; preview: { schemaVersion: number } }; + expect(parsed.effective).toBe(false); + expect(parsed.reason).toBe("awaiting-notice"); + expect(parsed.preview.schemaVersion).toBe(1); + } finally { + log.mockRestore(); + } + }); + + test("enable and disable mutate config.json; enable refuses when agent-driven while disable succeeds", async () => { + const log = spyOn(console, "log").mockImplementation(() => {}); + const err = spyOn(console, "error").mockImplementation(() => {}); + try { + const enableCode = await handleTelemetryCommand(["enable", "--json"]); + expect(enableCode).toBe(0); + expect(readConfigDiagnostics().config.telemetry?.decision).toBe("accepted"); + + ensureTelemetryInstallId(home); + expect(existsSync(telemetryIdPath(home))).toBe(true); + + // Agent-driven session: + process.env.CODEX_THREAD_ID = "agent-123"; + log.mockClear(); + const refusedCode = await handleTelemetryCommand(["enable", "--json"]); + expect(refusedCode).toBe(1); + expect(log.mock.calls.map(c => String(c[0])).join("\n")).toContain("agent_consent_required"); + + // Disable is always allowed, even when agent-driven: + const disableCode = await handleTelemetryCommand(["disable", "--json"]); + expect(disableCode).toBe(0); + expect(readConfigDiagnostics().config.telemetry?.decision).toBe("declined"); + expect(existsSync(telemetryIdPath(home))).toBe(false); + } finally { + log.mockRestore(); + err.mockRestore(); + } + }); + + test("ocx config set/unset refuse telemetry and import drops telemetry when agent-driven", async () => { + const log = spyOn(console, "log").mockImplementation(() => {}); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + const err = spyOn(console, "error").mockImplementation(() => {}); + try { + expect(await handleConfigCommand(["set", "telemetry.decision", "accepted"])).not.toBe(0); + expect(await handleConfigCommand(["unset", "telemetry"])).not.toBe(0); + + const importFile = join(home, "imported.json"); + writeFileSync( + importFile, + JSON.stringify({ + port: 10100, + defaultProvider: "openai", + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + }, + telemetry: { decision: "accepted", decidedAt: "2026-11-06T00:00:00.000Z" }, + }), + "utf8", + ); + process.env.CODEX_THREAD_ID = "agent-import"; + expect(await handleConfigCommand(["import", importFile, "--yes"])).toBe(0); + expect(readConfigDiagnostics().config.telemetry).toBeUndefined(); + expect(warn.mock.calls.map(c => String(c[0])).join("\n")).toContain("ocx telemetry"); + } finally { + log.mockRestore(); + warn.mockRestore(); + err.mockRestore(); + } + }); + + test("agent-driven start prints notice once per version and writes no noticeShownAt or decision; interactive prompt defaults to yes", async () => { + const log = spyOn(console, "log").mockImplementation(() => {}); + try { + process.env.CODEX_THREAD_ID = "agent-start"; + setTelemetryPromptDepsForTests({ isTty: () => true }); + await maybeShowTelemetryPrompt(); + expect(log.mock.calls.length).toBe(1); + expect(existsSync(join(home, TELEMETRY_DEFERRAL_FILE))).toBe(true); + expect(readConfigDiagnostics().config.telemetry).toBeUndefined(); + + // Second agent-driven run on the same version stays quiet: + log.mockClear(); + await maybeShowTelemetryPrompt(); + expect(log.mock.calls.length).toBe(0); + expect(readConfigDiagnostics().config.telemetry).toBeUndefined(); + + // Interactive human run prompts with defaultYes: true and records accepted: + delete process.env.CODEX_THREAD_ID; + let observedDefaultYes: boolean | undefined; + setTelemetryPromptDepsForTests({ + isTty: () => true, + interactiveConfirm: async opts => { + observedDefaultYes = opts.defaultYes; + return opts.defaultYes; + }, + }); + await maybeShowTelemetryPrompt(); + expect(observedDefaultYes).toBe(true); + const telemetry = readConfigDiagnostics().config.telemetry; + expect(telemetry?.decision).toBe("accepted"); + expect(typeof telemetry?.decidedAt).toBe("string"); + expect(typeof telemetry?.noticeShownAt).toBe("string"); + expect(JSON.parse(readFileSync(join(home, "config.json"), "utf8")).telemetry.decision).toBe("accepted"); + } finally { + log.mockRestore(); + } + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 71a17ebfca7..46504f81156 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1622,5 +1622,6 @@ "messages-sse-baseline-contract.test.ts": "claude-integration", "messages-sse-control-fields.test.ts": "claude-integration", "messages-sse-continuation.test.ts": "claude-integration", - "sse-payload-rewrite-continuation.test.ts": "responses" + "sse-payload-rewrite-continuation.test.ts": "responses", + "usage-telemetry-client.test.ts": "usage", "usage-telemetry-core-boundary.test.ts": "usage", "server-telemetry-routes.test.ts": "server", "cli-telemetry.test.ts": "cli" } diff --git a/tests/server/server-telemetry-routes.test.ts b/tests/server/server-telemetry-routes.test.ts new file mode 100644 index 00000000000..7c8814ec7e6 --- /dev/null +++ b/tests/server/server-telemetry-routes.test.ts @@ -0,0 +1,177 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleManagementAPI } from "../../src/server/management-api"; +import type { ManagementPrincipal } from "../../src/server/management-auth"; +import type { ManagementRequestIngress } from "../../src/server/management/context"; +import { TELEMETRY_PREVIEW_HASH_PLACEHOLDER, ensureTelemetryInstallId, resetTelemetryInstallIdMemoForTests, telemetryIdPath } from "../../src/telemetry/install-id"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +describe("/api/telemetry management routes", () => { + let home: string; + let config: OcxConfig; + const prevHome = process.env.OPENCODEX_HOME; + const prevTelemetry = process.env.OCX_TELEMETRY; + const prevDnt = process.env.DO_NOT_TRACK; + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-telemetry-routes-")); + process.env.OPENCODEX_HOME = home; + delete process.env.OCX_TELEMETRY; + delete process.env.DO_NOT_TRACK; + resetTelemetryInstallIdMemoForTests(); + config = { + port: 10100, + defaultProvider: "openai", + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + }, + } as OcxConfig; + writeFileSync(join(home, "config.json"), JSON.stringify(config, null, 2) + "\n", "utf8"); + }); + + afterEach(() => { + resetTelemetryInstallIdMemoForTests(); + if (prevHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = prevHome; + if (prevTelemetry === undefined) delete process.env.OCX_TELEMETRY; + else process.env.OCX_TELEMETRY = prevTelemetry; + if (prevDnt === undefined) delete process.env.DO_NOT_TRACK; + else process.env.DO_NOT_TRACK = prevDnt; + removeTreeWithRetry(home); + }); + + async function call( + method: string, + pathname: string, + options: { + body?: unknown; + principal?: ManagementPrincipal; + ingress?: ManagementRequestIngress; + } = {}, + ) { + const url = new URL(`http://127.0.0.1:10100${pathname}`); + const req = new Request(url, { + method, + headers: { + host: "127.0.0.1:10100", + ...(options.body !== undefined ? { "content-type": "application/json" } : {}), + }, + ...(options.body !== undefined ? { body: JSON.stringify(options.body) } : {}), + }); + const res = await handleManagementAPI( + req, + url, + config, + {}, + options.principal ?? "admin-token", + undefined, + options.ingress ?? { trustedLoopback: true }, + ); + expect(res).not.toBeNull(); + return { + status: res!.status, + body: (await res!.json()) as Record, + }; + } + + test("GET /api/telemetry returns status and preview with placeholder hash without creating an id", async () => { + const res = await call("GET", "/api/telemetry"); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + effective: false, + reason: "awaiting-notice", + decision: null, + lastSentDay: null, + }); + const preview = res.body.preview as { installDayHash: string; schemaVersion: number }; + expect(preview.schemaVersion).toBe(1); + expect(preview.installDayHash).toBe(TELEMETRY_PREVIEW_HASH_PLACEHOLDER); + expect(existsSync(telemetryIdPath(home))).toBe(false); + }); + + test("PUT /api/telemetry and POST /api/telemetry/notice refuse admin-token and remembered-admin sessions", async () => { + for (const [principal, guiSessionIssuance] of [ + ["admin-token", null], + ["gui-session", "remembered-admin"], + ] as const) { + const putRes = await call("PUT", "/api/telemetry", { + body: { decision: "accepted" }, + principal, + ingress: { trustedLoopback: true, guiSessionIssuance }, + }); + expect(putRes.status).toBe(403); + expect(putRes.body.code).toBe("agent_consent_required"); + + const noticeRes = await call("POST", "/api/telemetry/notice", { + principal, + ingress: { trustedLoopback: true, guiSessionIssuance }, + }); + expect(noticeRes.status).toBe(403); + expect(noticeRes.body.code).toBe("agent_consent_required"); + } + expect(config.telemetry).toBeUndefined(); + }); + + test("POST /api/telemetry/notice accepts a human dashboard session and is idempotent", async () => { + const first = await call("POST", "/api/telemetry/notice", { + principal: "gui-session", + ingress: { trustedLoopback: true, guiSessionIssuance: "loopback-bootstrap" }, + }); + expect(first.status).toBe(200); + expect(first.body).toMatchObject({ + ok: true, + effective: true, + reason: "default-on", + decision: null, + }); + const firstNoticeAt = first.body.noticeShownAt as string; + expect(typeof firstNoticeAt).toBe("string"); + + const second = await call("POST", "/api/telemetry/notice", { + principal: "gui-session", + ingress: { trustedLoopback: true, guiSessionIssuance: "loopback-bootstrap" }, + }); + expect(second.status).toBe(200); + expect(second.body.noticeShownAt).toBe(firstNoticeAt); + }); + + test("PUT /api/telemetry accepts a human dashboard session and deletes telemetry-id.json on decline", async () => { + ensureTelemetryInstallId(home); + expect(existsSync(telemetryIdPath(home))).toBe(true); + + const accepted = await call("PUT", "/api/telemetry", { + body: { decision: "accepted" }, + principal: "gui-session", + ingress: { trustedLoopback: true, guiSessionIssuance: "loopback-bootstrap" }, + }); + expect(accepted.status).toBe(200); + expect(accepted.body).toMatchObject({ + ok: true, + effective: true, + reason: "accepted", + decision: "accepted", + }); + expect(config.telemetry?.decision).toBe("accepted"); + + const declined = await call("PUT", "/api/telemetry", { + body: { decision: "declined" }, + principal: "gui-session", + ingress: { trustedLoopback: true, guiSessionIssuance: "loopback-bootstrap" }, + }); + expect(declined.status).toBe(200); + expect(declined.body).toMatchObject({ + ok: true, + effective: false, + reason: "declined", + decision: "declined", + }); + expect(existsSync(telemetryIdPath(home))).toBe(false); + }); +}); diff --git a/tests/usage/usage-telemetry-client.test.ts b/tests/usage/usage-telemetry-client.test.ts new file mode 100644 index 00000000000..e8cee325640 --- /dev/null +++ b/tests/usage/usage-telemetry-client.test.ts @@ -0,0 +1,468 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { readConfigDiagnostics, telemetryConfigError } from "../../src/config/diagnostics"; +import { adoptPersistedTelemetry, armClaudeCodeBaseline } from "../../src/config/live-reconcile"; +import { malformedTelemetryWarning } from "../../src/config/load-degrade"; +import { + aggregateTelemetryEntriesForDay, + aggregateTelemetryForDay, + classifyModelFamily, + classifyProviderKind, + disjointTelemetryTokens, +} from "../../src/telemetry/aggregate"; +import { resolveTelemetryStatus } from "../../src/telemetry/consent"; +import { + ARCH_VALUES, + INSTALL_CHANNELS, + MODEL_FAMILIES, + OS_VALUES, + PROVIDER_KINDS, + RELEASE_CHANNELS, + REQUEST_FAILURE_CAUSES as TELEMETRY_FAILURE_CAUSES, + REQUEST_OUTCOME_CLASSES as TELEMETRY_OUTCOME_CLASSES, + TELEMETRY_SCHEMA_VERSION, + validateTelemetryPayloadV1, + type TelemetryPayloadV1, +} from "../../src/telemetry/contract"; +import { + computeInstallDayHash, + deleteTelemetryInstallId, + ensureTelemetryInstallId, + readTelemetryInstallId, + resetTelemetryInstallIdMemoForTests, + telemetryIdPath, +} from "../../src/telemetry/install-id"; +import { buildTelemetryPayloadV1, resolveInstallChannel } from "../../src/telemetry/payload"; +import { readTelemetryState, runTelemetryTick } from "../../src/telemetry/scheduler"; +import { sendTelemetryPayload, setTelemetrySenderOverridesForTests } from "../../src/telemetry/sender"; +import type { OcxConfig } from "../../src/types"; +import type { PersistedUsageEntry } from "../../src/usage/log"; +import { REQUEST_OUTCOME_CLASSES } from "../../src/usage/request-outcome"; +import { REQUEST_FAILURE_CAUSES } from "../../src/usage/telemetry-contract"; +import { usageDisplayTotalTokens } from "../../src/usage/totals"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { repoPath } from "../helpers/repo-root"; + +const NOW = Date.parse("2026-11-07T12:00:00.000Z"); + +describe("telemetry contract parity and validation", () => { + test("contract module is import-free and pins outcome and failure-cause rosters", () => { + const src = readFileSync(repoPath("src", "telemetry", "contract.ts"), "utf8"); + expect(src).not.toMatch(/^\s*(?:import|export\s+.*from)\s+/m); + expect([...TELEMETRY_OUTCOME_CLASSES]).toEqual([...REQUEST_OUTCOME_CLASSES]); + expect([...TELEMETRY_FAILURE_CAUSES]).toEqual([...REQUEST_FAILURE_CAUSES]); + expect(PROVIDER_KINDS).toContain("anthropic"); + expect(MODEL_FAMILIES).toContain("claude-opus"); + expect(INSTALL_CHANNELS).toContain("npm-global"); + expect(RELEASE_CHANNELS).toEqual(["latest", "preview"]); + expect(OS_VALUES).toEqual(["darwin", "linux", "win32", "other"]); + expect(ARCH_VALUES).toEqual(["x64", "arm64", "other"]); + }); + + test("validateTelemetryPayloadV1 accepts a canonical payload and rejects extra keys, unknown roster values, semver metadata, and bad dates", () => { + const base: TelemetryPayloadV1 = { + schemaVersion: TELEMETRY_SCHEMA_VERSION, + batchId: "11111111-2222-4333-8444-555555555555", + day: "2026-11-06", + installDayHash: "a".repeat(64), + newInstall: false, + env: { + version: "2.40.1", + releaseChannel: "latest", + os: "darwin", + arch: "arm64", + installChannel: "npm-global", + }, + activeMinutes: 312, + requests: { completed: 840, failed: 12, incomplete: 3, aborted: 5 }, + models: [ + { + providerKind: "anthropic", + modelFamily: "claude-opus", + requests: 400, + inputTokens: 1000, + cacheReadTokens: 2000, + cacheWriteTokens: 500, + outputTokens: 300, + reasoningTokens: 100, + }, + ], + errors: { "rate-limit": 7 }, + }; + + expect(validateTelemetryPayloadV1(base, NOW)).toEqual({ ok: true, payload: base }); + expect(validateTelemetryPayloadV1({ ...base, env: { ...base.env, version: "2.40.1-preview.3", releaseChannel: "preview" } }, NOW).ok).toBe(true); + + expect(validateTelemetryPayloadV1({ ...base, extra: 1 }, NOW).ok).toBe(false); + expect(validateTelemetryPayloadV1({ ...base, env: { ...base.env, extra: 1 } }, NOW).ok).toBe(false); + expect(validateTelemetryPayloadV1({ ...base, env: { ...base.env, version: "2.40.1+build.9" } }, NOW).ok).toBe(false); + expect(validateTelemetryPayloadV1({ ...base, env: { ...base.env, installChannel: "brew" as never } }, NOW).ok).toBe(false); + expect(validateTelemetryPayloadV1({ ...base, day: "2026-02-30" }, NOW).ok).toBe(false); + expect(validateTelemetryPayloadV1({ ...base, day: "2026-10-20" }, NOW).ok).toBe(false); + expect(validateTelemetryPayloadV1({ ...base, day: "2026-11-09" }, NOW).ok).toBe(false); + expect(validateTelemetryPayloadV1({ ...base, errors: { "unknown-cause": 1 } as never }, NOW).ok).toBe(false); + expect( + validateTelemetryPayloadV1( + { ...base, models: [{ ...base.models[0]!, providerKind: "custom-vendor" as never }] }, + NOW, + ).ok, + ).toBe(false); + expect( + validateTelemetryPayloadV1( + { ...base, models: [base.models[0]!, base.models[0]!] }, + NOW, + ).ok, + ).toBe(false); + }); +}); + +describe("consent status matrix and config schema wiring", () => { + test("resolveTelemetryStatus follows the precedence table", () => { + expect(resolveTelemetryStatus(undefined, {})).toEqual({ + effective: false, + reason: "awaiting-notice", + decision: null, + decidedAt: null, + noticeShownAt: null, + envOverride: null, + }); + + expect(resolveTelemetryStatus({ noticeShownAt: "2026-11-06T00:00:00.000Z" }, {})).toMatchObject({ + effective: true, + reason: "default-on", + decision: null, + }); + + expect(resolveTelemetryStatus({ decision: "accepted", decidedAt: "2026-11-06T00:00:00.000Z" }, {})).toMatchObject({ + effective: true, + reason: "accepted", + decision: "accepted", + }); + + expect( + resolveTelemetryStatus( + { decision: "declined", decidedAt: "2026-11-06T00:00:00.000Z", noticeShownAt: "2026-11-05T00:00:00.000Z" }, + {}, + ), + ).toMatchObject({ + effective: false, + reason: "declined", + decision: "declined", + }); + + for (const val of ["0", "false", "off", "no", " FALSE "]) { + expect( + resolveTelemetryStatus({ decision: "accepted" }, { OCX_TELEMETRY: val, DO_NOT_TRACK: "1" }), + ).toMatchObject({ + effective: false, + reason: "env-ocx-telemetry", + envOverride: "OCX_TELEMETRY", + }); + } + + for (const val of ["1", "true", "yes", " TRUE "]) { + expect(resolveTelemetryStatus({ decision: "accepted" }, { DO_NOT_TRACK: val })).toMatchObject({ + effective: false, + reason: "env-do-not-track", + envOverride: "DO_NOT_TRACK", + }); + } + }); + + test("config diagnostics, load-degrade, and live-reconcile handle telemetry config", () => { + expect(telemetryConfigError({ telemetry: { decision: "accepted" } })).toBeNull(); + expect(telemetryConfigError({ telemetry: { endpoint: "https://evil.example" } })).toContain("schema_invalid: telemetry"); + expect(malformedTelemetryWarning({ telemetry: { decision: "maybe" } })).toContain("telemetry.decision ignored"); + + const live = { port: 10100, defaultProvider: "openai", providers: {} } as OcxConfig; + armClaudeCodeBaseline(live); + adoptPersistedTelemetry(live, { decision: "accepted", decidedAt: "2026-11-06T00:00:00.000Z" }); + expect(live.telemetry).toEqual({ decision: "accepted", decidedAt: "2026-11-06T00:00:00.000Z" }); + adoptPersistedTelemetry(live, undefined); + expect(live.telemetry).toBeUndefined(); + }); +}); + +describe("install identity, aggregation, disjoint tokens, and scheduler", () => { + let home: string; + const prevHome = process.env.OPENCODEX_HOME; + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-telemetry-unit-")); + process.env.OPENCODEX_HOME = home; + resetTelemetryInstallIdMemoForTests(); + setTelemetrySenderOverridesForTests(null); + }); + + afterEach(() => { + resetTelemetryInstallIdMemoForTests(); + setTelemetrySenderOverridesForTests(null); + if (prevHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = prevHome; + removeTreeWithRetry(home); + }); + + test("telemetry-id.json is created with 0o600 mode and deleted on decline", () => { + expect(readTelemetryInstallId(home)).toBeNull(); + const first = ensureTelemetryInstallId(home); + expect(first.freshlyCreated).toBe(true); + const idFile = telemetryIdPath(home); + expect(existsSync(idFile)).toBe(true); + if (process.platform !== "win32") { + expect(statSync(idFile).mode & 0o777).toBe(0o600); + } + const second = ensureTelemetryInstallId(home); + expect(second.freshlyCreated).toBe(false); + expect(second.identity.installId).toBe(first.identity.installId); + + const hashA = computeInstallDayHash(first.identity.installId, "2026-11-05"); + const hashB = computeInstallDayHash(first.identity.installId, "2026-11-06"); + expect(hashA).toMatch(/^[0-9a-f]{64}$/); + expect(hashA).not.toBe(hashB); + + expect(deleteTelemetryInstallId(home)).toBe(true); + expect(existsSync(idFile)).toBe(false); + expect(readTelemetryInstallId(home)).toBeNull(); + }); + + test("aggregates a fixture ledger and preserves the disjoint token bucket identity", async () => { + const day = "2026-11-06"; + const baseTs = Date.parse("2026-11-06T10:00:00.000Z"); + const fixtureEntries: PersistedUsageEntry[] = [ + { + requestId: "req-1", + timestamp: baseTs + 5_000, + provider: "anthropic-p123456", + model: "claude-opus-4-6", + status: 200, + terminalStatus: "completed", + durationMs: 1200, + usageStatus: "reported", + usage: { + inputTokens: 1000, + cacheReadInputTokens: 600, + cacheCreationInputTokens: 150, + outputTokens: 400, + reasoningOutputTokens: 120, + }, + }, + { + requestId: "req-2", + timestamp: baseTs + 25_000, + provider: "my-custom-secret-provider", + model: "gpt-5.3-codex", + status: 429, + terminalStatus: "failed", + failureCause: "rate-limit", + durationMs: 300, + usageStatus: "reported", + usage: { + inputTokens: 500, + outputTokens: 50, + }, + }, + { + requestId: "req-3", + timestamp: baseTs + 90_000, + provider: "openai", + model: "o3-mini", + status: 499, + closeReason: "client_cancel", + failureCause: "client-cancelled", + durationMs: 100, + usageStatus: "unreported", + }, + { + requestId: "req-other-day", + timestamp: Date.parse("2026-11-05T23:59:59.000Z"), + provider: "openai", + model: "gpt-5", + status: 200, + durationMs: 100, + usageStatus: "reported", + usage: { inputTokens: 9999, outputTokens: 9999 }, + }, + ]; + + writeFileSync( + join(home, "usage.jsonl"), + fixtureEntries.map(row => JSON.stringify(row)).join("\n") + "\n", + "utf8", + ); + + for (const entry of fixtureEntries) { + if (!entry.usage) continue; + const buckets = disjointTelemetryTokens(entry.usage); + const sum = + buckets.inputTokens + + buckets.cacheReadTokens + + buckets.cacheWriteTokens + + buckets.outputTokens + + buckets.reasoningTokens; + expect(sum).toBe(usageDisplayTotalTokens(entry.usage)!); + } + + const agg = await aggregateTelemetryForDay(day); + expect(agg.activeMinutes).toBe(2); + expect(agg.requests).toEqual({ completed: 1, failed: 1, incomplete: 0, aborted: 1 }); + expect(agg.errors).toEqual({ "rate-limit": 1, "client-cancelled": 1 }); + expect(agg.models).toEqual([ + { + providerKind: "anthropic", + modelFamily: "claude-opus", + requests: 1, + inputTokens: 250, + cacheReadTokens: 600, + cacheWriteTokens: 150, + outputTokens: 280, + reasoningTokens: 120, + }, + { + providerKind: "other", + modelFamily: "gpt-5", + requests: 1, + inputTokens: 500, + cacheReadTokens: 0, + cacheWriteTokens: 0, + outputTokens: 50, + reasoningTokens: 0, + }, + { + providerKind: "openai", + modelFamily: "o-series", + requests: 1, + inputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + }, + ]); + expect(JSON.stringify(agg)).not.toContain("my-custom-secret-provider"); + expect(classifyProviderKind("google-vertex")).toBe("vertex"); + expect(classifyModelFamily("deepseek/deepseek-r1")).toBe("deepseek"); + expect(aggregateTelemetryEntriesForDay(day, []).activeMinutes).toBe(0); + }); + + test("installChannel mapping covers desktop, docker, and package managers", () => { + expect(resolveInstallChannel({ env: { OCX_DESKTOP_SUPERVISED: "1" } })).toBe("desktop"); + expect(resolveInstallChannel({ env: {}, exists: p => p === "/.dockerenv" })).toBe("docker"); + expect(resolveInstallChannel({ env: {}, exists: () => false, detectOwnership: () => ({ installer: "npm" }) })).toBe("npm-global"); + expect(resolveInstallChannel({ env: {}, exists: () => false, detectOwnership: () => ({ installer: "pnpm" }) })).toBe("pnpm-global"); + expect(resolveInstallChannel({ env: {}, exists: () => false, detectOwnership: () => ({ installer: "bun" }) })).toBe("bun-global"); + expect(resolveInstallChannel({ env: {}, exists: () => false, detectOwnership: () => ({ installer: "mise" }) })).toBe("mise"); + expect(resolveInstallChannel({ env: {}, exists: () => false, detectOwnership: () => ({ installer: "standalone" }) })).toBe("standalone"); + expect(resolveInstallChannel({ env: {}, exists: () => false, detectOwnership: () => ({ installer: "source" }) })).toBe("source"); + expect(resolveInstallChannel({ env: {}, exists: () => false, detectOwnership: () => ({ installer: "mystery" }) })).toBe("unknown"); + }); + + test("scheduler sends nothing when not effective, skips pre-consent days, and sends at most one day per tick oldest first", async () => { + const sentPayloads: TelemetryPayloadV1[] = []; + const mockFetch = (async (_url: string | URL | Request, init?: RequestInit) => { + sentPayloads.push(JSON.parse(String(init?.body)) as TelemetryPayloadV1); + return new Response(JSON.stringify({ ok: true }), { status: 202 }); + }) as unknown as typeof fetch; + + // 1. Undecided without notice -> nothing sent, no id created. + const resAwaiting = await runTelemetryTick({ + configDir: home, + config: {}, + env: {}, + now: NOW, + fetch: mockFetch, + }); + expect(resAwaiting.sent).toBe(false); + expect(resAwaiting.reason).toBe("awaiting-notice"); + expect(readTelemetryInstallId(home)).toBeNull(); + expect(sentPayloads).toHaveLength(0); + + // 2. Consent given on 2026-11-05 -> 2026-11-04 and earlier are never backfilled; + // first tick sends 2026-11-05 (oldest post-consent day) with newInstall=true, + // second tick sends 2026-11-06 with newInstall=false, + // third tick finds nothing left. + const config = { + telemetry: { + decision: "accepted" as const, + decidedAt: "2026-11-05T15:30:00.000Z", + noticeShownAt: "2026-11-05T15:30:00.000Z", + }, + }; + const tick1 = await runTelemetryTick({ + configDir: home, + config, + env: {}, + now: NOW, + fetch: mockFetch, + }); + expect(tick1).toMatchObject({ sent: true, day: "2026-11-05" }); + expect(sentPayloads).toHaveLength(1); + expect(sentPayloads[0]!.day).toBe("2026-11-05"); + expect(sentPayloads[0]!.newInstall).toBe(true); + + const tick2 = await runTelemetryTick({ + configDir: home, + config, + env: {}, + now: NOW, + fetch: mockFetch, + }); + expect(tick2).toMatchObject({ sent: true, day: "2026-11-06" }); + expect(sentPayloads).toHaveLength(2); + expect(sentPayloads[1]!.day).toBe("2026-11-06"); + expect(sentPayloads[1]!.newInstall).toBe(false); + + const tick3 = await runTelemetryTick({ + configDir: home, + config, + env: {}, + now: NOW, + fetch: mockFetch, + }); + expect(tick3).toEqual({ sent: false, reason: "up-to-date" }); + expect(readTelemetryState(home).lastSentDay).toBe("2026-11-06"); + }); + + test("sendTelemetryPayload enforces its timeout and does not retry", async () => { + const agg = aggregateTelemetryEntriesForDay("2026-11-06", []); + const payload = buildTelemetryPayloadV1({ + aggregate: agg, + installDayHash: "b".repeat(64), + newInstall: false, + now: NOW, + }); + let calls = 0; + const slowFetch = ((_url: string | URL | Request, init?: RequestInit) => { + calls += 1; + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + reject(new Error("aborted by timeout")); + }); + }); + }) as unknown as typeof fetch; + const result = await sendTelemetryPayload(payload, { + fetch: slowFetch, + timeoutMs: 25, + now: NOW, + }); + expect(result.ok).toBe(false); + expect(calls).toBe(1); + }); + + test("readConfigDiagnostics passively reads persisted telemetry config", () => { + writeFileSync( + join(home, "config.json"), + JSON.stringify({ + port: 10100, + defaultProvider: "openai", + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" } }, + telemetry: { decision: "accepted", decidedAt: "2026-11-06T00:00:00.000Z" }, + }), + "utf8", + ); + const diag = readConfigDiagnostics({ passive: true }); + expect(diag.error).toBeNull(); + expect(diag.config.telemetry?.decision).toBe("accepted"); + }); +}); diff --git a/tests/usage/usage-telemetry-core-boundary.test.ts b/tests/usage/usage-telemetry-core-boundary.test.ts new file mode 100644 index 00000000000..0088bef8342 --- /dev/null +++ b/tests/usage/usage-telemetry-core-boundary.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { repoRoot as resolveRepoRoot } from "../helpers/repo-root"; + +const PROTECTED = [ + "src/router.ts", + "src/server/lifecycle.ts", + "src/server/responses/core.ts", + "src/server/management-api.ts", +] as const; + +const repoRoot = resolveRepoRoot(); + +const STATIC_IMPORT_RE = + /^\s*import\s+(?!type\b)[^;]*?from\s+["']([^"']+)["']|^\s*import\s+["']([^"']+)["']|^\s*export\s+(?!type\b)[^;]*?from\s+["']([^"']+)["']/gm; + +function resolveSpec(spec: string, fromFile: string): string | null { + if (!spec.startsWith(".")) return null; + const base = resolve(dirname(fromFile), spec); + for (const candidate of [`${base}.ts`, join(base, "index.ts"), `${base}.mts`, `${base}.mjs`]) { + if (existsSync(candidate)) return candidate; + } + return null; +} + +function firstTelemetryPath(entry: string): string[] | null { + const start = resolve(repoRoot, entry); + const previous = new Map([[start, null]]); + const queue = [start]; + while (queue.length > 0) { + const current = queue.shift()!; + if (!existsSync(current)) continue; + const source = readFileSync(current, "utf8"); + STATIC_IMPORT_RE.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = STATIC_IMPORT_RE.exec(source)) !== null) { + const spec = match[1] ?? match[2] ?? match[3]; + if (!spec) continue; + const next = resolveSpec(spec, current); + if (!next || previous.has(next)) continue; + previous.set(next, current); + if (next.replaceAll("\\", "/").includes("/src/telemetry/")) { + const chain: string[] = []; + let node: string | null = next; + while (node) { + chain.push(node.slice(repoRoot.length + 1).replaceAll("\\", "/")); + node = previous.get(node) ?? null; + } + return chain.reverse(); + } + queue.push(next); + } + } + return null; +} + +describe("telemetry core load-time boundary", () => { + test.each(PROTECTED)("%s has no load-time import path to src/telemetry/", entry => { + expect(firstTelemetryPath(entry)).toBeNull(); + }); +}); From b5eddc370f4b3a399aab57801c557a179e19b7e2 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 7 Nov 2026 23:56:25 +0900 Subject: [PATCH 05/37] fix(telemetry): remove state installId leak, harden writes, and preserve declined config on agent import --- src/cli/config-command.ts | 13 +- src/cli/telemetry.ts | 10 +- src/service/cli.ts | 1 - src/telemetry/aggregate.ts | 8 +- src/telemetry/install-id.ts | 47 +++++++- src/telemetry/routes.ts | 2 +- src/telemetry/scheduler.ts | 41 +++++-- tests/cli/cli-telemetry.test.ts | 84 +++++++++++-- tests/server/server-telemetry-routes.test.ts | 25 +++- tests/usage/usage-telemetry-client.test.ts | 119 ++++++++++++++++++- 10 files changed, 308 insertions(+), 42 deletions(-) diff --git a/src/cli/config-command.ts b/src/cli/config-command.ts index 1a03189d27b..001496c574b 100644 --- a/src/cli/config-command.ts +++ b/src/cli/config-command.ts @@ -335,9 +335,10 @@ export async function handleConfigCommand(argv: string[]): Promise { if (!yes) throw new CliUsageError("import requires --yes", USAGE); rejectArgs(args, USAGE); await withConfigDirHardeningSettled(getConfigDir(), () => { - readCliConfigForMutation(); + const current = readCliConfigForMutation(); + const agentDriven = isAgentDriven(); const rawInput = loadInput(path); - if (isAgentDriven() && rawInput && typeof rawInput === "object" && !Array.isArray(rawInput) && Object.hasOwn(rawInput, "telemetry")) { + if (agentDriven && rawInput && typeof rawInput === "object" && !Array.isArray(rawInput) && Object.hasOwn(rawInput, "telemetry")) { delete (rawInput as Record).telemetry; console.warn("Note: dropped telemetry block during agent-driven config import; use `ocx telemetry` to manage telemetry consent."); } @@ -348,6 +349,14 @@ export async function handleConfigCommand(argv: string[]): Promise { || JSON.stringify(imported.client) !== JSON.stringify(client.value))) { throw new Error("config import refused: disconnect the active client before replacing its connection"); } + if (agentDriven) { + const persistedTelemetry = readConfigDiagnostics({ passive: true }).config.telemetry ?? current.telemetry; + if (persistedTelemetry !== undefined) { + imported.telemetry = structuredClone(persistedTelemetry); + } else { + delete imported.telemetry; + } + } saveConfig(imported); }); }); diff --git a/src/cli/telemetry.ts b/src/cli/telemetry.ts index 60ffd2f652d..6433278018c 100644 --- a/src/cli/telemetry.ts +++ b/src/cli/telemetry.ts @@ -1,7 +1,8 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { lstatSync, mkdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { isatty } from "node:tty"; import { getConfigDir, mutatePersistedConfig, readConfigDiagnostics } from "../config"; +import { atomicWriteFileNoFollow } from "../config/atomic-write"; import { withConfigDirHardeningSettled } from "../config/paths"; import { recordOwnedConfigPath } from "../lib/config-ownership"; import { resolveTelemetryStatus } from "../telemetry/consent"; @@ -95,7 +96,10 @@ export async function maybeShowTelemetryPrompt(): Promise { const deferralPath = join(dir, TELEMETRY_DEFERRAL_FILE); let record: string | null = null; try { - if (existsSync(deferralPath)) record = readFileSync(deferralPath, "utf8"); + const stat = lstatSync(deferralPath); + if (stat.isFile() && !stat.isSymbolicLink() && stat.size <= 1024) { + record = readFileSync(deferralPath, "utf8"); + } } catch { /* best-effort */ } const version = currentVersion(); if (isTelemetryDeferralCurrent(record, version, Date.now())) return; @@ -105,7 +109,7 @@ export async function maybeShowTelemetryPrompt(): Promise { try { mkdirSync(dir, { recursive: true, mode: 0o700 }); recordOwnedConfigPath(dir, deferralPath); - writeFileSync(deferralPath, `${new Date().toISOString()} ${version}`, { encoding: "utf8", mode: 0o600 }); + atomicWriteFileNoFollow(deferralPath, `${new Date().toISOString()} ${version}\n`); } catch { /* best-effort */ } return; } diff --git a/src/service/cli.ts b/src/service/cli.ts index 4daf72dce17..3a1c198055d 100644 --- a/src/service/cli.ts +++ b/src/service/cli.ts @@ -362,7 +362,6 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise> = Object.freeze({ openai: "openai", @@ -183,6 +184,11 @@ export function disjointTelemetryTokens(usage: OcxUsage | undefined): DisjointTe } const reasoningTokens = Math.min(totalOutput, nonNegInt(usage.reasoningOutputTokens)); const outputTokens = Math.max(0, totalOutput - reasoningTokens); + const displayTotal = nonNegInt(usageDisplayTotalTokens(usage)); + const accounted = inputTokens + cacheReadTokens + cacheWriteTokens + outputTokens + reasoningTokens; + if (displayTotal > accounted) { + inputTokens += displayTotal - accounted; + } return { inputTokens, cacheReadTokens, diff --git a/src/telemetry/install-id.ts b/src/telemetry/install-id.ts index a69e3882c37..2c2a7dd1e0e 100644 --- a/src/telemetry/install-id.ts +++ b/src/telemetry/install-id.ts @@ -13,6 +13,7 @@ import { writeSync, } from "node:fs"; import { dirname, join } from "node:path"; +import { atomicWriteFileNoFollow } from "../config/atomic-write"; import { getConfigDir } from "../config/paths"; import { recordOwnedConfigPath } from "../lib/config-ownership"; @@ -22,6 +23,7 @@ export const TELEMETRY_PREVIEW_HASH_PLACEHOLDER = "0".repeat(64); export interface TelemetryInstallIdentity { installId: string; createdAt: string; + firstReportSent?: boolean; } const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; @@ -44,7 +46,11 @@ function parseTelemetryId(text: string): TelemetryInstallIdentity | null { if (!UUID_V4_RE.test(raw.installId) || Number.isNaN(Date.parse(raw.createdAt))) { return null; } - return { installId: raw.installId.toLowerCase(), createdAt: raw.createdAt }; + return { + installId: raw.installId.toLowerCase(), + createdAt: raw.createdAt, + firstReportSent: raw.firstReportSent === true, + }; } catch { return null; } @@ -141,6 +147,7 @@ export function ensureTelemetryInstallId( const identity: TelemetryInstallIdentity = { installId: randomUUID().toLowerCase(), createdAt: new Date().toISOString(), + firstReportSent: false, }; const content = `${JSON.stringify({ version: 1, ...identity })}\n`; const temp = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`; @@ -173,19 +180,47 @@ export function ensureTelemetryInstallId( return { identity, freshlyCreated: true }; } +/** Mark `firstReportSent: true` inside `/telemetry-id.json` after the first report succeeds. */ +export function markTelemetryFirstReportSent(configDir = getConfigDir()): void { + const existing = readTelemetryInstallId(configDir); + if (!existing || existing.firstReportSent === true) return; + const path = telemetryIdPath(configDir); + const updated: TelemetryInstallIdentity = { + installId: existing.installId, + createdAt: existing.createdAt, + firstReportSent: true, + }; + try { recordOwnedConfigPath(configDir, path); } catch { /* best-effort */ } + atomicWriteFileNoFollow(path, `${JSON.stringify({ version: 1, ...updated })}\n`); + memo = { path, identity: updated }; +} + /** Delete `/telemetry-id.json` when telemetry is declined. */ export function deleteTelemetryInstallId(configDir = getConfigDir()): boolean { const path = telemetryIdPath(configDir); if (memo?.path === path) memo = null; + let deleted = false; try { const stat = lstatSync(path); - if (stat.isSymbolicLink() || !stat.isFile()) return false; - unlinkSync(path); - return true; + if (!stat.isSymbolicLink() && stat.isFile()) { + unlinkSync(path); + deleted = true; + } } catch (error) { - if (errorCode(error) === "ENOENT") return false; - return false; + if (errorCode(error) !== "ENOENT") deleted = false; } + const statePath = join(configDir, "telemetry-state.json"); + try { + const stat = lstatSync(statePath); + if (!stat.isSymbolicLink() && stat.isFile() && stat.size <= 64 * 1024) { + const raw = JSON.parse(readFileSync(statePath, "utf8")) as Record; + if (raw && typeof raw === "object" && !Array.isArray(raw) && "firstReportSentForInstallId" in raw) { + delete raw.firstReportSentForInstallId; + atomicWriteFileNoFollow(statePath, `${JSON.stringify(raw, null, 2)}\n`); + } + } + } catch { /* best-effort */ } + return deleted; } export function computeInstallDayHash(installId: string, day: string): string { diff --git a/src/telemetry/routes.ts b/src/telemetry/routes.ts index 49707f420e2..65be7e621eb 100644 --- a/src/telemetry/routes.ts +++ b/src/telemetry/routes.ts @@ -41,7 +41,7 @@ export async function buildTelemetryStatusSnapshot( ? computeInstallDayHash(identity.installId, day) : TELEMETRY_PREVIEW_HASH_PLACEHOLDER; const newInstall = identity - ? state.firstReportSentForInstallId !== identity.installId + ? identity.firstReportSent !== true : true; const preview = buildTelemetryPayloadV1({ aggregate, diff --git a/src/telemetry/scheduler.ts b/src/telemetry/scheduler.ts index 604c535d0ec..4d98a9562d3 100644 --- a/src/telemetry/scheduler.ts +++ b/src/telemetry/scheduler.ts @@ -1,5 +1,6 @@ -import { lstatSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { lstatSync, mkdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; +import { atomicWriteFileNoFollow } from "../config/atomic-write"; import { readConfigDiagnostics } from "../config/diagnostics"; import { getConfigDir } from "../config/paths"; import { recordOwnedConfigPath } from "../lib/config-ownership"; @@ -7,7 +8,7 @@ import { registerOptionalShutdownHook } from "../lib/optional-shutdown-hooks"; import type { OcxConfig } from "../types"; import { aggregateTelemetryForDay, type DailyTelemetryAggregate } from "./aggregate"; import { isTelemetryEnvOptOut, resolveTelemetryStatus } from "./consent"; -import { computeInstallDayHash, ensureTelemetryInstallId } from "./install-id"; +import { computeInstallDayHash, ensureTelemetryInstallId, markTelemetryFirstReportSent } from "./install-id"; import { buildTelemetryPayloadV1, resolveInstallChannel, type ResolveInstallChannelOptions } from "./payload"; import { sendTelemetryPayload, type TelemetrySendOptions, type TelemetrySendResult } from "./sender"; @@ -22,7 +23,6 @@ export interface TelemetryState { sentDays: Record; lastSentDay: string | null; lastSentAt: string | null; - firstReportSentForInstallId: string | null; } export function telemetryStatePath(configDir = getConfigDir()): string { @@ -35,7 +35,6 @@ function emptyState(): TelemetryState { sentDays: {}, lastSentDay: null, lastSentAt: null, - firstReportSentForInstallId: null, }; } @@ -59,8 +58,6 @@ export function readTelemetryState(configDir = getConfigDir()): TelemetryState { sentDays, lastSentDay: typeof raw.lastSentDay === "string" ? raw.lastSentDay : null, lastSentAt: typeof raw.lastSentAt === "string" ? raw.lastSentAt : null, - firstReportSentForInstallId: - typeof raw.firstReportSentForInstallId === "string" ? raw.firstReportSentForInstallId : null, }; } catch { return emptyState(); @@ -81,9 +78,8 @@ export function writeTelemetryState(state: TelemetryState, configDir = getConfig sentDays: prunedSentDays, lastSentDay: state.lastSentDay, lastSentAt: state.lastSentAt, - firstReportSentForInstallId: state.firstReportSentForInstallId, }; - writeFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); + atomicWriteFileNoFollow(path, `${JSON.stringify(payload, null, 2)}\n`); } export function utcMidnightMs(epochMs: number): number { @@ -155,7 +151,7 @@ export async function runTelemetryTick(options: TelemetryTickOptions = {}): Prom } const { identity } = ensureTelemetryInstallId(configDir); - const newInstall = state.firstReportSentForInstallId !== identity.installId; + const newInstall = identity.firstReportSent !== true; const installDayHash = computeInstallDayHash(identity.installId, targetDay); const aggregate = await (options.aggregateDay ? options.aggregateDay(targetDay) @@ -171,23 +167,29 @@ export async function runTelemetryTick(options: TelemetryTickOptions = {}): Prom if (!sendResult.ok) { return { sent: false, day: targetDay, reason: "send-failed", sendResult }; } + markTelemetryFirstReportSent(configDir); const nowIso = new Date(now).toISOString(); state.sentDays[targetDay] = nowIso; state.lastSentDay = targetDay; state.lastSentAt = nowIso; - state.firstReportSentForInstallId = identity.installId; writeTelemetryState(state, configDir, now); return { sent: true, day: targetDay, reason: "sent", sendResult }; } let startupTimer: ReturnType | null = null; let intervalTimer: ReturnType | null = null; +let nudgeTimer: ReturnType | null = null; let detachShutdownHook: (() => void) | null = null; let activeTick: Promise | null = null; let running = false; function triggerBackgroundTick(): void { - if (!running || activeTick) return; + if (!running) return; + if (isTelemetryEnvOptOut(process.env) !== null) { + stopTelemetryScheduler(); + return; + } + if (activeTick) return; activeTick = runTelemetryTick() .catch((): TelemetryTickResult => ({ sent: false, reason: "tick-error" })) .finally(() => { @@ -226,6 +228,10 @@ export function stopTelemetryScheduler(): void { clearInterval(intervalTimer); intervalTimer = null; } + if (nudgeTimer) { + clearTimeout(nudgeTimer); + nudgeTimer = null; + } if (detachShutdownHook) { const detach = detachShutdownHook; detachShutdownHook = null; @@ -235,12 +241,21 @@ export function stopTelemetryScheduler(): void { export function nudgeTelemetryScheduler(): void { if (!running) return; + if (isTelemetryEnvOptOut(process.env) !== null) { + stopTelemetryScheduler(); + return; + } if (startupTimer) { clearTimeout(startupTimer); startupTimer = null; } - const timer = setTimeout(() => { + if (nudgeTimer) { + clearTimeout(nudgeTimer); + nudgeTimer = null; + } + nudgeTimer = setTimeout(() => { + nudgeTimer = null; triggerBackgroundTick(); }, 10); - timer.unref?.(); + nudgeTimer.unref?.(); } diff --git a/tests/cli/cli-telemetry.test.ts b/tests/cli/cli-telemetry.test.ts index ece7f41245a..eaea6062012 100644 --- a/tests/cli/cli-telemetry.test.ts +++ b/tests/cli/cli-telemetry.test.ts @@ -1,8 +1,9 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; -import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, lstatSync, mkdtempSync, readdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { handleConfigCommand } from "../../src/cli/config-command"; +import { maybeShowStarPrompt, setStarPromptDepsForTests } from "../../src/cli/star-prompt"; import { TELEMETRY_DEFERRAL_FILE, handleTelemetryCommand, @@ -11,10 +12,12 @@ import { } from "../../src/cli/telemetry"; import { readConfigDiagnostics } from "../../src/config/diagnostics"; import { - ensureTelemetryInstallId, + readTelemetryInstallId, resetTelemetryInstallIdMemoForTests, telemetryIdPath, } from "../../src/telemetry/install-id"; +import { runTelemetryTick } from "../../src/telemetry/scheduler"; +import { repoPath } from "../helpers/repo-root"; import { removeTreeWithRetry } from "../helpers/remove-tree"; const AGENT_ENV_VARS = [ @@ -62,6 +65,7 @@ describe("ocx telemetry CLI and interactive prompt", () => { afterEach(() => { setTelemetryPromptDepsForTests(null); + setStarPromptDepsForTests(null); resetTelemetryInstallIdMemoForTests(); for (const [key, value] of savedAgentEnv) { if (value === undefined) delete process.env[key]; @@ -103,7 +107,14 @@ describe("ocx telemetry CLI and interactive prompt", () => { expect(enableCode).toBe(0); expect(readConfigDiagnostics().config.telemetry?.decision).toBe("accepted"); - ensureTelemetryInstallId(home); + const tick = await runTelemetryTick({ + configDir: home, + env: {}, + now: Date.now() + 2 * 86_400_000, + fetch: (async () => new Response(JSON.stringify({ ok: true }), { status: 202 })) as unknown as typeof fetch, + }); + expect(tick.sent).toBe(true); + const rawInstallId = readTelemetryInstallId(home)!.installId; expect(existsSync(telemetryIdPath(home))).toBe(true); // Agent-driven session: @@ -118,6 +129,12 @@ describe("ocx telemetry CLI and interactive prompt", () => { expect(disableCode).toBe(0); expect(readConfigDiagnostics().config.telemetry?.decision).toBe("declined"); expect(existsSync(telemetryIdPath(home))).toBe(false); + for (const entry of readdirSync(home)) { + const full = join(home, entry); + if (lstatSync(full).isFile()) { + expect(readFileSync(full, "utf8")).not.toContain(rawInstallId); + } + } } finally { log.mockRestore(); err.mockRestore(); @@ -132,9 +149,9 @@ describe("ocx telemetry CLI and interactive prompt", () => { expect(await handleConfigCommand(["set", "telemetry.decision", "accepted"])).not.toBe(0); expect(await handleConfigCommand(["unset", "telemetry"])).not.toBe(0); - const importFile = join(home, "imported.json"); + const importAccepted = join(home, "imported-accepted.json"); writeFileSync( - importFile, + importAccepted, JSON.stringify({ port: 10100, defaultProvider: "openai", @@ -150,9 +167,37 @@ describe("ocx telemetry CLI and interactive prompt", () => { "utf8", ); process.env.CODEX_THREAD_ID = "agent-import"; - expect(await handleConfigCommand(["import", importFile, "--yes"])).toBe(0); + // Direction 1: undecided on disk -> agent import does not inject telemetry + expect(await handleConfigCommand(["import", importAccepted, "--yes"])).toBe(0); expect(readConfigDiagnostics().config.telemetry).toBeUndefined(); expect(warn.mock.calls.map(c => String(c[0])).join("\n")).toContain("ocx telemetry"); + + // Direction 2: declined on disk -> agent import preserves declined whether input has telemetry or omits it + expect(await handleTelemetryCommand(["disable", "--json"])).toBe(0); + const declinedBefore = readConfigDiagnostics().config.telemetry; + expect(declinedBefore?.decision).toBe("declined"); + + expect(await handleConfigCommand(["import", importAccepted, "--yes"])).toBe(0); + expect(readConfigDiagnostics().config.telemetry).toEqual(declinedBefore); + + const importWithoutTelemetry = join(home, "imported-no-telemetry.json"); + writeFileSync( + importWithoutTelemetry, + JSON.stringify({ + port: 10100, + defaultProvider: "openai", + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + }, + }), + "utf8", + ); + expect(await handleConfigCommand(["import", importWithoutTelemetry, "--yes"])).toBe(0); + expect(readConfigDiagnostics().config.telemetry).toEqual(declinedBefore); } finally { log.mockRestore(); warn.mockRestore(); @@ -163,11 +208,18 @@ describe("ocx telemetry CLI and interactive prompt", () => { test("agent-driven start prints notice once per version and writes no noticeShownAt or decision; interactive prompt defaults to yes", async () => { const log = spyOn(console, "log").mockImplementation(() => {}); try { + // A symlinked .telemetry-deferred marker is neither trusted on read nor followed on write: + const victim = join(home, "deferral-victim.txt"); + writeFileSync(victim, "preserve-deferral-victim", "utf8"); + const deferralPath = join(home, TELEMETRY_DEFERRAL_FILE); + symlinkSync(victim, deferralPath); + process.env.CODEX_THREAD_ID = "agent-start"; setTelemetryPromptDepsForTests({ isTty: () => true }); await maybeShowTelemetryPrompt(); expect(log.mock.calls.length).toBe(1); - expect(existsSync(join(home, TELEMETRY_DEFERRAL_FILE))).toBe(true); + expect(readFileSync(victim, "utf8")).toBe("preserve-deferral-victim"); + expect(lstatSync(deferralPath).isSymbolicLink()).toBe(false); expect(readConfigDiagnostics().config.telemetry).toBeUndefined(); // Second agent-driven run on the same version stays quiet: @@ -197,4 +249,22 @@ describe("ocx telemetry CLI and interactive prompt", () => { log.mockRestore(); } }); + + test("service/cli.ts does not duplicate maybeShowTelemetryPrompt after maybeShowStarPrompt and the prompt runs once", async () => { + const serviceCliSource = readFileSync(repoPath("src", "service", "cli.ts"), "utf8"); + expect(serviceCliSource).toContain("await maybeShowStarPrompt()"); + expect(serviceCliSource).not.toContain("maybeShowTelemetryPrompt"); + + let telemetryPromptCount = 0; + setStarPromptDepsForTests({ isTty: () => true, ghAvailable: () => false }); + setTelemetryPromptDepsForTests({ + isTty: () => true, + interactiveConfirm: async opts => { + telemetryPromptCount += 1; + return opts.defaultYes; + }, + }); + await maybeShowStarPrompt(); + expect(telemetryPromptCount).toBe(1); + }); }); diff --git a/tests/server/server-telemetry-routes.test.ts b/tests/server/server-telemetry-routes.test.ts index 7c8814ec7e6..9062a351829 100644 --- a/tests/server/server-telemetry-routes.test.ts +++ b/tests/server/server-telemetry-routes.test.ts @@ -1,11 +1,12 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, writeFileSync } from "node:fs"; +import { existsSync, lstatSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { handleManagementAPI } from "../../src/server/management-api"; import type { ManagementPrincipal } from "../../src/server/management-auth"; import type { ManagementRequestIngress } from "../../src/server/management/context"; -import { TELEMETRY_PREVIEW_HASH_PLACEHOLDER, ensureTelemetryInstallId, resetTelemetryInstallIdMemoForTests, telemetryIdPath } from "../../src/telemetry/install-id"; +import { TELEMETRY_PREVIEW_HASH_PLACEHOLDER, readTelemetryInstallId, resetTelemetryInstallIdMemoForTests, telemetryIdPath } from "../../src/telemetry/install-id"; +import { runTelemetryTick } from "../../src/telemetry/scheduler"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -143,9 +144,6 @@ describe("/api/telemetry management routes", () => { }); test("PUT /api/telemetry accepts a human dashboard session and deletes telemetry-id.json on decline", async () => { - ensureTelemetryInstallId(home); - expect(existsSync(telemetryIdPath(home))).toBe(true); - const accepted = await call("PUT", "/api/telemetry", { body: { decision: "accepted" }, principal: "gui-session", @@ -160,6 +158,17 @@ describe("/api/telemetry management routes", () => { }); expect(config.telemetry?.decision).toBe("accepted"); + const tick = await runTelemetryTick({ + configDir: home, + config, + env: {}, + now: Date.now() + 2 * 86_400_000, + fetch: (async () => new Response(JSON.stringify({ ok: true }), { status: 202 })) as unknown as typeof fetch, + }); + expect(tick.sent).toBe(true); + const rawInstallId = readTelemetryInstallId(home)!.installId; + expect(existsSync(telemetryIdPath(home))).toBe(true); + const declined = await call("PUT", "/api/telemetry", { body: { decision: "declined" }, principal: "gui-session", @@ -173,5 +182,11 @@ describe("/api/telemetry management routes", () => { decision: "declined", }); expect(existsSync(telemetryIdPath(home))).toBe(false); + for (const entry of readdirSync(home)) { + const full = join(home, entry); + if (lstatSync(full).isFile()) { + expect(readFileSync(full, "utf8")).not.toContain(rawInstallId); + } + } }); }); diff --git a/tests/usage/usage-telemetry-client.test.ts b/tests/usage/usage-telemetry-client.test.ts index e8cee325640..e9ae6782ac4 100644 --- a/tests/usage/usage-telemetry-client.test.ts +++ b/tests/usage/usage-telemetry-client.test.ts @@ -1,5 +1,5 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { existsSync, lstatSync, mkdtempSync, readdirSync, readFileSync, statSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { readConfigDiagnostics, telemetryConfigError } from "../../src/config/diagnostics"; @@ -35,7 +35,15 @@ import { telemetryIdPath, } from "../../src/telemetry/install-id"; import { buildTelemetryPayloadV1, resolveInstallChannel } from "../../src/telemetry/payload"; -import { readTelemetryState, runTelemetryTick } from "../../src/telemetry/scheduler"; +import { + nudgeTelemetryScheduler, + readTelemetryState, + runTelemetryTick, + startTelemetryScheduler, + stopTelemetryScheduler, + telemetryStatePath, + writeTelemetryState, +} from "../../src/telemetry/scheduler"; import { sendTelemetryPayload, setTelemetrySenderOverridesForTests } from "../../src/telemetry/sender"; import type { OcxConfig } from "../../src/types"; import type { PersistedUsageEntry } from "../../src/usage/log"; @@ -346,6 +354,35 @@ describe("install identity, aggregation, disjoint tokens, and scheduler", () => expect(aggregateTelemetryEntriesForDay(day, []).activeMinutes).toBe(0); }); + test("disjointTelemetryTokens legacy fallback keeps 5-bucket sum equal to usageDisplayTotalTokens and ACCOUNT_SUFFIX_RE matches k + 32 hex", () => { + const legacyUsage = { + inputTokens: 250, + cacheReadInputTokens: 600, + cacheCreationInputTokens: 150, + outputTokens: 400, + reasoningOutputTokens: 120, + totalTokens: 1400, + }; + const buckets = disjointTelemetryTokens(legacyUsage); + const sum = + buckets.inputTokens + + buckets.cacheReadTokens + + buckets.cacheWriteTokens + + buckets.outputTokens + + buckets.reasoningTokens; + expect(sum).toBe(usageDisplayTotalTokens(legacyUsage)!); + expect(buckets.inputTokens).toBe(750); + expect(buckets.cacheReadTokens).toBe(100); + expect(buckets.cacheWriteTokens).toBe(150); + expect(buckets.outputTokens).toBe(280); + expect(buckets.reasoningTokens).toBe(120); + + expect(classifyProviderKind(`openai-k${"a1b2c3d4".repeat(4)}`)).toBe("openai"); + expect(classifyProviderKind("openai-p123456")).toBe("openai"); + expect(classifyProviderKind("openai-o123456")).toBe("openai"); + expect(classifyProviderKind("openai-k123456")).toBe("other"); + }); + test("installChannel mapping covers desktop, docker, and package managers", () => { expect(resolveInstallChannel({ env: { OCX_DESKTOP_SUPERVISED: "1" } })).toBe("desktop"); expect(resolveInstallChannel({ env: {}, exists: p => p === "/.dockerenv" })).toBe("docker"); @@ -422,6 +459,82 @@ describe("install identity, aggregation, disjoint tokens, and scheduler", () => }); expect(tick3).toEqual({ sent: false, reason: "up-to-date" }); expect(readTelemetryState(home).lastSentDay).toBe("2026-11-06"); + + // Deleting the install id after a successful tick leaves no trace of the raw installId in configDir. + const rawInstallId = readTelemetryInstallId(home)!.installId; + expect(deleteTelemetryInstallId(home)).toBe(true); + for (const entry of readdirSync(home)) { + const full = join(home, entry); + if (lstatSync(full).isFile()) { + expect(readFileSync(full, "utf8")).not.toContain(rawInstallId); + } + } + }); + + test("writeTelemetryState replaces a symlinked telemetry-state.json without following it", () => { + const victim = join(home, "victim.txt"); + writeFileSync(victim, "preserve-me", "utf8"); + const stateFile = telemetryStatePath(home); + symlinkSync(victim, stateFile); + expect(lstatSync(stateFile).isSymbolicLink()).toBe(true); + + writeTelemetryState( + { + version: 1, + sentDays: { "2026-11-06": "2026-11-07T00:00:00.000Z" }, + lastSentDay: "2026-11-06", + lastSentAt: "2026-11-07T00:00:00.000Z", + } as never, + home, + NOW, + ); + + expect(readFileSync(victim, "utf8")).toBe("preserve-me"); + expect(lstatSync(stateFile).isSymbolicLink()).toBe(false); + expect(readTelemetryState(home).lastSentDay).toBe("2026-11-06"); + }); + + test("nudgeTelemetryScheduler clears previous nudge timer and runtime env opt-out stops the scheduler in both nudge and tick", async () => { + const prevEnv = process.env.OCX_TELEMETRY; + const prevDnt = process.env.DO_NOT_TRACK; + delete process.env.OCX_TELEMETRY; + delete process.env.DO_NOT_TRACK; + const clearSpy = spyOn(globalThis, "clearTimeout"); + try { + startTelemetryScheduler({ startupDelayMs: 60_000, intervalMs: 60_000 }); + clearSpy.mockClear(); + // First nudge clears startupTimer (1 call); second and third nudges clear the prior nudgeTimer. + nudgeTelemetryScheduler(); + nudgeTelemetryScheduler(); + nudgeTelemetryScheduler(); + expect(clearSpy.mock.calls.length).toBeGreaterThanOrEqual(3); + + // Setting OCX_TELEMETRY=0 at runtime stops the scheduler when nudged. + process.env.OCX_TELEMETRY = "0"; + nudgeTelemetryScheduler(); + delete process.env.OCX_TELEMETRY; + + // Because the scheduler stopped, a subsequent nudge is a no-op and sets no new timer. + clearSpy.mockClear(); + nudgeTelemetryScheduler(); + expect(clearSpy.mock.calls.length).toBe(0); + + // Restart and set DO_NOT_TRACK=1 before startupTimer fires triggerBackgroundTick: + startTelemetryScheduler({ startupDelayMs: 5, intervalMs: 60_000 }); + process.env.DO_NOT_TRACK = "1"; + await new Promise(resolve => setTimeout(resolve, 25)); + delete process.env.DO_NOT_TRACK; + clearSpy.mockClear(); + nudgeTelemetryScheduler(); + expect(clearSpy.mock.calls.length).toBe(0); + } finally { + stopTelemetryScheduler(); + clearSpy.mockRestore(); + if (prevEnv === undefined) delete process.env.OCX_TELEMETRY; + else process.env.OCX_TELEMETRY = prevEnv; + if (prevDnt === undefined) delete process.env.DO_NOT_TRACK; + else process.env.DO_NOT_TRACK = prevDnt; + } }); test("sendTelemetryPayload enforces its timeout and does not retry", async () => { From 3e4597d1ba3c85d4049d6c01e3b3c15036d72b8f Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 02:58:44 +0900 Subject: [PATCH 06/37] docs(devlog): record PR review round 1 decisions for the telemetry stack --- .../261107_telemetry_dashboard/000_plan.md | 10 ++- .../010_client_core.md | 2 +- .../060_review_round.md | 76 +++++++++++++++++++ 3 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 devlog/_plan/261107_telemetry_dashboard/060_review_round.md diff --git a/devlog/_plan/261107_telemetry_dashboard/000_plan.md b/devlog/_plan/261107_telemetry_dashboard/000_plan.md index e694f22d51a..317491c152e 100644 --- a/devlog/_plan/261107_telemetry_dashboard/000_plan.md +++ b/devlog/_plan/261107_telemetry_dashboard/000_plan.md @@ -1,8 +1,8 @@ -# Opt-in telemetry and the opencodex.me usage dashboard +# Default-on (opt-out) telemetry and the opencodex.me usage dashboard ## Objective and scope -Add anonymous, opt-in product telemetry to the opencodex proxy and a public, project-wide +Add anonymous, default-on (opt-out) product telemetry to the opencodex proxy and a public, project-wide analytics page at `https://opencodex.me/dashboard`. The proxy reports install/version/platform pings, active time, request and token totals, and error counts; a collector stores daily aggregates; the dashboard renders installs, active time, usage, errors and downloads. @@ -91,3 +91,9 @@ Same reviewer: PASS, 0 blockers; all 9 blockers and 6 majors verified resolved. collector `tsconfig.json` overrides `rootDir`/`declaration` (030), and the new capability objects in `src/cli/capabilities-observe-system.ts` stay plain literals because `tests/helpers/cli-capability-data.ts` rejects `/` tokens and host identifiers there (010). + +## PR review round 1 + +Reviewer feedback on the open stack is recorded, with owner decisions D1–D20, in +[060_review_round.md](060_review_round.md). Those decisions supersede conflicting text in 000–050, including +the public privacy statement change that must land with the first layer able to send. diff --git a/devlog/_plan/261107_telemetry_dashboard/010_client_core.md b/devlog/_plan/261107_telemetry_dashboard/010_client_core.md index 62a3cb13885..d6d39999357 100644 --- a/devlog/_plan/261107_telemetry_dashboard/010_client_core.md +++ b/devlog/_plan/261107_telemetry_dashboard/010_client_core.md @@ -21,7 +21,7 @@ client cannot emit a key or value the collector rejects and the two cannot drift - `MODEL_FAMILIES` (closed prefix buckets): gpt-6, gpt-5, gpt-4, o-series, codex, claude-opus, claude-sonnet, claude-haiku, gemini-3, gemini-2, grok, deepseek, qwen, kimi, glm, minimax, mistral, llama, other. -- `version` must match `^\d+\.\d+\.\d+(-preview\.\d+)?$` (no build metadata). +- `version` must match `^\d+\.\d+\.\d+(-preview\.\d{8}(\.\d+)?)?$` (no build metadata). ## State model diff --git a/devlog/_plan/261107_telemetry_dashboard/060_review_round.md b/devlog/_plan/261107_telemetry_dashboard/060_review_round.md new file mode 100644 index 00000000000..eceb29c3fc1 --- /dev/null +++ b/devlog/_plan/261107_telemetry_dashboard/060_review_round.md @@ -0,0 +1,76 @@ +# 060 — PR review round 1 + +Review feedback on the stack (#8293, #8300, #8303, #8304, #8305, #8308) from Ingwannu, CodeRabbit and the +Codex reviewer. These decisions supersede any conflicting text in 000–050. Each lands on the layer that +owns the code, and each layer's PR description records it. + +## Why the privacy statement changes (blocking) + +`README.md` (and its seven translations under `readme/`) and `docs-site/src/content/docs/reference/code-signing.md` +carry the SignPath Foundation sentence "This program will not transfer any information to other networked +systems unless specifically requested…". With default-on telemetry that sentence would be false. SignPath's +conditions (https://signpath.org/terms.html) allow software that sends data to systems the user did not +specify only if the behaviour is described in a privacy policy, shown during installation, and can be +disabled at installation. The replacement statement (D12) says exactly that, lands on layer 2 (the first +layer that can send), and layer 5 links it to the telemetry guide. The interactive install-time prompt and +the first-run dashboard popup are the "shown during installation, can be disabled" points. + +## Decisions + +D1 Consent-day cutoff: only UTC days strictly AFTER the UTC day of (decidedAt ?? noticeShownAt) are ever sent. +D2 Env opt-out: whenever OCX_TELEMETRY or DO_NOT_TRACK opt-out is observed (proxy startup in background-lifecycle even though the scheduler is not started, and on every tick), persist ineligibleThroughDay = today's UTC day in telemetry-state.json (atomic no-follow write) and delete the install id; only days strictly after ineligibleThroughDay are sent. Tests: consented user runs with OCX_TELEMETRY=0 for days, removes it, those days are never sent and a fresh id is minted. +D3 Version regex everywhere (contract): ^\d+\.\d+\.\d+(-preview\.\d{8}(\.\d+)?)?$ ; test vectors 2.40.1, 2.6.15-preview.20260701, 2.6.15-preview.20260701.1, 2.7.9-preview.20260712.2; reject build metadata. +D4 ocx telemetry enable: requires an interactive human terminal (isatty stdin AND stdout, not OCX_SERVICE, not isAgentDriven); prints the disclosure block, then asks a confirm (default yes) and records accepted; otherwise exits non-zero with agent_consent_required / tty_required pointing to the dashboard. disable always allowed. +D5 Disclosure block printed before every CLI Y/n prompt (ocx start/init/service install and enable): what is sent, what is never sent, that aggregates are public at opencodex.me/dashboard, how to see the exact payload (ocx telemetry status --json), how to turn it off (ocx telemetry disable, OCX_TELEMETRY=0, DO_NOT_TRACK=1, Proxy details toggle). A CLI test pins the text. +D6 ocx config import: for EVERY caller, preserve the persisted telemetry block unchanged (ignore imported telemetry). Tests: undecided and declined installs. +D7 Re-read persisted consent and env immediately before transmission; if not effective, abort without sending. Ticks carry an AbortSignal cancelled by stop and by consent changes (nudge). +D8 Aggregation accumulates counters inside onEntry for the target day only; never retains ledger rows. +D9 CLI enable/disable: if mutatePersistedConfig returns unavailable (or throws), exit non-zero with an actionable error, print no success line, and do not delete the id unless the decline persisted. +D10 GET /api/telemetry preview uses the same day the scheduler would send next (oldest eligible unsent day). If none is eligible, preview the latest completed day and return previewEligible:false (field added); never imply it will be sent. +D11 Client honors HTTP 429 Retry-After (and 503): persist retryAfterUntil in telemetry-state.json, skip ticks until then; spreads sends with jitter. +D12 Privacy statement (README.md, all 7 readme/README.*.md, docs-site/src/content/docs/reference/code-signing.md): replace the SignPath "will not transfer any information..." sentence with an accurate statement: opencodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to telemetry.opencodex.me only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off at install time in that prompt and anytime with ocx telemetry disable, OCX_TELEMETRY=0, DO_NOT_TRACK=1 or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. Keep the README Privacy line in English in every translation (as today) unless readme/i18n-manifest.json rules require otherwise. code-signing.md: also fix the "carry none of your ... usage data" claim. +D13 GUI popup dismissal (Escape/backdrop/close) records NOTHING; only Continue (PUT /api/telemetry) records decision and notice; the popup returns on the next app start while undecided. Remove POST /api/telemetry/notice entirely (server route, registry entry, capability exemptions, GUI call, tests, docs). +D14 Stats semantics: activeInstalls in totals and installs in breakdowns are INSTALL-DAYS (sum of daily reporting installs); add totals.avgDailyActiveInstalls (install-days / days in range, rounded to 1 decimal). Dashboard shows "Avg daily active installs" as the KPI with install-days in the tooltip. +D15 Small-cohort protection (collector): add an installs column (count of contributing reports) to daily_models and daily_errors (and dailyByProvider derivation); in every breakdown (providers, models, versions, platforms, channels, errors, dailyByProvider per day) fold rows with fewer than 10 install-days in the selected range into "other"; drop "other" if it is itself below 10; totals/daily/heatmap unchanged. Document the residual subtraction risk. +D16 Rate limit: 120 reports/hour per salted IP hash (shared IP / CGNAT capacity); 429 with Retry-After. +D17 Dashboard labels all figures "self-reported by opencodex installs"; docs note reports are unauthenticated and can be forged within rate limits. +D18 Collector-first rollout: any roster/contract change must be deployed to the collector before a client release emits it (README of collector + structure doc). +D19 Guide discloses the salted hourly IP hash for rate limiting (pruned after 2 hours). +D20 Dashboard: GitHub release download totals follow pagination (per_page=100, follow Link rel=next up to 10 pages, mark "partial" if capped); applying an empty snapshot clears the heatmap. + + +## Finding → decision map + +| Finding | Source | Decision | Layer | +| --- | --- | --- | --- | +| Published privacy statement becomes false | Ingwannu (blocking) | D12 | 2, 5 | +| Env opt-out days are backfilled | Ingwannu (blocking) | D2 | 2 | +| Consent day usage before the notice | Ingwannu | D1 | 2 | +| Preview tags rejected by the version regex | Ingwannu | D3 | 2 (contract) | +| activeInstalls over a range is a sum | Ingwannu | D14 | 4, 6 | +| CLI prompt is not a disclosure | Ingwannu | D5 | 2 | +| Opt-in wording | CodeRabbit | title/objective now say default-on (opt-out) | 1 | +| Roster changes break older collectors | CodeRabbit | D18 | 4, 5 | +| `ocx telemetry enable` without a TTY | CodeRabbit | D4 | 2 | +| Imported consent can backdate sending | CodeRabbit | D6 | 2 | +| Identity kept under an env opt-out | CodeRabbit | D2 | 2 | +| Preview day differs from the next send | CodeRabbit | D10 | 2, 3 | +| Automated dismissal records the notice | CodeRabbit | D13 | 2, 3 | +| Atomic dedup and rollup | CodeRabbit | already in #8304 (single conditional batch) | 4 | +| Forged reports | CodeRabbit | D17 (labelled self-reported; rate limit) | 4, 5, 6 | +| More than 64 model rows | CodeRabbit | already folded into `other`/`other` in #8300 | 2 | +| Shared-IP capacity and Retry-After | CodeRabbit | D11, D16 | 2, 4 | +| Small-cohort exposure | CodeRabbit | D15 | 4, 6 | +| IP-hash disclosure | CodeRabbit | D19 | 5 | +| Consent re-check before sending | Codex P1 | D7 | 2 | +| Aggregation keeps the whole ledger | Codex P2 | D8 | 2 | +| CLI reports unpersisted changes | Codex P2 | D9 | 2 | +| Public docs for the controls | Codex P1 (#8300, #8303) | already in #8305 | 5 | +| Release pagination, heatmap reset | Codex P2 (#8308) | D20 | 6 | + +## Residual risks + +- Reports are unauthenticated. Rate limits and the self-reported label bound, but do not prevent, forged + figures. +- Folding small rows into `other` limits single-install exposure; a suppressed value can still be + approximated by subtracting published rows from published totals when only one row is folded. From 9e0284d392b095e781abd32f024b67634bc5c33c Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 03:19:00 +0900 Subject: [PATCH 07/37] fix(telemetry): apply review round 1 client core, consent, and privacy decisions --- AGENTS_INSTALL.md | 2 +- README.md | 2 +- .../content/docs/reference/code-signing.md | 14 +- .../content/docs/tr/reference/code-signing.md | 16 +- readme/README.fr.md | 2 +- readme/README.ja.md | 2 +- readme/README.ko.md | 2 +- readme/README.ru.md | 2 +- readme/README.tr.md | 2 +- readme/README.zh-CN.md | 2 +- readme/README.zh-TW.md | 2 +- readme/i18n-manifest.json | 14 +- .../references/01_surface_observe-system.md | 8 +- src/cli/capabilities-observe-system.ts | 6 +- src/cli/config-command.ts | 18 +- src/cli/registry.ts | 2 +- src/cli/telemetry.ts | 82 +++- src/server/background-lifecycle.ts | 3 + src/server/management/route-registry.ts | 1 - src/server/management/telemetry-routes.ts | 4 - src/telemetry/aggregate.ts | 125 ++++--- src/telemetry/contract.ts | 2 +- src/telemetry/routes.ts | 50 +-- src/telemetry/scheduler.ts | 282 +++++++++++--- src/telemetry/sender.ts | 32 ++ structure/telemetry.md | 44 ++- tests/cli/cli-telemetry.test.ts | 123 +++++- tests/server/server-telemetry-routes.test.ts | 110 ++++-- tests/usage/usage-telemetry-client.test.ts | 353 +++++++++++++++++- 29 files changed, 1037 insertions(+), 270 deletions(-) diff --git a/AGENTS_INSTALL.md b/AGENTS_INSTALL.md index e5489ddc853..62402cc7f71 100644 --- a/AGENTS_INSTALL.md +++ b/AGENTS_INSTALL.md @@ -57,7 +57,7 @@ The following instructions concern **starring the repository on GitHub**. Anonymous daily usage telemetry defaults to on only after a human has seen the disclosure notice (in the GUI or an interactive TTY prompt). An agent must never enable telemetry or acknowledge the telemetry disclosure on the user's behalf: -`PUT /api/telemetry`, `POST /api/telemetry/notice`, `ocx telemetry enable`, and +`PUT /api/telemetry`, `ocx telemetry enable`, and `ocx config set telemetry` refuse agent-driven callers with `agent_consent_required`. Opting out (`ocx telemetry disable` or `OCX_TELEMETRY=0`) is always permitted. diff --git a/README.md b/README.md index 8197f747220..e7f3df50651 100644 --- a/README.md +++ b/README.md @@ -498,7 +498,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: This program will not transfer any information to other networked systems unless specifically requested by the user or the person installing or operating it. +- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off at install time in that prompt and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## Disclaimer diff --git a/docs-site/src/content/docs/reference/code-signing.md b/docs-site/src/content/docs/reference/code-signing.md index a85a1b381cf..356c5937c68 100644 --- a/docs-site/src/content/docs/reference/code-signing.md +++ b/docs-site/src/content/docs/reference/code-signing.md @@ -30,11 +30,15 @@ SignPath Foundation policy forbids signing them with the project certificate. ### Privacy -This program will not transfer any information to other networked systems unless specifically -requested by the user or the person installing or operating it. In practice, OpenCodex forwards -requests to the providers you configure. To look for new versions it also reads release -metadata from GitHub and the npm registry, and those checks carry none of your prompts, -credentials or usage data. +OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, +outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person +installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and +has not declined; it can be turned off at install time in that prompt and anytime with +`ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide +aggregates are public; otherwise data goes only where the user configures. In practice, +OpenCodex forwards requests to the providers you configure. To look for new versions it also +reads release metadata from GitHub and the npm registry, and those update checks carry none of +your prompts, credentials or usage data. ## Verifying a download diff --git a/docs-site/src/content/docs/tr/reference/code-signing.md b/docs-site/src/content/docs/tr/reference/code-signing.md index d88d0866aed..890146fa9bf 100644 --- a/docs-site/src/content/docs/tr/reference/code-signing.md +++ b/docs-site/src/content/docs/tr/reference/code-signing.md @@ -31,11 +31,17 @@ imzalanmasını yasaklar. ### Gizlilik -Bu program; kullanıcı, kuran kişi veya çalıştıran kişi açıkça istemedikçe başka -ağ sistemlerine bilgi aktarmaz. OpenCodex, istekleri yapılandırdığınız sağlayıcılara -iletir. Yeni sürümler için GitHub ve npm kayıt deposundan yayın meta verilerini -de okur; bu kontroller istemlerinizi, kimlik bilgilerinizi veya kullanım verilerinizi -taşımaz. +OpenCodex, yalnızca programı kuran veya çalıştıran kişi bildirimi (kontrol paneli +açılır penceresi veya etkileşimli CLI istemi) gördükten ve reddetmedikten sonra +`telemetry.opencodex.me` adresine günde bir anonim kullanım özeti (sürüm, platform, +sayımlar; istemler, çıktılar, anahtarlar, hesaplar, ana bilgisayar adları veya dosya +yolları asla dahil edilmez) gönderir; kurulum sırasında bu istemden ve istediğiniz +zaman `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` veya Proxy +details üzerinden kapatılabilir; proje genelindeki toplamlar herkese açıktır; bunun +dışında veriler yalnızca kullanıcının yapılandırdığı yerlere gider. Uygulamada +OpenCodex, istekleri yapılandırdığınız sağlayıcılara iletir. Yeni sürümler için +GitHub ve npm kayıt deposundan yayın meta verilerini de okur; bu güncelleme +kontrolleri istemlerinizi, kimlik bilgilerinizi veya kullanım verilerinizi taşımaz. ## İndirmeyi doğrulama diff --git a/readme/README.fr.md b/readme/README.fr.md index 7613afe8b65..cc8d08dff5f 100644 --- a/readme/README.fr.md +++ b/readme/README.fr.md @@ -496,7 +496,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: This program will not transfer any information to other networked systems unless specifically requested by the user or the person installing or operating it. +- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off at install time in that prompt and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## Avis de non-responsabilité diff --git a/readme/README.ja.md b/readme/README.ja.md index b396a9d0fa1..5762cd62484 100644 --- a/readme/README.ja.md +++ b/readme/README.ja.md @@ -490,7 +490,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: This program will not transfer any information to other networked systems unless specifically requested by the user or the person installing or operating it. +- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off at install time in that prompt and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## 免責事項 diff --git a/readme/README.ko.md b/readme/README.ko.md index 5a3530c674e..8693f1e68ff 100644 --- a/readme/README.ko.md +++ b/readme/README.ko.md @@ -473,7 +473,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: This program will not transfer any information to other networked systems unless specifically requested by the user or the person installing or operating it. +- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off at install time in that prompt and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## 면책 조항 diff --git a/readme/README.ru.md b/readme/README.ru.md index 0b8428934ce..368a5899490 100644 --- a/readme/README.ru.md +++ b/readme/README.ru.md @@ -502,7 +502,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: This program will not transfer any information to other networked systems unless specifically requested by the user or the person installing or operating it. +- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off at install time in that prompt and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## Отказ от ответственности diff --git a/readme/README.tr.md b/readme/README.tr.md index dd1eda9f03b..fe0aae5f81f 100644 --- a/readme/README.tr.md +++ b/readme/README.tr.md @@ -498,7 +498,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: This program will not transfer any information to other networked systems unless specifically requested by the user or the person installing or operating it. +- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off at install time in that prompt and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## Sorumluluk reddi diff --git a/readme/README.zh-CN.md b/readme/README.zh-CN.md index a56eac15ba2..7c1aa50300d 100644 --- a/readme/README.zh-CN.md +++ b/readme/README.zh-CN.md @@ -471,7 +471,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: This program will not transfer any information to other networked systems unless specifically requested by the user or the person installing or operating it. +- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off at install time in that prompt and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## 免责声明 diff --git a/readme/README.zh-TW.md b/readme/README.zh-TW.md index 78cacab3696..8cc2b645b03 100644 --- a/readme/README.zh-TW.md +++ b/readme/README.zh-TW.md @@ -470,7 +470,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: This program will not transfer any information to other networked systems unless specifically requested by the user or the person installing or operating it. +- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off at install time in that prompt and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## 免責聲明 diff --git a/readme/i18n-manifest.json b/readme/i18n-manifest.json index 80ed6b2aa0c..86421b84262 100644 --- a/readme/i18n-manifest.json +++ b/readme/i18n-manifest.json @@ -6,43 +6,43 @@ "file": "readme/README.fr.md", "label": "Français", "docsPath": "fr", - "sourceSha256": "f0f17270033cd0af45d92506a6386420b7e509eac18457dac050b8f689789230" + "sourceSha256": "b47453dc6ff893a9ddeccfcab475d11b99954943a7ddcb5252aadfa751d4f3e4" }, "ko": { "file": "readme/README.ko.md", "label": "한국어", "docsPath": "ko", - "sourceSha256": "f0f17270033cd0af45d92506a6386420b7e509eac18457dac050b8f689789230" + "sourceSha256": "b47453dc6ff893a9ddeccfcab475d11b99954943a7ddcb5252aadfa751d4f3e4" }, "zh-CN": { "file": "readme/README.zh-CN.md", "label": "简体中文", "docsPath": "zh-cn", - "sourceSha256": "f0f17270033cd0af45d92506a6386420b7e509eac18457dac050b8f689789230" + "sourceSha256": "b47453dc6ff893a9ddeccfcab475d11b99954943a7ddcb5252aadfa751d4f3e4" }, "zh-TW": { "file": "readme/README.zh-TW.md", "label": "繁體中文", "docsPath": "zh-tw", - "sourceSha256": "f0f17270033cd0af45d92506a6386420b7e509eac18457dac050b8f689789230" + "sourceSha256": "b47453dc6ff893a9ddeccfcab475d11b99954943a7ddcb5252aadfa751d4f3e4" }, "ru": { "file": "readme/README.ru.md", "label": "Русский", "docsPath": "ru", - "sourceSha256": "f0f17270033cd0af45d92506a6386420b7e509eac18457dac050b8f689789230" + "sourceSha256": "b47453dc6ff893a9ddeccfcab475d11b99954943a7ddcb5252aadfa751d4f3e4" }, "ja": { "file": "readme/README.ja.md", "label": "日本語", "docsPath": "ja", - "sourceSha256": "f0f17270033cd0af45d92506a6386420b7e509eac18457dac050b8f689789230" + "sourceSha256": "b47453dc6ff893a9ddeccfcab475d11b99954943a7ddcb5252aadfa751d4f3e4" }, "tr": { "file": "readme/README.tr.md", "label": "Türkçe", "docsPath": "tr", - "sourceSha256": "f0f17270033cd0af45d92506a6386420b7e509eac18457dac050b8f689789230" + "sourceSha256": "b47453dc6ff893a9ddeccfcab475d11b99954943a7ddcb5252aadfa751d4f3e4" } } } diff --git a/skills/ocx/references/01_surface_observe-system.md b/skills/ocx/references/01_surface_observe-system.md index 2e132e543d2..97b2c18f044 100644 --- a/skills/ocx/references/01_surface_observe-system.md +++ b/skills/ocx/references/01_surface_observe-system.md @@ -1876,7 +1876,7 @@ State-changing: no. JSON mode: `payload`. Usage: `ocx telemetry status [--json]` -Inspect anonymous telemetry consent status and the next daily payload preview. +Inspect anonymous telemetry consent status and the daily payload preview. State-changing: no. JSON mode: `payload`. @@ -1889,7 +1889,7 @@ State-changing: no. JSON mode: `payload`. | `--json` | boolean | Emit the telemetry status and preview as JSON. | - Reads live status from the running proxy and falls back to local configuration and ledger preview when the proxy is stopped. -- Never creates or transmits a telemetry install identifier. +- Previews the oldest eligible unsent day, or the latest completed day with previewEligible = false when no day is eligible. Never creates or transmits a telemetry install identifier. ### `ocx telemetry enable` @@ -1905,8 +1905,8 @@ Drives no management route. |---|---|---| | `--json` | boolean | Emit the updated telemetry status as JSON. | -- Writes telemetry.decision = accepted and telemetry.noticeShownAt in local config.json. -- Refuses with agent_consent_required when called from an agent-driven session. +- Requires an interactive human terminal, prints the telemetry disclosure block, and asks for confirmation before recording telemetry.decision = accepted. +- Refuses with agent_consent_required when agent-driven and with tty_required when non-interactive or running under OCX_SERVICE. ### `ocx telemetry disable` diff --git a/src/cli/capabilities-observe-system.ts b/src/cli/capabilities-observe-system.ts index 4692f318666..2be0edcea69 100644 --- a/src/cli/capabilities-observe-system.ts +++ b/src/cli/capabilities-observe-system.ts @@ -732,12 +732,12 @@ export const OBSERVE_SYSTEM_CAPABILITIES: readonly Capability[] = [ { command: ["telemetry", "status"], usage: "ocx telemetry status [--json]", - summary: "Inspect anonymous telemetry consent status and the next daily payload preview.", + summary: "Inspect anonymous telemetry consent status and the daily payload preview.", routes: [{"method": "GET", "path": "/api/telemetry"}], flags: [{"name": "--json", "value": "boolean", "summary": "Emit the telemetry status and preview as JSON."}], mutates: false, json: "payload", - details: ["Reads live status from the running proxy and falls back to local configuration and ledger preview when the proxy is stopped.", "Never creates or transmits a telemetry install identifier."], + details: ["Reads live status from the running proxy and falls back to local configuration and ledger preview when the proxy is stopped.", "Previews the oldest eligible unsent day, or the latest completed day with previewEligible = false when no day is eligible. Never creates or transmits a telemetry install identifier."], }, { command: ["telemetry", "enable"], @@ -747,7 +747,7 @@ export const OBSERVE_SYSTEM_CAPABILITIES: readonly Capability[] = [ flags: [{"name": "--json", "value": "boolean", "summary": "Emit the updated telemetry status as JSON."}], mutates: true, json: "envelope", - details: ["Writes telemetry.decision = accepted and telemetry.noticeShownAt in local config.json.", "Refuses with agent_consent_required when called from an agent-driven session."], + details: ["Requires an interactive human terminal, prints the telemetry disclosure block, and asks for confirmation before recording telemetry.decision = accepted.", "Refuses with agent_consent_required when agent-driven and with tty_required when non-interactive or running under OCX_SERVICE."], }, { command: ["telemetry", "disable"], diff --git a/src/cli/config-command.ts b/src/cli/config-command.ts index 001496c574b..9ff69f6b0df 100644 --- a/src/cli/config-command.ts +++ b/src/cli/config-command.ts @@ -9,7 +9,6 @@ import type { OcxConfig } from "../types"; import { normalizeVisionReasoningForModel } from "../vision/reasoning"; import type { ServiceApiTokenState } from "../lib/service-secrets"; import { redactUrlForLog } from "../lib/redact"; -import { isAgentDriven } from "./agent-driven"; import { CliUsageError, printData, rejectArgs, runCliAction, takeFlag } from "./runtime-api"; const USAGE = `Usage: @@ -336,11 +335,10 @@ export async function handleConfigCommand(argv: string[]): Promise { rejectArgs(args, USAGE); await withConfigDirHardeningSettled(getConfigDir(), () => { const current = readCliConfigForMutation(); - const agentDriven = isAgentDriven(); const rawInput = loadInput(path); - if (agentDriven && rawInput && typeof rawInput === "object" && !Array.isArray(rawInput) && Object.hasOwn(rawInput, "telemetry")) { + if (rawInput && typeof rawInput === "object" && !Array.isArray(rawInput) && Object.hasOwn(rawInput, "telemetry")) { delete (rawInput as Record).telemetry; - console.warn("Note: dropped telemetry block during agent-driven config import; use `ocx telemetry` to manage telemetry consent."); + console.warn("Note: ignored telemetry block during config import; use `ocx telemetry` to manage telemetry consent."); } const imported = validate(rawInput); withConfigMutationLockSync(() => { @@ -349,13 +347,11 @@ export async function handleConfigCommand(argv: string[]): Promise { || JSON.stringify(imported.client) !== JSON.stringify(client.value))) { throw new Error("config import refused: disconnect the active client before replacing its connection"); } - if (agentDriven) { - const persistedTelemetry = readConfigDiagnostics({ passive: true }).config.telemetry ?? current.telemetry; - if (persistedTelemetry !== undefined) { - imported.telemetry = structuredClone(persistedTelemetry); - } else { - delete imported.telemetry; - } + const persistedTelemetry = readConfigDiagnostics({ passive: true }).config.telemetry ?? current.telemetry; + if (persistedTelemetry !== undefined) { + imported.telemetry = structuredClone(persistedTelemetry); + } else { + delete imported.telemetry; } saveConfig(imported); }); diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 749de00e31e..592d6c766fd 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -840,7 +840,7 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ summary: "Inspect or change anonymous daily usage telemetry consent and preview the payload.", details: [ "status [--json] Show effective state, reason, install channel, last sent day, and exact preview payload.", - "enable [--json] Opt in to anonymous telemetry (refused with agent_consent_required when agent-driven).", + "enable [--json] Confirm and opt in to anonymous telemetry in an interactive terminal (refused with agent_consent_required or tty_required otherwise).", "disable [--json] Opt out of anonymous telemetry and delete telemetry-id.json.", ], }, diff --git a/src/cli/telemetry.ts b/src/cli/telemetry.ts index 6433278018c..b89cfd2fa5e 100644 --- a/src/cli/telemetry.ts +++ b/src/cli/telemetry.ts @@ -30,6 +30,19 @@ const USAGE = `Usage: export const TELEMETRY_DEFERRAL_FILE = ".telemetry-deferred"; const DEFERRAL_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; +export const TELEMETRY_CLI_DISCLOSURE_LINES = Object.freeze([ + " Anonymous daily usage telemetry:", + " • Sent once per day to telemetry.opencodex.me: version, OS/arch, install channel, provider kind, coarse model family, active minutes, and request/token/error counts.", + " • Never sent: prompts, model outputs, request/response bodies, API keys, OAuth tokens, account IDs or emails, custom base URLs, hostnames, file paths, or error text.", + " • Public aggregates: project-wide totals are published at opencodex.me/dashboard.", + " • See the exact payload: ocx telemetry status --json", + " • Turn off anytime: ocx telemetry disable, OCX_TELEMETRY=0, DO_NOT_TRACK=1, or the Proxy details toggle in the dashboard.", +]); + +export function printTelemetryDisclosure(): void { + console.log(TELEMETRY_CLI_DISCLOSURE_LINES.join("\n")); +} + export function isTelemetryDeferralCurrent(record: string | null, version: string, now: number): boolean { if (!record) return false; const match = /^(\S+)\s+(\S+)\s*$/.exec(record.trim()); @@ -52,10 +65,18 @@ export function setTelemetryPromptDepsForTests(deps: TelemetryPromptTestDeps | n promptDepsForTests = deps; } +function isInteractiveTty(): boolean { + try { + return promptDepsForTests?.isTty ? promptDepsForTests.isTty() : Boolean(isatty(0) && isatty(1)); + } catch { + return false; + } +} + async function persistTelemetryDecision(decision: OcxTelemetryDecision): Promise { const nowIso = new Date().toISOString(); const dir = getConfigDir(); - await withConfigDirHardeningSettled(dir, () => + const outcome = await withConfigDirHardeningSettled(dir, () => mutatePersistedConfig(fresh => { const prev = fresh.telemetry; const next: OcxTelemetryConfig = { @@ -68,6 +89,11 @@ async function persistTelemetryDecision(decision: OcxTelemetryDecision): Promise return { changed, value: next }; }), ); + if (outcome.status === "unavailable") { + throw new Error( + `Cannot update telemetry configuration: config.json is ${outcome.reason}. Fix or restore config.json before retrying.`, + ); + } if (decision === "declined") { deleteTelemetryInstallId(dir); } @@ -85,12 +111,7 @@ export async function maybeShowTelemetryPrompt(): Promise { if (status.envOverride !== null || status.decision !== null || status.noticeShownAt !== null) { return; } - let isTty = false; - try { - isTty = promptDepsForTests?.isTty ? promptDepsForTests.isTty() : isatty(0) && isatty(1); - } catch { - /* best-effort */ - } + const isTty = isInteractiveTty(); const dir = getConfigDir(); if (isAgentDriven() || !isTty || Boolean(process.env.OCX_SERVICE)) { const deferralPath = join(dir, TELEMETRY_DEFERRAL_FILE); @@ -113,6 +134,7 @@ export async function maybeShowTelemetryPrompt(): Promise { } catch { /* best-effort */ } return; } + printTelemetryDisclosure(); const ask = promptDepsForTests?.interactiveConfirm ?? interactiveConfirm; const yes = await ask({ question: "\n Share anonymous usage statistics? [Y/n]", @@ -131,7 +153,10 @@ function formatStatusLines(snapshot: TelemetryStatusSnapshot): string[] { ...(snapshot.envOverride ? [`Environment override: ${snapshot.envOverride}`] : []), `Install channel: ${snapshot.installChannel}`, `Last sent day: ${snapshot.lastSentDay ?? "never"}`, - `Preview (${snapshot.preview.day}):`, + `Preview eligible: ${snapshot.previewEligible ? "yes" : "no"}`, + snapshot.previewEligible + ? `Preview (${snapshot.preview.day}, next eligible send):` + : `Preview (${snapshot.preview.day}, sample only — not eligible to send):`, JSON.stringify(snapshot.preview, null, 2), ]; } @@ -149,15 +174,29 @@ export async function handleTelemetryCommand(argv: string[], deps: RuntimeApiDep const args = [...argv]; const wantsJson = takeFlag(args, "--json"); const sub = (args.shift() ?? "status").toLowerCase(); - if (sub === "enable" && isAgentDriven()) { - const error = { - ok: false, - code: "agent_consent_required", - error: "Refused: telemetry consent must be enabled by a human user in their own terminal or dashboard.", - }; - if (wantsJson) console.log(JSON.stringify(error, null, 2)); - else console.error(`${error.code}: ${error.error}`); - return 1; + if (sub === "enable") { + if (isAgentDriven()) { + const error = { + ok: false, + code: "agent_consent_required", + error: + "Refused: telemetry consent must be enabled by a human user in their own interactive terminal or in the dashboard (Proxy details).", + }; + if (wantsJson) console.log(JSON.stringify(error, null, 2)); + else console.error(`${error.code}: ${error.error}`); + return 1; + } + if (Boolean(process.env.OCX_SERVICE) || !isInteractiveTty()) { + const error = { + ok: false, + code: "tty_required", + error: + "Refused: 'ocx telemetry enable' requires an interactive human terminal (TTY stdin and stdout, not OCX_SERVICE). Enable telemetry in an interactive terminal or in the dashboard (Proxy details).", + }; + if (wantsJson) console.log(JSON.stringify(error, null, 2)); + else console.error(`${error.code}: ${error.error}`); + return 1; + } } return runCliAction(async () => { if (args.includes("--json")) { @@ -171,6 +210,15 @@ export async function handleTelemetryCommand(argv: string[], deps: RuntimeApiDep } if (sub === "enable") { rejectArgs(args, USAGE); + printTelemetryDisclosure(); + const ask = promptDepsForTests?.interactiveConfirm ?? interactiveConfirm; + const yes = await ask({ + question: "\n Share anonymous usage statistics? [Y/n]", + defaultYes: true, + }); + if (!yes) { + throw new Error("Telemetry enable cancelled: confirmation was declined."); + } await persistTelemetryDecision("accepted"); const snapshot = await buildTelemetryStatusSnapshot(readConfigDiagnostics({ passive: true }).config); printData({ ok: true, ...snapshot }, wantsJson, ["Anonymous telemetry enabled."]); diff --git a/src/server/background-lifecycle.ts b/src/server/background-lifecycle.ts index 2661fff01d3..abfcc5e67d9 100644 --- a/src/server/background-lifecycle.ts +++ b/src/server/background-lifecycle.ts @@ -33,6 +33,7 @@ import { type MemoryWatchdog, } from "./memory-watchdog"; import { ensureQuotaPoolIdentity } from "../quota-pool/identity"; +import { recordTelemetryEnvOptOut } from "../telemetry/scheduler"; type PolicyApply = (policy: StorageCleanupPolicy) => void; @@ -143,6 +144,8 @@ function startProcessLoops(applyPolicy: PolicyApply): ProcessLoops { .catch(() => { // Optional background loop. }); + } else { + try { recordTelemetryEnvOptOut(); } catch { /* best-effort */ } } return { memoryWatchdog, stateStoreSweeper }; } catch (error) { diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index 119c45f0156..5f1f20f7a11 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -454,7 +454,6 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ // server/management/telemetry-routes { method: "GET", path: "/api/telemetry", module: "server/management/telemetry-routes", mutates: false }, { method: "PUT", path: "/api/telemetry", module: "server/management/telemetry-routes", mutates: true, exempt: { reason: "session-only", why: "User-consent boundary in AGENTS_INSTALL.md: changing telemetry consent over HTTP requires a human dashboard session; CLI callers use ocx telemetry enable/disable." } }, - { method: "POST", path: "/api/telemetry/notice", module: "server/management/telemetry-routes", mutates: true, exempt: { reason: "session-only", why: "Recording that the disclosure notice was seen activates default-on telemetry and requires a human dashboard session." } }, // --- Further routes an equality scan of their own file cannot see. --- // Each carries `mechanism`; the reconciliation test counts these separately. { method: "GET", path: "/api/storage", module: "server/management/storage-log-guard-routes", mutates: false, mechanism: "negated-guard" }, diff --git a/src/server/management/telemetry-routes.ts b/src/server/management/telemetry-routes.ts index 3739c5380b7..834a17af9e9 100644 --- a/src/server/management/telemetry-routes.ts +++ b/src/server/management/telemetry-routes.ts @@ -1,7 +1,6 @@ import type { ManagementContext } from "./context"; import { handleGetTelemetryRoute, - handlePostTelemetryNoticeRoute, handlePutTelemetryRoute, } from "../../telemetry/routes"; @@ -13,8 +12,5 @@ export async function handleTelemetryRoutes(ctx: ManagementContext): Promise, -): DailyTelemetryAggregate { +export interface DailyTelemetryAccumulator { + onEntry: (entry: PersistedUsageEntry) => void; + finalize: () => DailyTelemetryAggregate; +} + +/** + * Streaming accumulator for one completed UTC day. + * + * Accumulates counters directly inside `onEntry` for entries falling inside `day` + * and never retains `PersistedUsageEntry` rows in memory. + */ +export function createDailyTelemetryAccumulator(day: string): DailyTelemetryAccumulator { const dayStartMs = parseCalendarUtcDayMs(day); if (dayStartMs === null) { throw new Error(`Invalid UTC day: ${day}`); @@ -244,9 +252,9 @@ export function aggregateTelemetryEntriesForDay( row.reasoningTokens += tokens.reasoningTokens; }; - for (const entry of entries) { + const onEntry = (entry: PersistedUsageEntry): void => { if (typeof entry.timestamp !== "number" || entry.timestamp < dayStartMs || entry.timestamp >= dayEndMs) { - continue; + return; } minutes.add(Math.floor(entry.timestamp / 60_000)); const outcome = classifyRequestOutcome(entry); @@ -264,64 +272,77 @@ export function aggregateTelemetryEntriesForDay( } else { addModelAttribution(entry.provider, entry.model, entry.usage); } - } + }; - let models = [...modelMap.values()].sort((a, b) => { - if (b.requests !== a.requests) return b.requests - a.requests; - const aTokens = a.inputTokens + a.cacheReadTokens + a.cacheWriteTokens + a.outputTokens + a.reasoningTokens; - const bTokens = b.inputTokens + b.cacheReadTokens + b.cacheWriteTokens + b.outputTokens + b.reasoningTokens; - if (bTokens !== aTokens) return bTokens - aTokens; - if (a.providerKind !== b.providerKind) return a.providerKind.localeCompare(b.providerKind); - return a.modelFamily.localeCompare(b.modelFamily); - }); + const finalize = (): DailyTelemetryAggregate => { + let models = [...modelMap.values()].sort((a, b) => { + if (b.requests !== a.requests) return b.requests - a.requests; + const aTokens = a.inputTokens + a.cacheReadTokens + a.cacheWriteTokens + a.outputTokens + a.reasoningTokens; + const bTokens = b.inputTokens + b.cacheReadTokens + b.cacheWriteTokens + b.outputTokens + b.reasoningTokens; + if (bTokens !== aTokens) return bTokens - aTokens; + if (a.providerKind !== b.providerKind) return a.providerKind.localeCompare(b.providerKind); + return a.modelFamily.localeCompare(b.modelFamily); + }); - if (models.length > TELEMETRY_MAX_MODELS) { - const kept = models.slice(0, TELEMETRY_MAX_MODELS - 1); - const overflow = models.slice(TELEMETRY_MAX_MODELS - 1); - let otherRow = kept.find(r => r.providerKind === "other" && r.modelFamily === "other"); - if (!otherRow) { - otherRow = { - providerKind: "other", - modelFamily: "other", - requests: 0, - inputTokens: 0, - cacheReadTokens: 0, - cacheWriteTokens: 0, - outputTokens: 0, - reasoningTokens: 0, - }; - kept.push(otherRow); - } - for (const row of overflow) { - otherRow.requests += row.requests; - otherRow.inputTokens += row.inputTokens; - otherRow.cacheReadTokens += row.cacheReadTokens; - otherRow.cacheWriteTokens += row.cacheWriteTokens; - otherRow.outputTokens += row.outputTokens; - otherRow.reasoningTokens += row.reasoningTokens; + if (models.length > TELEMETRY_MAX_MODELS) { + const kept = models.slice(0, TELEMETRY_MAX_MODELS - 1); + const overflow = models.slice(TELEMETRY_MAX_MODELS - 1); + let otherRow = kept.find(r => r.providerKind === "other" && r.modelFamily === "other"); + if (!otherRow) { + otherRow = { + providerKind: "other", + modelFamily: "other", + requests: 0, + inputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + }; + kept.push(otherRow); + } + for (const row of overflow) { + otherRow.requests += row.requests; + otherRow.inputTokens += row.inputTokens; + otherRow.cacheReadTokens += row.cacheReadTokens; + otherRow.cacheWriteTokens += row.cacheWriteTokens; + otherRow.outputTokens += row.outputTokens; + otherRow.reasoningTokens += row.reasoningTokens; + } + models = kept; } - models = kept; - } - return { - day, - activeMinutes: Math.min(1440, minutes.size), - requests, - models, - errors, + return { + day, + activeMinutes: Math.min(1440, minutes.size), + requests: { ...requests }, + models, + errors: { ...errors }, + }; }; + + return { onEntry, finalize }; +} + +export function aggregateTelemetryEntriesForDay( + day: string, + entries: Iterable, +): DailyTelemetryAggregate { + const accumulator = createDailyTelemetryAccumulator(day); + for (const entry of entries) { + accumulator.onEntry(entry); + } + return accumulator.finalize(); } export async function aggregateTelemetryForDay( day: string, options: { signal?: AbortSignal } = {}, ): Promise { - const entries: PersistedUsageEntry[] = []; + const accumulator = createDailyTelemetryAccumulator(day); await scanUsageLedgerCooperatively({ signal: options.signal, - onEntry: entry => { - entries.push(entry); - }, + onEntry: accumulator.onEntry, }); - return aggregateTelemetryEntriesForDay(day, entries); + return accumulator.finalize(); } diff --git a/src/telemetry/contract.ts b/src/telemetry/contract.ts index 2e645963f9a..20d23d1dcc5 100644 --- a/src/telemetry/contract.ts +++ b/src/telemetry/contract.ts @@ -8,7 +8,7 @@ export const TELEMETRY_SCHEMA_VERSION = 1 as const; export const TELEMETRY_MAX_MODELS = 64; -export const TELEMETRY_VERSION_RE = /^\d+\.\d+\.\d+(-preview\.\d+)?$/; +export const TELEMETRY_VERSION_RE = /^\d+\.\d+\.\d+(-preview\.\d{8}(\.\d+)?)?$/; export const PROVIDER_KINDS = Object.freeze([ "openai", diff --git a/src/telemetry/routes.ts b/src/telemetry/routes.ts index 65be7e621eb..13fb34bdd59 100644 --- a/src/telemetry/routes.ts +++ b/src/telemetry/routes.ts @@ -15,12 +15,18 @@ import { readTelemetryInstallId, } from "./install-id"; import { buildTelemetryPayloadV1, resolveInstallChannel } from "./payload"; -import { latestCompletedUtcDay, nudgeTelemetryScheduler, readTelemetryState } from "./scheduler"; +import { + latestCompletedUtcDay, + nudgeTelemetryScheduler, + readTelemetryState, + resolveNextEligibleTelemetryDay, +} from "./scheduler"; export interface TelemetryStatusSnapshot extends TelemetryConsentStatus { installChannel: TelemetryInstallChannel; lastSentDay: string | null; lastSentAt: string | null; + previewEligible: boolean; preview: TelemetryPayloadV1; } @@ -34,7 +40,9 @@ export async function buildTelemetryStatusSnapshot( const status = resolveTelemetryStatus(config, env); const installChannel = resolveInstallChannel({ env }); const state = readTelemetryState(configDir); - const day = latestCompletedUtcDay(now); + const { day: nextEligibleDay } = resolveNextEligibleTelemetryDay(status, state, now); + const previewEligible = nextEligibleDay !== null; + const day = nextEligibleDay ?? latestCompletedUtcDay(now); const aggregate = await aggregateTelemetryForDay(day); const identity = readTelemetryInstallId(configDir); const installDayHash = identity @@ -55,6 +63,7 @@ export async function buildTelemetryStatusSnapshot( installChannel, lastSentDay: state.lastSentDay, lastSentAt: state.lastSentAt, + previewEligible, preview, }; } @@ -133,40 +142,3 @@ export async function handlePutTelemetryRoute(ctx: ManagementContext): Promise { - if (!hasHumanDashboardSession(ctx)) { - return consentRefusedResponse(ctx); - } - const nowIso = new Date().toISOString(); - let committed: OcxTelemetryConfig | undefined; - const outcome = await withConfigDirHardeningSettled(getConfigDir(), () => - mutatePersistedConfig(fresh => { - const prev = fresh.telemetry; - if (prev?.noticeShownAt && !Number.isNaN(Date.parse(prev.noticeShownAt))) { - committed = structuredClone(prev); - return { changed: false, value: prev }; - } - const next: OcxTelemetryConfig = { - ...(prev?.decision ? { decision: prev.decision } : {}), - ...(prev?.decidedAt ? { decidedAt: prev.decidedAt } : {}), - noticeShownAt: nowIso, - }; - fresh.telemetry = next; - committed = structuredClone(next); - return { changed: true, value: next }; - }), - ); - if (outcome.status === "unavailable") { - return jsonResponse( - { error: `Configuration is ${outcome.reason}`, code: "config_unavailable" }, - 503, - ctx.req, - ctx.config, - ); - } - adoptPersistedTelemetry(ctx.config, committed); - nudgeTelemetryScheduler(); - const snapshot = await buildTelemetryStatusSnapshot(ctx.config); - return jsonResponse({ ok: true, ...snapshot }, 200, ctx.req, ctx.config); -} diff --git a/src/telemetry/scheduler.ts b/src/telemetry/scheduler.ts index 4d98a9562d3..61ac771cc48 100644 --- a/src/telemetry/scheduler.ts +++ b/src/telemetry/scheduler.ts @@ -7,13 +7,22 @@ import { recordOwnedConfigPath } from "../lib/config-ownership"; import { registerOptionalShutdownHook } from "../lib/optional-shutdown-hooks"; import type { OcxConfig } from "../types"; import { aggregateTelemetryForDay, type DailyTelemetryAggregate } from "./aggregate"; -import { isTelemetryEnvOptOut, resolveTelemetryStatus } from "./consent"; -import { computeInstallDayHash, ensureTelemetryInstallId, markTelemetryFirstReportSent } from "./install-id"; +import { isTelemetryEnvOptOut, resolveTelemetryStatus, type TelemetryConsentStatus } from "./consent"; +import { parseCalendarUtcDayMs } from "./contract"; +import { + computeInstallDayHash, + deleteTelemetryInstallId, + ensureTelemetryInstallId, + markTelemetryFirstReportSent, +} from "./install-id"; import { buildTelemetryPayloadV1, resolveInstallChannel, type ResolveInstallChannelOptions } from "./payload"; import { sendTelemetryPayload, type TelemetrySendOptions, type TelemetrySendResult } from "./sender"; export const TELEMETRY_STATE_FILE = "telemetry-state.json"; export const TELEMETRY_TICK_INTERVAL_MS = 6 * 60 * 60 * 1000; +export const TELEMETRY_STARTUP_BASE_DELAY_MS = 30_000; +export const TELEMETRY_STARTUP_JITTER_MS = 60_000; +export const TELEMETRY_INTERVAL_JITTER_MS = 15 * 60 * 1000; const DAY_MS = 86_400_000; const MAX_BACKLOG_DAYS = 7; const STATE_RETENTION_DAYS = 30; @@ -23,6 +32,8 @@ export interface TelemetryState { sentDays: Record; lastSentDay: string | null; lastSentAt: string | null; + ineligibleThroughDay: string | null; + retryAfterUntil: string | null; } export function telemetryStatePath(configDir = getConfigDir()): string { @@ -35,6 +46,8 @@ function emptyState(): TelemetryState { sentDays: {}, lastSentDay: null, lastSentAt: null, + ineligibleThroughDay: null, + retryAfterUntil: null, }; } @@ -53,11 +66,21 @@ export function readTelemetryState(configDir = getConfigDir()): TelemetryState { sentDays[day] = at; } } + const ineligibleThroughDay = + typeof raw.ineligibleThroughDay === "string" && parseCalendarUtcDayMs(raw.ineligibleThroughDay) !== null + ? raw.ineligibleThroughDay + : null; + const retryAfterUntil = + typeof raw.retryAfterUntil === "string" && !Number.isNaN(Date.parse(raw.retryAfterUntil)) + ? raw.retryAfterUntil + : null; return { version: 1, sentDays, lastSentDay: typeof raw.lastSentDay === "string" ? raw.lastSentDay : null, lastSentAt: typeof raw.lastSentAt === "string" ? raw.lastSentAt : null, + ineligibleThroughDay, + retryAfterUntil, }; } catch { return emptyState(); @@ -70,18 +93,34 @@ export function writeTelemetryState(state: TelemetryState, configDir = getConfig try { recordOwnedConfigPath(configDir, path); } catch { /* best-effort */ } const minKeepDay = formatUtcDay(utcMidnightMs(now) - STATE_RETENTION_DAYS * DAY_MS); const prunedSentDays: Record = {}; - for (const [day, at] of Object.entries(state.sentDays)) { + for (const [day, at] of Object.entries(state.sentDays ?? {})) { if (day >= minKeepDay) prunedSentDays[day] = at; } const payload: TelemetryState = { version: 1, sentDays: prunedSentDays, - lastSentDay: state.lastSentDay, - lastSentAt: state.lastSentAt, + lastSentDay: state.lastSentDay ?? null, + lastSentAt: state.lastSentAt ?? null, + ineligibleThroughDay: state.ineligibleThroughDay ?? null, + retryAfterUntil: state.retryAfterUntil ?? null, }; atomicWriteFileNoFollow(path, `${JSON.stringify(payload, null, 2)}\n`); } +/** + * Persist `ineligibleThroughDay = today's UTC day` in `telemetry-state.json` (atomic no-follow) + * and delete `telemetry-id.json` whenever an `OCX_TELEMETRY` or `DO_NOT_TRACK` opt-out is observed. + */ +export function recordTelemetryEnvOptOut(configDir = getConfigDir(), now = Date.now()): void { + const todayDay = formatUtcDay(utcMidnightMs(now)); + const state = readTelemetryState(configDir); + if (!state.ineligibleThroughDay || state.ineligibleThroughDay < todayDay) { + state.ineligibleThroughDay = todayDay; + } + writeTelemetryState(state, configDir, now); + deleteTelemetryInstallId(configDir); +} + export function utcMidnightMs(epochMs: number): number { const d = new Date(epochMs); return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); @@ -95,11 +134,58 @@ export function latestCompletedUtcDay(now = Date.now()): string { return formatUtcDay(utcMidnightMs(now) - DAY_MS); } +/** + * Resolve the oldest completed UTC day (within the 7-day window) that is eligible to be sent next: + * - `status.effective` must be true. + * - Strictly AFTER the UTC day of `decidedAt ?? noticeShownAt` (D1). + * - Strictly AFTER `state.ineligibleThroughDay` when set (D2). + * - Not already recorded in `state.sentDays`. + */ +export function resolveNextEligibleTelemetryDay( + status: TelemetryConsentStatus, + state: TelemetryState, + now = Date.now(), +): { day: string | null; reason: string } { + if (!status.effective) { + return { day: null, reason: status.reason }; + } + const consentTimestamp = status.decidedAt ?? status.noticeShownAt; + if (!consentTimestamp) { + return { day: null, reason: "missing-consent-timestamp" }; + } + const consentMs = Date.parse(consentTimestamp); + if (Number.isNaN(consentMs)) { + return { day: null, reason: "invalid-consent-timestamp" }; + } + const consentDay = formatUtcDay(consentMs); + const todayMs = utcMidnightMs(now); + + for (let offset = MAX_BACKLOG_DAYS; offset >= 1; offset--) { + const candidateDay = formatUtcDay(todayMs - offset * DAY_MS); + if (candidateDay <= consentDay) continue; + if (state.ineligibleThroughDay && candidateDay <= state.ineligibleThroughDay) continue; + if (state.sentDays[candidateDay]) continue; + return { day: candidateDay, reason: "eligible" }; + } + return { day: null, reason: "up-to-date" }; +} + +export function computeTelemetryStartupDelayMs(random: () => number = Math.random): number { + return TELEMETRY_STARTUP_BASE_DELAY_MS + Math.floor(random() * TELEMETRY_STARTUP_JITTER_MS); +} + +export function computeTelemetryIntervalDelayMs( + baseMs = TELEMETRY_TICK_INTERVAL_MS, + random: () => number = Math.random, +): number { + return baseMs + Math.floor(random() * TELEMETRY_INTERVAL_JITTER_MS); +} + export interface TelemetryTickOptions extends TelemetrySendOptions, ResolveInstallChannelOptions { configDir?: string; config?: Pick readConfig?: () => Pick; - aggregateDay?: (day: string) => Promise; + aggregateDay?: (day: string, options?: { signal?: AbortSignal }) => Promise; } export interface TelemetryTickResult { @@ -109,71 +195,150 @@ export interface TelemetryTickResult { sendResult?: TelemetrySendResult; } +const activeTickControllers = new Set(); + +function abortActiveTicks(reason: string): void { + for (const controller of activeTickControllers) { + controller.abort(new Error(reason)); + } + activeTickControllers.clear(); +} + /** * Run one telemetry tick: * - Re-reads config passively from disk and re-evaluates `resolveTelemetryStatus`. - * - Sends only completed UTC days on or after the UTC day of `decidedAt ?? noticeShownAt` - * (no pre-consent backfill). + * - Persists `ineligibleThroughDay` and deletes the install id whenever an env opt-out is observed. + * - Sends only completed UTC days strictly after the UTC day of `decidedAt ?? noticeShownAt` + * and strictly after `ineligibleThroughDay`. + * - Re-checks consent and env immediately before transmission and cancels in-flight work on stop/nudge. * - Sends at most ONE day per tick, oldest first, at most the last 7 completed days. */ export async function runTelemetryTick(options: TelemetryTickOptions = {}): Promise { const now = options.now ?? Date.now(); - const env = options.env ?? process.env; const configDir = options.configDir ?? getConfigDir(); - const config = options.config - ?? (options.readConfig ? options.readConfig() : readConfigDiagnostics({ passive: true }).config); - const status = resolveTelemetryStatus(config, env); - if (!status.effective) { + const readCurrentEnv = (): Record => options.env ?? process.env; + const readCurrentConfig = (): Pick | undefined => { + if (options.readConfig) return options.readConfig(); + const diskConfig = readConfigDiagnostics({ passive: true }).config; + if (options.config) { + if (diskConfig.telemetry !== undefined) { + const currentEnv = readCurrentEnv(); + const optStatus = resolveTelemetryStatus(options.config, currentEnv); + const diskStatus = resolveTelemetryStatus(diskConfig, currentEnv); + return !optStatus.effective ? options.config : !diskStatus.effective ? diskConfig : options.config; + } + return options.config; + } + return diskConfig; + }; + + const initialEnv = readCurrentEnv(); + if (isTelemetryEnvOptOut(initialEnv) !== null) { + recordTelemetryEnvOptOut(configDir, now); + const status = resolveTelemetryStatus(readCurrentConfig(), initialEnv); return { sent: false, reason: status.reason }; } - const consentTimestamp = status.decidedAt ?? status.noticeShownAt; - if (!consentTimestamp) { - return { sent: false, reason: "missing-consent-timestamp" }; - } - const consentMs = Date.parse(consentTimestamp); - if (Number.isNaN(consentMs)) { - return { sent: false, reason: "invalid-consent-timestamp" }; + + const status = resolveTelemetryStatus(readCurrentConfig(), initialEnv); + if (!status.effective) { + return { sent: false, reason: status.reason }; } - const consentDay = formatUtcDay(consentMs); - const todayMs = utcMidnightMs(now); - const state = readTelemetryState(configDir); - let targetDay: string | null = null; - for (let offset = MAX_BACKLOG_DAYS; offset >= 1; offset--) { - const candidateDay = formatUtcDay(todayMs - offset * DAY_MS); - if (candidateDay < consentDay) continue; - if (state.sentDays[candidateDay]) continue; - targetDay = candidateDay; - break; + const state = readTelemetryState(configDir); + if (state.retryAfterUntil) { + const retryUntilMs = Date.parse(state.retryAfterUntil); + if (!Number.isNaN(retryUntilMs) && now < retryUntilMs) { + return { sent: false, reason: "retry-after-backoff" }; + } } + + const { day: targetDay, reason: eligibilityReason } = resolveNextEligibleTelemetryDay(status, state, now); if (!targetDay) { - return { sent: false, reason: "up-to-date" }; + return { sent: false, reason: eligibilityReason }; } - const { identity } = ensureTelemetryInstallId(configDir); - const newInstall = identity.firstReportSent !== true; - const installDayHash = computeInstallDayHash(identity.installId, targetDay); - const aggregate = await (options.aggregateDay - ? options.aggregateDay(targetDay) - : aggregateTelemetryForDay(targetDay, { signal: options.signal })); - const payload = buildTelemetryPayloadV1({ - aggregate, - installDayHash, - newInstall, - now, - installChannel: resolveInstallChannel(options), - }); - const sendResult = await sendTelemetryPayload(payload, { ...options, now }); - if (!sendResult.ok) { - return { sent: false, day: targetDay, reason: "send-failed", sendResult }; + const tickController = new AbortController(); + activeTickControllers.add(tickController); + const parentSignal = options.signal; + const onParentAbort = () => tickController.abort(parentSignal?.reason); + if (parentSignal?.aborted) { + activeTickControllers.delete(tickController); + return { sent: false, day: targetDay, reason: "aborted" }; + } + parentSignal?.addEventListener("abort", onParentAbort, { once: true }); + + try { + let aggregate: DailyTelemetryAggregate; + try { + aggregate = await (options.aggregateDay + ? options.aggregateDay(targetDay, { signal: tickController.signal }) + : aggregateTelemetryForDay(targetDay, { signal: tickController.signal })); + } catch (error) { + if (tickController.signal.aborted) { + return { sent: false, day: targetDay, reason: "aborted" }; + } + throw error; + } + + const preSendEnv = readCurrentEnv(); + if (isTelemetryEnvOptOut(preSendEnv) !== null) { + recordTelemetryEnvOptOut(configDir, now); + const latestStatus = resolveTelemetryStatus(readCurrentConfig(), preSendEnv); + return { sent: false, day: targetDay, reason: latestStatus.reason }; + } + if (tickController.signal.aborted) { + return { sent: false, day: targetDay, reason: "aborted" }; + } + const preSendStatus = resolveTelemetryStatus(readCurrentConfig(), preSendEnv); + if (!preSendStatus.effective) { + return { sent: false, day: targetDay, reason: preSendStatus.reason }; + } + const preSendState = readTelemetryState(configDir); + const recheckedEligibility = resolveNextEligibleTelemetryDay(preSendStatus, preSendState, now); + if (recheckedEligibility.day !== targetDay) { + return { sent: false, day: targetDay, reason: recheckedEligibility.reason }; + } + + const { identity } = ensureTelemetryInstallId(configDir); + const newInstall = identity.firstReportSent !== true; + const installDayHash = computeInstallDayHash(identity.installId, targetDay); + const payload = buildTelemetryPayloadV1({ + aggregate, + installDayHash, + newInstall, + now, + installChannel: resolveInstallChannel({ ...options, env: preSendEnv }), + }); + const sendResult = await sendTelemetryPayload(payload, { + ...options, + signal: tickController.signal, + now, + }); + if (!sendResult.ok) { + if (tickController.signal.aborted) { + return { sent: false, day: targetDay, reason: "aborted", sendResult }; + } + if (typeof sendResult.retryAfterMs === "number" && sendResult.retryAfterMs > 0) { + const freshState = readTelemetryState(configDir); + freshState.retryAfterUntil = new Date(now + sendResult.retryAfterMs).toISOString(); + writeTelemetryState(freshState, configDir, now); + return { sent: false, day: targetDay, reason: "retry-after-backoff", sendResult }; + } + return { sent: false, day: targetDay, reason: "send-failed", sendResult }; + } + markTelemetryFirstReportSent(configDir); + const freshState = readTelemetryState(configDir); + const nowIso = new Date(now).toISOString(); + freshState.sentDays[targetDay] = nowIso; + freshState.lastSentDay = targetDay; + freshState.lastSentAt = nowIso; + freshState.retryAfterUntil = null; + writeTelemetryState(freshState, configDir, now); + return { sent: true, day: targetDay, reason: "sent", sendResult }; + } finally { + parentSignal?.removeEventListener("abort", onParentAbort); + activeTickControllers.delete(tickController); } - markTelemetryFirstReportSent(configDir); - const nowIso = new Date(now).toISOString(); - state.sentDays[targetDay] = nowIso; - state.lastSentDay = targetDay; - state.lastSentAt = nowIso; - writeTelemetryState(state, configDir, now); - return { sent: true, day: targetDay, reason: "sent", sendResult }; } let startupTimer: ReturnType | null = null; @@ -186,6 +351,7 @@ let running = false; function triggerBackgroundTick(): void { if (!running) return; if (isTelemetryEnvOptOut(process.env) !== null) { + try { recordTelemetryEnvOptOut(); } catch { /* best-effort */ } stopTelemetryScheduler(); return; } @@ -199,13 +365,14 @@ function triggerBackgroundTick(): void { export function startTelemetryScheduler(options: { startupDelayMs?: number; intervalMs?: number } = {}): () => void { if (isTelemetryEnvOptOut(process.env) !== null) { + try { recordTelemetryEnvOptOut(); } catch { /* best-effort */ } return () => {}; } if (running) return stopTelemetryScheduler; running = true; detachShutdownHook = registerOptionalShutdownHook("telemetry", stopTelemetryScheduler); - const startupDelay = options.startupDelayMs ?? (30_000 + Math.floor(Math.random() * 30_000)); - const intervalMs = options.intervalMs ?? TELEMETRY_TICK_INTERVAL_MS; + const startupDelay = options.startupDelayMs ?? computeTelemetryStartupDelayMs(); + const intervalMs = options.intervalMs ?? computeTelemetryIntervalDelayMs(); startupTimer = setTimeout(() => { startupTimer = null; triggerBackgroundTick(); @@ -220,6 +387,7 @@ export function startTelemetryScheduler(options: { startupDelayMs?: number; inte export function stopTelemetryScheduler(): void { running = false; + abortActiveTicks("Telemetry scheduler stopped"); if (startupTimer) { clearTimeout(startupTimer); startupTimer = null; @@ -240,8 +408,10 @@ export function stopTelemetryScheduler(): void { } export function nudgeTelemetryScheduler(): void { + abortActiveTicks("Telemetry consent changed"); if (!running) return; if (isTelemetryEnvOptOut(process.env) !== null) { + try { recordTelemetryEnvOptOut(); } catch { /* best-effort */ } stopTelemetryScheduler(); return; } diff --git a/src/telemetry/sender.ts b/src/telemetry/sender.ts index d7a4be4b4a8..cc42909a08b 100644 --- a/src/telemetry/sender.ts +++ b/src/telemetry/sender.ts @@ -3,6 +3,9 @@ import { validateTelemetryPayloadV1, type TelemetryPayloadV1 } from "./contract" export const TELEMETRY_COLLECTOR_ENDPOINT = "https://telemetry.opencodex.me/v1/events"; export const TELEMETRY_SEND_TIMEOUT_MS = 10_000; +export const TELEMETRY_DEFAULT_RETRY_AFTER_MS = 15 * 60 * 1000; +export const TELEMETRY_RETRY_JITTER_MS = 5 * 60 * 1000; +const MAX_RETRY_AFTER_MS = 7 * 86_400_000; export interface TelemetrySendOptions { endpoint?: string; @@ -16,6 +19,24 @@ export interface TelemetrySendResult { ok: boolean; status?: number; error?: string; + retryAfterMs?: number; +} + +/** + * Parse an HTTP `Retry-After` header value (delta-seconds or HTTP-date) into milliseconds. + */ +export function parseRetryAfterMs(headerValue: string | null | undefined, now = Date.now()): number | null { + if (typeof headerValue !== "string") return null; + const trimmed = headerValue.trim(); + if (!trimmed) return null; + if (/^\d+$/.test(trimmed)) { + const seconds = Number(trimmed); + if (!Number.isFinite(seconds) || seconds <= 0) return null; + return Math.min(seconds * 1000, MAX_RETRY_AFTER_MS); + } + const targetMs = Date.parse(trimmed); + if (Number.isNaN(targetMs) || targetMs <= now) return null; + return Math.min(targetMs - now, MAX_RETRY_AFTER_MS); } let testOverrides: TelemetrySendOptions | null = null; @@ -49,6 +70,17 @@ export async function sendTelemetryPayload( signal: deadline.signal, }); if (!response.ok) { + if (response.status === 429 || response.status === 503) { + const headerRetryMs = parseRetryAfterMs(response.headers?.get?.("retry-after"), now); + const retryAfterMs = headerRetryMs + ?? (TELEMETRY_DEFAULT_RETRY_AFTER_MS + Math.floor(Math.random() * TELEMETRY_RETRY_JITTER_MS)); + return { + ok: false, + status: response.status, + error: `collector responded ${response.status}`, + retryAfterMs, + }; + } return { ok: false, status: response.status, error: `collector responded ${response.status}` }; } return { ok: true, status: response.status }; diff --git a/structure/telemetry.md b/structure/telemetry.md index 6adefcb16db..82dc673dde6 100644 --- a/structure/telemetry.md +++ b/structure/telemetry.md @@ -9,7 +9,10 @@ Anonymous, default-on daily usage telemetry owned by `src/telemetry/`, `src/type the closed rosters (`PROVIDER_KINDS`, `MODEL_FAMILIES`, `INSTALL_CHANNELS`, `RELEASE_CHANNELS`, `OS_VALUES`, `ARCH_VALUES`, `REQUEST_OUTCOME_CLASSES`, `REQUEST_FAILURE_CAUSES`), and `validateTelemetryPayloadV1`. Every level of the payload rejects unknown keys, non-roster strings, -out-of-window or non-calendar `day` values, semver build metadata, and more than 64 model rows. +out-of-window or non-calendar `day` values, versions not matching +`^\d+\.\d+\.\d+(-preview\.\d{8}(\.\d+)?)?$` (no build metadata), and more than 64 model rows. +Any roster or wire-contract addition must be deployed to the collector before a client release +emits the new values (collector-first rollout). ## Consent and agent boundary @@ -25,12 +28,17 @@ via `adoptPersistedTelemetry` in `src/config/live-reconcile.ts`. Only a human can transition an undecided install to active sending: -- `PUT /api/telemetry` and `POST /api/telemetry/notice` in `src/telemetry/routes.ts` (delegated +- `PUT /api/telemetry` in `src/telemetry/routes.ts` (delegated from `src/server/management/telemetry-routes.ts` and lazily mounted in `src/server/management-api.ts`) - require `hasHumanDashboardSession` from `src/server/management/consent-session.ts`. -- `ocx telemetry enable` in `src/cli/telemetry.ts` and `ocx config set telemetry` in - `src/cli/config-command.ts` refuse agent-driven callers; `ocx telemetry disable` is always - permitted. + requires `hasHumanDashboardSession` from `src/server/management/consent-session.ts`. + `GET /api/telemetry` previews the oldest eligible unsent day (or the latest completed day with + `previewEligible: false` when no day is eligible). +- `ocx telemetry enable` in `src/cli/telemetry.ts` requires an interactive human terminal + (`isatty` stdin and stdout, not `OCX_SERVICE`, not agent-driven), prints the disclosure block, + and asks for confirmation before recording `accepted`; otherwise it exits non-zero with + `agent_consent_required` or `tty_required`. `ocx telemetry disable` is always permitted. + `ocx config set`/`unset` in `src/cli/config-command.ts` refuse `telemetry`, and `ocx config import` + preserves the persisted `telemetry` block unchanged for every caller. - Interactive prompts run from `src/cli/star-prompt.ts`, `src/service/cli.ts`, and `src/cli/init.ts`; agent-driven, non-TTY, or service starts print a once-per-version notice and record neither `noticeShownAt` nor `decision`. @@ -38,17 +46,25 @@ Only a human can transition an undecided install to active sending: ## Identity, aggregation, and scheduling `src/telemetry/install-id.ts` manages `/telemetry-id.json` (`0o600`, exclusive -no-follow creation) on the first send and deletes it when telemetry is declined. Only +no-follow creation) on the first send and deletes it when telemetry is declined or when an +`OCX_TELEMETRY`/`DO_NOT_TRACK` environment opt-out is observed. Only `sha256(installId + ":" + day)` leaves the machine. `src/telemetry/aggregate.ts` scans `usage.jsonl` through `src/usage/ledger-scanner.ts` for one -completed UTC day, mapping providers to `PROVIDER_KINDS`, models to `MODEL_FAMILIES`, outcomes -via `src/usage/request-outcome.ts`, and decomposing usage into five disjoint token buckets -(`inputTokens`, `cacheReadTokens`, `cacheWriteTokens`, `outputTokens`, `reasoningTokens`) whose -sum matches `usageDisplayTotalTokens` in `src/usage/totals.ts`. +completed UTC day, accumulating counters directly inside `onEntry` without retaining ledger rows in +memory, mapping providers to `PROVIDER_KINDS`, models to `MODEL_FAMILIES`, outcomes via +`src/usage/request-outcome.ts`, and decomposing usage into five disjoint token buckets +(`inputTokens`, `cacheReadTokens`, `cacheWriteTokens`, `outputTokens`, `reasoningTokens`) whose sum +matches `usageDisplayTotalTokens` in `src/usage/totals.ts`. `src/telemetry/payload.ts` resolves the install channel and validates the built payload with `validateTelemetryPayloadV1`. `src/telemetry/sender.ts` posts to the constant collector URL -with a 10-second `signalWithTimeout`. `src/telemetry/scheduler.ts` runs an unref'd background -timer started lazily from `src/server/background-lifecycle.ts`, re-reading config from disk on -each tick and sending at most one completed post-consent day per tick. +with a 10-second `signalWithTimeout` and parses `Retry-After` on HTTP 429/503 responses. +`src/telemetry/scheduler.ts` runs an unref'd background timer with startup and interval jitter +started from `src/server/background-lifecycle.ts`. Whenever an `OCX_TELEMETRY` or `DO_NOT_TRACK` +opt-out is observed (at startup in `src/server/background-lifecycle.ts` or on a tick), +`recordTelemetryEnvOptOut` persists `ineligibleThroughDay` (today's UTC day) in +`telemetry-state.json` and deletes `telemetry-id.json`. Each tick sends at most one completed UTC +day strictly after the UTC day of `decidedAt ?? noticeShownAt` and strictly after +`ineligibleThroughDay`, honors `retryAfterUntil` backoffs, re-reads persisted consent and env +immediately before transmission, and carries an `AbortSignal` cancelled on stop or consent changes. diff --git a/tests/cli/cli-telemetry.test.ts b/tests/cli/cli-telemetry.test.ts index eaea6062012..e8b25bf6a0a 100644 --- a/tests/cli/cli-telemetry.test.ts +++ b/tests/cli/cli-telemetry.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { handleConfigCommand } from "../../src/cli/config-command"; import { maybeShowStarPrompt, setStarPromptDepsForTests } from "../../src/cli/star-prompt"; import { + TELEMETRY_CLI_DISCLOSURE_LINES, TELEMETRY_DEFERRAL_FILE, handleTelemetryCommand, maybeShowTelemetryPrompt, @@ -12,6 +13,7 @@ import { } from "../../src/cli/telemetry"; import { readConfigDiagnostics } from "../../src/config/diagnostics"; import { + ensureTelemetryInstallId, readTelemetryInstallId, resetTelemetryInstallIdMemoForTests, telemetryIdPath, @@ -103,8 +105,42 @@ describe("ocx telemetry CLI and interactive prompt", () => { const log = spyOn(console, "log").mockImplementation(() => {}); const err = spyOn(console, "error").mockImplementation(() => {}); try { + // D4: Non-TTY or OCX_SERVICE refuses enable with tty_required pointing to the dashboard: + setTelemetryPromptDepsForTests({ isTty: () => false }); + const nonTtyCode = await handleTelemetryCommand(["enable", "--json"]); + expect(nonTtyCode).toBe(1); + const nonTtyBody = JSON.parse(log.mock.calls.map(c => String(c[0])).join("\n")) as { code: string; error: string }; + expect(nonTtyBody.code).toBe("tty_required"); + expect(nonTtyBody.error.toLowerCase()).toContain("dashboard"); + expect(readConfigDiagnostics().config.telemetry).toBeUndefined(); + + log.mockClear(); + process.env.OCX_SERVICE = "1"; + setTelemetryPromptDepsForTests({ isTty: () => true }); + const serviceCode = await handleTelemetryCommand(["enable", "--json"]); + expect(serviceCode).toBe(1); + expect(log.mock.calls.map(c => String(c[0])).join("\n")).toContain("tty_required"); + delete process.env.OCX_SERVICE; + + // Interactive human terminal: prints disclosure block, prompts with defaultYes=true, and records accepted: + log.mockClear(); + let confirmQuestion = ""; + let confirmDefaultYes: boolean | undefined; + let disclosureBeforePrompt = ""; + setTelemetryPromptDepsForTests({ + isTty: () => true, + interactiveConfirm: async opts => { + disclosureBeforePrompt = log.mock.calls.map(c => String(c[0])).join("\n"); + confirmQuestion = opts.question; + confirmDefaultYes = opts.defaultYes; + return true; + }, + }); const enableCode = await handleTelemetryCommand(["enable", "--json"]); expect(enableCode).toBe(0); + expect(confirmDefaultYes).toBe(true); + expect(confirmQuestion).toContain("Share anonymous usage statistics?"); + expect(disclosureBeforePrompt).toBe(TELEMETRY_CLI_DISCLOSURE_LINES.join("\n")); expect(readConfigDiagnostics().config.telemetry?.decision).toBe("accepted"); const tick = await runTelemetryTick({ @@ -136,12 +172,13 @@ describe("ocx telemetry CLI and interactive prompt", () => { } } } finally { + delete process.env.OCX_SERVICE; log.mockRestore(); err.mockRestore(); } }); - test("ocx config set/unset refuse telemetry and import drops telemetry when agent-driven", async () => { + test("D6: ocx config set/unset refuse telemetry and import preserves persisted telemetry for every caller (human and agent)", async () => { const log = spyOn(console, "log").mockImplementation(() => {}); const warn = spyOn(console, "warn").mockImplementation(() => {}); const err = spyOn(console, "error").mockImplementation(() => {}); @@ -166,13 +203,19 @@ describe("ocx telemetry CLI and interactive prompt", () => { }), "utf8", ); + // 1a. Human caller (not agent-driven) on undecided install -> imported telemetry is ignored, disk stays undecided: + expect(await handleConfigCommand(["import", importAccepted, "--yes"])).toBe(0); + expect(readConfigDiagnostics().config.telemetry).toBeUndefined(); + expect(warn.mock.calls.map(c => String(c[0])).join("\n")).toContain("ocx telemetry"); + + // 1b. Agent-driven caller on undecided install -> imported telemetry is ignored, disk stays undecided: + warn.mockClear(); process.env.CODEX_THREAD_ID = "agent-import"; - // Direction 1: undecided on disk -> agent import does not inject telemetry expect(await handleConfigCommand(["import", importAccepted, "--yes"])).toBe(0); expect(readConfigDiagnostics().config.telemetry).toBeUndefined(); expect(warn.mock.calls.map(c => String(c[0])).join("\n")).toContain("ocx telemetry"); - // Direction 2: declined on disk -> agent import preserves declined whether input has telemetry or omits it + // 2. Declined on disk -> both human and agent callers preserve declined whether input has telemetry or omits it: expect(await handleTelemetryCommand(["disable", "--json"])).toBe(0); const declinedBefore = readConfigDiagnostics().config.telemetry; expect(declinedBefore?.decision).toBe("declined"); @@ -180,6 +223,10 @@ describe("ocx telemetry CLI and interactive prompt", () => { expect(await handleConfigCommand(["import", importAccepted, "--yes"])).toBe(0); expect(readConfigDiagnostics().config.telemetry).toEqual(declinedBefore); + delete process.env.CODEX_THREAD_ID; + expect(await handleConfigCommand(["import", importAccepted, "--yes"])).toBe(0); + expect(readConfigDiagnostics().config.telemetry).toEqual(declinedBefore); + const importWithoutTelemetry = join(home, "imported-no-telemetry.json"); writeFileSync( importWithoutTelemetry, @@ -228,18 +275,30 @@ describe("ocx telemetry CLI and interactive prompt", () => { expect(log.mock.calls.length).toBe(0); expect(readConfigDiagnostics().config.telemetry).toBeUndefined(); - // Interactive human run prompts with defaultYes: true and records accepted: + // Interactive human run prints the disclosure block before prompting with defaultYes: true and records accepted: delete process.env.CODEX_THREAD_ID; let observedDefaultYes: boolean | undefined; + let printedBeforeConfirm = ""; setTelemetryPromptDepsForTests({ isTty: () => true, interactiveConfirm: async opts => { + printedBeforeConfirm = log.mock.calls.map(c => String(c[0])).join("\n"); observedDefaultYes = opts.defaultYes; return opts.defaultYes; }, }); await maybeShowTelemetryPrompt(); expect(observedDefaultYes).toBe(true); + expect(printedBeforeConfirm).toBe(TELEMETRY_CLI_DISCLOSURE_LINES.join("\n")); + // D5: Pin required elements of the disclosure text: + expect(printedBeforeConfirm).toContain("telemetry.opencodex.me"); + expect(printedBeforeConfirm).toContain("Never sent: prompts, model outputs"); + expect(printedBeforeConfirm).toContain("opencodex.me/dashboard"); + expect(printedBeforeConfirm).toContain("ocx telemetry status --json"); + expect(printedBeforeConfirm).toContain("ocx telemetry disable"); + expect(printedBeforeConfirm).toContain("OCX_TELEMETRY=0"); + expect(printedBeforeConfirm).toContain("DO_NOT_TRACK=1"); + expect(printedBeforeConfirm).toContain("Proxy details"); const telemetry = readConfigDiagnostics().config.telemetry; expect(telemetry?.decision).toBe("accepted"); expect(typeof telemetry?.decidedAt).toBe("string"); @@ -250,6 +309,62 @@ describe("ocx telemetry CLI and interactive prompt", () => { } }); + test("D9: enable and disable exit non-zero with no success line and preserve telemetry-id.json when config.json is unavailable", async () => { + const log = spyOn(console, "log").mockImplementation(() => {}); + const err = spyOn(console, "error").mockImplementation(() => {}); + try { + const { identity } = ensureTelemetryInstallId(home); + expect(existsSync(telemetryIdPath(home))).toBe(true); + + // Corrupt config.json so mutatePersistedConfig returns { status: "unavailable", reason: "malformed" }: + writeFileSync(join(home, "config.json"), "{ malformed json\n", "utf8"); + + const disableCode = await handleTelemetryCommand(["disable"]); + expect(disableCode).not.toBe(0); + expect(log.mock.calls).toHaveLength(0); + expect(err.mock.calls.map(c => String(c[0])).join("\n")).toContain("config.json is invalid"); + // Install id must NOT be deleted when the decline failed to persist: + expect(existsSync(telemetryIdPath(home))).toBe(true); + expect(readTelemetryInstallId(home)?.installId).toBe(identity.installId); + + log.mockClear(); + err.mockClear(); + setTelemetryPromptDepsForTests({ + isTty: () => true, + interactiveConfirm: async () => true, + }); + const enableCode = await handleTelemetryCommand(["enable"]); + expect(enableCode).not.toBe(0); + // Only the disclosure block was printed before confirm; no "Anonymous telemetry enabled." success line: + expect(log.mock.calls.map(c => String(c[0])).join("\n")).not.toContain("Anonymous telemetry enabled"); + expect(err.mock.calls.map(c => String(c[0])).join("\n")).toContain("config.json is invalid"); + } finally { + log.mockRestore(); + err.mockRestore(); + } + }); + + test("D12: README.md, translated READMEs, and code-signing.md state the telemetry privacy policy accurately", () => { + for (const rel of [ + "README.md", + "readme/README.fr.md", + "readme/README.ja.md", + "readme/README.ko.md", + "readme/README.ru.md", + "readme/README.tr.md", + "readme/README.zh-CN.md", + "readme/README.zh-TW.md", + "docs-site/src/content/docs/reference/code-signing.md", + ]) { + const text = readFileSync(repoPath(rel), "utf8"); + expect(text).not.toContain("will not transfer any information to other networked systems"); + expect(text).toContain("telemetry.opencodex.me"); + expect(text).toContain("ocx telemetry disable"); + expect(text).toContain("OCX_TELEMETRY=0"); + expect(text).toContain("DO_NOT_TRACK=1"); + } + }); + test("service/cli.ts does not duplicate maybeShowTelemetryPrompt after maybeShowStarPrompt and the prompt runs once", async () => { const serviceCliSource = readFileSync(repoPath("src", "service", "cli.ts"), "utf8"); expect(serviceCliSource).toContain("await maybeShowStarPrompt()"); diff --git a/tests/server/server-telemetry-routes.test.ts b/tests/server/server-telemetry-routes.test.ts index 9062a351829..c22ae8a8d93 100644 --- a/tests/server/server-telemetry-routes.test.ts +++ b/tests/server/server-telemetry-routes.test.ts @@ -6,7 +6,8 @@ import { handleManagementAPI } from "../../src/server/management-api"; import type { ManagementPrincipal } from "../../src/server/management-auth"; import type { ManagementRequestIngress } from "../../src/server/management/context"; import { TELEMETRY_PREVIEW_HASH_PLACEHOLDER, readTelemetryInstallId, resetTelemetryInstallIdMemoForTests, telemetryIdPath } from "../../src/telemetry/install-id"; -import { runTelemetryTick } from "../../src/telemetry/scheduler"; +import { buildTelemetryStatusSnapshot } from "../../src/telemetry/routes"; +import { latestCompletedUtcDay, runTelemetryTick } from "../../src/telemetry/scheduler"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -90,14 +91,75 @@ describe("/api/telemetry management routes", () => { reason: "awaiting-notice", decision: null, lastSentDay: null, + previewEligible: false, }); - const preview = res.body.preview as { installDayHash: string; schemaVersion: number }; + const preview = res.body.preview as { installDayHash: string; schemaVersion: number; day: string }; expect(preview.schemaVersion).toBe(1); expect(preview.installDayHash).toBe(TELEMETRY_PREVIEW_HASH_PLACEHOLDER); + expect(preview.day).toBe(latestCompletedUtcDay()); expect(existsSync(telemetryIdPath(home))).toBe(false); }); - test("PUT /api/telemetry and POST /api/telemetry/notice refuse admin-token and remembered-admin sessions", async () => { + test("D10: GET /api/telemetry preview uses the oldest eligible unsent day and marks previewEligible=false when none is eligible", async () => { + const now = Date.parse("2026-11-07T12:00:00.000Z"); + + // 1. Consent given today (2026-11-07) or yesterday (2026-11-06) -> no completed day is strictly after consent day yet. + const sameDaySnap = await buildTelemetryStatusSnapshot( + { + telemetry: { + decision: "accepted", + decidedAt: "2026-11-06T18:00:00.000Z", + noticeShownAt: "2026-11-06T18:00:00.000Z", + }, + }, + { now, env: {}, configDir: home }, + ); + expect(sameDaySnap.previewEligible).toBe(false); + expect(sameDaySnap.preview.day).toBe("2026-11-06"); + + // 2. Consent given on 2026-11-04 -> eligible unsent days are 2026-11-05 and 2026-11-06; + // preview shows 2026-11-05 (the exact day the scheduler will send next) with previewEligible=true. + const eligibleConfig = { + telemetry: { + decision: "accepted" as const, + decidedAt: "2026-11-04T18:00:00.000Z", + noticeShownAt: "2026-11-04T18:00:00.000Z", + }, + }; + const firstSnap = await buildTelemetryStatusSnapshot(eligibleConfig, { now, env: {}, configDir: home }); + expect(firstSnap.previewEligible).toBe(true); + expect(firstSnap.preview.day).toBe("2026-11-05"); + + // Scheduler sends 2026-11-05; next preview moves to 2026-11-06 with previewEligible=true: + const tick1 = await runTelemetryTick({ + configDir: home, + config: eligibleConfig, + env: {}, + now, + fetch: (async () => new Response(JSON.stringify({ ok: true }), { status: 202 })) as unknown as typeof fetch, + }); + expect(tick1).toMatchObject({ sent: true, day: "2026-11-05" }); + + const secondSnap = await buildTelemetryStatusSnapshot(eligibleConfig, { now, env: {}, configDir: home }); + expect(secondSnap.previewEligible).toBe(true); + expect(secondSnap.preview.day).toBe("2026-11-06"); + + // Scheduler sends 2026-11-06; now no eligible day remains, so previewEligible=false on 2026-11-06: + const tick2 = await runTelemetryTick({ + configDir: home, + config: eligibleConfig, + env: {}, + now, + fetch: (async () => new Response(JSON.stringify({ ok: true }), { status: 202 })) as unknown as typeof fetch, + }); + expect(tick2).toMatchObject({ sent: true, day: "2026-11-06" }); + + const thirdSnap = await buildTelemetryStatusSnapshot(eligibleConfig, { now, env: {}, configDir: home }); + expect(thirdSnap.previewEligible).toBe(false); + expect(thirdSnap.preview.day).toBe("2026-11-06"); + }); + + test("PUT /api/telemetry refuses admin-token and remembered-admin sessions, and POST /api/telemetry/notice is removed (D13)", async () => { for (const [principal, guiSessionIssuance] of [ ["admin-token", null], ["gui-session", "remembered-admin"], @@ -109,38 +171,24 @@ describe("/api/telemetry management routes", () => { }); expect(putRes.status).toBe(403); expect(putRes.body.code).toBe("agent_consent_required"); - - const noticeRes = await call("POST", "/api/telemetry/notice", { - principal, - ingress: { trustedLoopback: true, guiSessionIssuance }, - }); - expect(noticeRes.status).toBe(403); - expect(noticeRes.body.code).toBe("agent_consent_required"); } expect(config.telemetry).toBeUndefined(); - }); - - test("POST /api/telemetry/notice accepts a human dashboard session and is idempotent", async () => { - const first = await call("POST", "/api/telemetry/notice", { - principal: "gui-session", - ingress: { trustedLoopback: true, guiSessionIssuance: "loopback-bootstrap" }, - }); - expect(first.status).toBe(200); - expect(first.body).toMatchObject({ - ok: true, - effective: true, - reason: "default-on", - decision: null, - }); - const firstNoticeAt = first.body.noticeShownAt as string; - expect(typeof firstNoticeAt).toBe("string"); - const second = await call("POST", "/api/telemetry/notice", { - principal: "gui-session", - ingress: { trustedLoopback: true, guiSessionIssuance: "loopback-bootstrap" }, + const noticeUrl = new URL("http://127.0.0.1:10100/api/telemetry/notice"); + const noticeReq = new Request(noticeUrl, { + method: "POST", + headers: { host: "127.0.0.1:10100" }, }); - expect(second.status).toBe(200); - expect(second.body.noticeShownAt).toBe(firstNoticeAt); + const noticeRes = await handleManagementAPI( + noticeReq, + noticeUrl, + config, + {}, + "gui-session", + undefined, + { trustedLoopback: true, guiSessionIssuance: "loopback-bootstrap" }, + ); + expect(noticeRes).toBeNull(); }); test("PUT /api/telemetry accepts a human dashboard session and deletes telemetry-id.json on decline", async () => { diff --git a/tests/usage/usage-telemetry-client.test.ts b/tests/usage/usage-telemetry-client.test.ts index e9ae6782ac4..0b6f8dd3d6d 100644 --- a/tests/usage/usage-telemetry-client.test.ts +++ b/tests/usage/usage-telemetry-client.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; -import { existsSync, lstatSync, mkdtempSync, readdirSync, readFileSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import { existsSync, lstatSync, mkdtempSync, readdirSync, readFileSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { readConfigDiagnostics, telemetryConfigError } from "../../src/config/diagnostics"; @@ -10,6 +10,7 @@ import { aggregateTelemetryForDay, classifyModelFamily, classifyProviderKind, + createDailyTelemetryAccumulator, disjointTelemetryTokens, } from "../../src/telemetry/aggregate"; import { resolveTelemetryStatus } from "../../src/telemetry/consent"; @@ -34,17 +35,25 @@ import { resetTelemetryInstallIdMemoForTests, telemetryIdPath, } from "../../src/telemetry/install-id"; -import { buildTelemetryPayloadV1, resolveInstallChannel } from "../../src/telemetry/payload"; +import { buildTelemetryPayloadV1, normalizeTelemetryVersion, resolveInstallChannel } from "../../src/telemetry/payload"; import { + computeTelemetryIntervalDelayMs, + computeTelemetryStartupDelayMs, nudgeTelemetryScheduler, readTelemetryState, + recordTelemetryEnvOptOut, runTelemetryTick, startTelemetryScheduler, stopTelemetryScheduler, telemetryStatePath, writeTelemetryState, } from "../../src/telemetry/scheduler"; -import { sendTelemetryPayload, setTelemetrySenderOverridesForTests } from "../../src/telemetry/sender"; +import { + parseRetryAfterMs, + sendTelemetryPayload, + setTelemetrySenderOverridesForTests, +} from "../../src/telemetry/sender"; +import { acquireServerBackgroundLifecycle } from "../../src/server/background-lifecycle"; import type { OcxConfig } from "../../src/types"; import type { PersistedUsageEntry } from "../../src/usage/log"; import { REQUEST_OUTCOME_CLASSES } from "../../src/usage/request-outcome"; @@ -55,6 +64,14 @@ import { repoPath } from "../helpers/repo-root"; const NOW = Date.parse("2026-11-07T12:00:00.000Z"); +function rmIfExists(path: string): void { + try { + if (lstatSync(path)) { + unlinkSync(path); + } + } catch { /* ignore */ } +} + describe("telemetry contract parity and validation", () => { test("contract module is import-free and pins outcome and failure-cause rosters", () => { const src = readFileSync(repoPath("src", "telemetry", "contract.ts"), "utf8"); @@ -101,11 +118,32 @@ describe("telemetry contract parity and validation", () => { }; expect(validateTelemetryPayloadV1(base, NOW)).toEqual({ ok: true, payload: base }); - expect(validateTelemetryPayloadV1({ ...base, env: { ...base.env, version: "2.40.1-preview.3", releaseChannel: "preview" } }, NOW).ok).toBe(true); + for (const validPreview of [ + "2.6.15-preview.20260701", + "2.6.15-preview.20260701.1", + "2.7.9-preview.20260712.2", + ]) { + expect( + validateTelemetryPayloadV1( + { ...base, env: { ...base.env, version: validPreview, releaseChannel: "preview" } }, + NOW, + ).ok, + ).toBe(true); + expect(normalizeTelemetryVersion(validPreview)).toEqual({ + version: validPreview, + releaseChannel: "preview", + }); + } + expect(normalizeTelemetryVersion("2.40.1")).toEqual({ + version: "2.40.1", + releaseChannel: "latest", + }); expect(validateTelemetryPayloadV1({ ...base, extra: 1 }, NOW).ok).toBe(false); expect(validateTelemetryPayloadV1({ ...base, env: { ...base.env, extra: 1 } }, NOW).ok).toBe(false); expect(validateTelemetryPayloadV1({ ...base, env: { ...base.env, version: "2.40.1+build.9" } }, NOW).ok).toBe(false); + expect(validateTelemetryPayloadV1({ ...base, env: { ...base.env, version: "2.6.15-preview.20260701+build.1", releaseChannel: "preview" } }, NOW).ok).toBe(false); + expect(validateTelemetryPayloadV1({ ...base, env: { ...base.env, version: "2.40.1-preview.3", releaseChannel: "preview" } }, NOW).ok).toBe(false); expect(validateTelemetryPayloadV1({ ...base, env: { ...base.env, installChannel: "brew" as never } }, NOW).ok).toBe(false); expect(validateTelemetryPayloadV1({ ...base, day: "2026-02-30" }, NOW).ok).toBe(false); expect(validateTelemetryPayloadV1({ ...base, day: "2026-10-20" }, NOW).ok).toBe(false); @@ -352,6 +390,12 @@ describe("install identity, aggregation, disjoint tokens, and scheduler", () => expect(classifyProviderKind("google-vertex")).toBe("vertex"); expect(classifyModelFamily("deepseek/deepseek-r1")).toBe("deepseek"); expect(aggregateTelemetryEntriesForDay(day, []).activeMinutes).toBe(0); + + // D8: createDailyTelemetryAccumulator accumulates inside onEntry for target day only and retains no rows. + const acc = createDailyTelemetryAccumulator(day); + expect(Object.keys(acc).sort()).toEqual(["finalize", "onEntry"]); + for (const row of fixtureEntries) acc.onEntry(row); + expect(acc.finalize()).toEqual(agg); }); test("disjointTelemetryTokens legacy fallback keeps 5-bucket sum equal to usageDisplayTotalTokens and ACCOUNT_SUFFIX_RE matches k + 32 hex", () => { @@ -419,11 +463,13 @@ describe("install identity, aggregation, disjoint tokens, and scheduler", () => // first tick sends 2026-11-05 (oldest post-consent day) with newInstall=true, // second tick sends 2026-11-06 with newInstall=false, // third tick finds nothing left. + // 2. D1: Consent given on 2026-11-04 -> 2026-11-04 (the consent day itself) and earlier are NEVER sent; + // only UTC days strictly AFTER 2026-11-04 (i.e. 2026-11-05, 2026-11-06) are eligible. const config = { telemetry: { decision: "accepted" as const, - decidedAt: "2026-11-05T15:30:00.000Z", - noticeShownAt: "2026-11-05T15:30:00.000Z", + decidedAt: "2026-11-04T15:30:00.000Z", + noticeShownAt: "2026-11-04T15:30:00.000Z", }, }; const tick1 = await runTelemetryTick({ @@ -469,6 +515,301 @@ describe("install identity, aggregation, disjoint tokens, and scheduler", () => expect(readFileSync(full, "utf8")).not.toContain(rawInstallId); } } + + // Consent given on 2026-11-06 (yesterday) when today is 2026-11-07 -> 2026-11-06 is the consent day itself, + // so no completed UTC day is strictly after 2026-11-06 yet. + const sameDayHome = mkdtempSync(join(tmpdir(), "ocx-telemetry-sameday-")); + try { + const resSameDay = await runTelemetryTick({ + configDir: sameDayHome, + config: { + telemetry: { + decision: "accepted", + decidedAt: "2026-11-06T23:59:00.000Z", + noticeShownAt: "2026-11-06T23:59:00.000Z", + }, + }, + env: {}, + now: NOW, + fetch: mockFetch, + }); + expect(resSameDay).toEqual({ sent: false, reason: "up-to-date" }); + } finally { + removeTreeWithRetry(sameDayHome); + } + }); + + test("D2: env opt-out at startup or tick persists ineligibleThroughDay, deletes the install id, never backfills opt-out days, and mints a fresh id later", async () => { + const sentPayloads: TelemetryPayloadV1[] = []; + const mockFetch = (async (_url: string | URL | Request, init?: RequestInit) => { + sentPayloads.push(JSON.parse(String(init?.body)) as TelemetryPayloadV1); + return new Response(JSON.stringify({ ok: true }), { status: 202 }); + }) as unknown as typeof fetch; + + const config = { + telemetry: { + decision: "accepted" as const, + decidedAt: "2026-11-01T10:00:00.000Z", + noticeShownAt: "2026-11-01T10:00:00.000Z", + }, + }; + + // Day 2026-11-03: sends 2026-11-02 and mints initial installId + const day3Now = Date.parse("2026-11-03T12:00:00.000Z"); + const firstTick = await runTelemetryTick({ + configDir: home, + config, + env: {}, + now: day3Now, + fetch: mockFetch, + }); + expect(firstTick).toMatchObject({ sent: true, day: "2026-11-02" }); + const originalInstallId = readTelemetryInstallId(home)!.installId; + expect(typeof originalInstallId).toBe("string"); + + // User starts proxy in background-lifecycle with OCX_TELEMETRY=0 on 2026-11-03: + const prevTelemetry = process.env.OCX_TELEMETRY; + process.env.OCX_TELEMETRY = "0"; + try { + const lease = acquireServerBackgroundLifecycle(() => {}, { + port: 0, + defaultProvider: "openai", + providers: {}, + } as unknown as OcxConfig); + await lease.release(); + } finally { + if (prevTelemetry === undefined) delete process.env.OCX_TELEMETRY; + else process.env.OCX_TELEMETRY = prevTelemetry; + } + expect(readTelemetryInstallId(home)).toBeNull(); + expect(existsSync(telemetryIdPath(home))).toBe(false); + expect(readTelemetryState(home).ineligibleThroughDay).not.toBeNull(); + + // Reset state to simulate consecutive opt-out days 2026-11-03, 2026-11-04, 2026-11-05: + writeTelemetryState( + { + version: 1, + sentDays: { "2026-11-02": "2026-11-03T12:00:00.000Z" }, + lastSentDay: "2026-11-02", + lastSentAt: "2026-11-03T12:00:00.000Z", + ineligibleThroughDay: null, + retryAfterUntil: null, + }, + home, + day3Now, + ); + ensureTelemetryInstallId(home); + expect(readTelemetryInstallId(home)).not.toBeNull(); + + for (const [dayIso, env] of [ + ["2026-11-03T18:00:00.000Z", { OCX_TELEMETRY: "0" }], + ["2026-11-04T18:00:00.000Z", { DO_NOT_TRACK: "1" }], + ["2026-11-05T18:00:00.000Z", { OCX_TELEMETRY: "false" }], + ] as const) { + const optOutTick = await runTelemetryTick({ + configDir: home, + config, + env, + now: Date.parse(dayIso), + fetch: mockFetch, + }); + expect(optOutTick.sent).toBe(false); + expect(readTelemetryInstallId(home)).toBeNull(); + } + expect(readTelemetryState(home).ineligibleThroughDay).toBe("2026-11-05"); + + // User removes OCX_TELEMETRY=0 on 2026-11-06: days 2026-11-03..2026-11-05 are <= ineligibleThroughDay, so nothing is sent on 2026-11-06! + const day6Tick = await runTelemetryTick({ + configDir: home, + config, + env: {}, + now: Date.parse("2026-11-06T12:00:00.000Z"), + fetch: mockFetch, + }); + expect(day6Tick).toEqual({ sent: false, reason: "up-to-date" }); + expect(sentPayloads).toHaveLength(1); + + // On 2026-11-07, day 2026-11-06 (strictly after ineligibleThroughDay 2026-11-05) is sent with a fresh installId and newInstall=true: + const day7Tick = await runTelemetryTick({ + configDir: home, + config, + env: {}, + now: NOW, + fetch: mockFetch, + }); + expect(day7Tick).toMatchObject({ sent: true, day: "2026-11-06" }); + expect(sentPayloads).toHaveLength(2); + expect(sentPayloads[1]!.day).toBe("2026-11-06"); + expect(sentPayloads[1]!.newInstall).toBe(true); + const rotatedInstallId = readTelemetryInstallId(home)!.installId; + expect(rotatedInstallId).not.toBe(originalInstallId); + + // Also verify recordTelemetryEnvOptOut replaces a symlinked telemetry-state.json without following it: + const victim = join(home, "optout-victim.txt"); + writeFileSync(victim, "keep-safe", "utf8"); + const stateFile = telemetryStatePath(home); + rmIfExists(stateFile); + symlinkSync(victim, stateFile); + recordTelemetryEnvOptOut(home, NOW); + expect(readFileSync(victim, "utf8")).toBe("keep-safe"); + expect(lstatSync(stateFile).isSymbolicLink()).toBe(false); + expect(readTelemetryState(home).ineligibleThroughDay).toBe("2026-11-07"); + }); + + test("D7: re-reads consent and env before transmission and aborts in-flight ticks on stop or nudge", async () => { + let fetchCalls = 0; + const mockFetch = (async () => { + fetchCalls += 1; + return new Response(JSON.stringify({ ok: true }), { status: 202 }); + }) as unknown as typeof fetch; + + // 1. Consent changes to declined while aggregateDay is running -> aborts before sending or minting an id. + let liveDecision: "accepted" | "declined" = "accepted"; + const abortedByDecline = await runTelemetryTick({ + configDir: home, + readConfig: () => ({ + telemetry: { + decision: liveDecision, + decidedAt: "2026-11-04T00:00:00.000Z", + noticeShownAt: "2026-11-04T00:00:00.000Z", + }, + }), + env: {}, + now: NOW, + aggregateDay: async day => { + liveDecision = "declined"; + return aggregateTelemetryEntriesForDay(day, []); + }, + fetch: mockFetch, + }); + expect(abortedByDecline).toEqual({ sent: false, day: "2026-11-05", reason: "declined" }); + expect(fetchCalls).toBe(0); + expect(readTelemetryInstallId(home)).toBeNull(); + + // 2. Env opt-out set while aggregateDay is running -> aborts before sending and records ineligibleThroughDay. + const envBag: Record = {}; + const abortedByEnv = await runTelemetryTick({ + configDir: home, + config: { + telemetry: { + decision: "accepted", + decidedAt: "2026-11-04T00:00:00.000Z", + noticeShownAt: "2026-11-04T00:00:00.000Z", + }, + }, + env: envBag, + now: NOW, + aggregateDay: async day => { + envBag.OCX_TELEMETRY = "0"; + return aggregateTelemetryEntriesForDay(day, []); + }, + fetch: mockFetch, + }); + expect(abortedByEnv).toEqual({ sent: false, day: "2026-11-05", reason: "env-ocx-telemetry" }); + expect(fetchCalls).toBe(0); + expect(readTelemetryState(home).ineligibleThroughDay).toBe("2026-11-07"); + + // Reset state for signal cancellation checks: + writeTelemetryState( + { + version: 1, + sentDays: {}, + lastSentDay: null, + lastSentAt: null, + ineligibleThroughDay: null, + retryAfterUntil: null, + }, + home, + NOW, + ); + + // 3. nudgeTelemetryScheduler() cancels an in-flight tick's AbortSignal. + let observedSignalAborted = false; + const abortedByNudge = await runTelemetryTick({ + configDir: home, + config: { + telemetry: { + decision: "accepted", + decidedAt: "2026-11-04T00:00:00.000Z", + noticeShownAt: "2026-11-04T00:00:00.000Z", + }, + }, + env: {}, + now: NOW, + aggregateDay: async (day, opts) => { + nudgeTelemetryScheduler(); + observedSignalAborted = opts?.signal?.aborted === true; + return aggregateTelemetryEntriesForDay(day, []); + }, + fetch: mockFetch, + }); + expect(observedSignalAborted).toBe(true); + expect(abortedByNudge).toEqual({ sent: false, day: "2026-11-05", reason: "aborted" }); + expect(fetchCalls).toBe(0); + }); + + test("D11: honors HTTP 429 and 503 Retry-After, persists retryAfterUntil, skips ticks until elapsed, and applies jitter", async () => { + expect(parseRetryAfterMs("120", NOW)).toBe(120_000); + expect(parseRetryAfterMs(new Date(NOW + 90_000).toUTCString(), NOW)).toBe(90_000); + expect(parseRetryAfterMs("invalid", NOW)).toBeNull(); + expect(computeTelemetryStartupDelayMs(() => 0.5)).toBe(60_000); + expect(computeTelemetryIntervalDelayMs(1000, () => 0.5)).toBe(1000 + 7.5 * 60 * 1000); + + const config = { + telemetry: { + decision: "accepted" as const, + decidedAt: "2026-11-04T00:00:00.000Z", + noticeShownAt: "2026-11-04T00:00:00.000Z", + }, + }; + let calls = 0; + const rateLimitedTick = await runTelemetryTick({ + configDir: home, + config, + env: {}, + now: NOW, + fetch: (async () => { + calls += 1; + return new Response("rate limited", { + status: 429, + headers: { "Retry-After": "120" }, + }); + }) as unknown as typeof fetch, + }); + expect(rateLimitedTick.sent).toBe(false); + expect(rateLimitedTick.reason).toBe("retry-after-backoff"); + expect(calls).toBe(1); + const stateAfter429 = readTelemetryState(home); + expect(stateAfter429.retryAfterUntil).toBe(new Date(NOW + 120_000).toISOString()); + + // Tick before retryAfterUntil is skipped without calling fetch: + const skippedTick = await runTelemetryTick({ + configDir: home, + config, + env: {}, + now: NOW + 60_000, + fetch: (async () => { + calls += 1; + return new Response(JSON.stringify({ ok: true }), { status: 202 }); + }) as unknown as typeof fetch, + }); + expect(skippedTick).toEqual({ sent: false, reason: "retry-after-backoff" }); + expect(calls).toBe(1); + + // Tick after retryAfterUntil succeeds and clears retryAfterUntil: + const recoveredTick = await runTelemetryTick({ + configDir: home, + config, + env: {}, + now: NOW + 121_000, + fetch: (async () => { + calls += 1; + return new Response(JSON.stringify({ ok: true }), { status: 202 }); + }) as unknown as typeof fetch, + }); + expect(recoveredTick).toMatchObject({ sent: true, day: "2026-11-05" }); + expect(calls).toBe(2); + expect(readTelemetryState(home).retryAfterUntil).toBeNull(); }); test("writeTelemetryState replaces a symlinked telemetry-state.json without following it", () => { From 8f0b86e5e8bce44d938026ad3f87a364e971e992 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 18:26:42 +0900 Subject: [PATCH 08/37] test(telemetry): prove env opt-out days of a long-running proxy are never sent A proxy started under OCX_TELEMETRY=0 reads the env once and runs no ticks, so it records ineligibleThroughDay only for its start day. These tests drive the real background lifecycle with a mocked clock for an opt-out period of several days, ended by a crash and by a clean stop. They fail on this commit: the first normal tick sends 2026-11-03, a day the proxy ran opted out. --- tests/usage/usage-telemetry-client.test.ts | 85 ++++++++++++++++++++-- 1 file changed, 80 insertions(+), 5 deletions(-) diff --git a/tests/usage/usage-telemetry-client.test.ts b/tests/usage/usage-telemetry-client.test.ts index 0b6f8dd3d6d..f602b56cd20 100644 --- a/tests/usage/usage-telemetry-client.test.ts +++ b/tests/usage/usage-telemetry-client.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, setSystemTime, spyOn, test } from "bun:test"; import { existsSync, lstatSync, mkdtempSync, readdirSync, readFileSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -618,7 +618,8 @@ describe("install identity, aggregation, disjoint tokens, and scheduler", () => } expect(readTelemetryState(home).ineligibleThroughDay).toBe("2026-11-05"); - // User removes OCX_TELEMETRY=0 on 2026-11-06: days 2026-11-03..2026-11-05 are <= ineligibleThroughDay, so nothing is sent on 2026-11-06! + // User removes OCX_TELEMETRY=0 on 2026-11-06. Nothing marked the end of the opt-out, so the first + // tick without it ends the period on its own day: 2026-11-03..2026-11-06 are never sent. const day6Tick = await runTelemetryTick({ configDir: home, config, @@ -627,9 +628,9 @@ describe("install identity, aggregation, disjoint tokens, and scheduler", () => fetch: mockFetch, }); expect(day6Tick).toEqual({ sent: false, reason: "up-to-date" }); + expect(readTelemetryState(home)).toMatchObject({ ineligibleThroughDay: "2026-11-06", envOptOutActiveSince: null }); expect(sentPayloads).toHaveLength(1); - // On 2026-11-07, day 2026-11-06 (strictly after ineligibleThroughDay 2026-11-05) is sent with a fresh installId and newInstall=true: const day7Tick = await runTelemetryTick({ configDir: home, config, @@ -637,9 +638,19 @@ describe("install identity, aggregation, disjoint tokens, and scheduler", () => now: NOW, fetch: mockFetch, }); - expect(day7Tick).toMatchObject({ sent: true, day: "2026-11-06" }); + expect(day7Tick).toEqual({ sent: false, reason: "up-to-date" }); + + // On 2026-11-08, day 2026-11-07 (strictly after ineligibleThroughDay 2026-11-06) is sent with a fresh installId and newInstall=true: + const day8Tick = await runTelemetryTick({ + configDir: home, + config, + env: {}, + now: Date.parse("2026-11-08T12:00:00.000Z"), + fetch: mockFetch, + }); + expect(day8Tick).toMatchObject({ sent: true, day: "2026-11-07" }); expect(sentPayloads).toHaveLength(2); - expect(sentPayloads[1]!.day).toBe("2026-11-06"); + expect(sentPayloads[1]!.day).toBe("2026-11-07"); expect(sentPayloads[1]!.newInstall).toBe(true); const rotatedInstallId = readTelemetryInstallId(home)!.installId; expect(rotatedInstallId).not.toBe(originalInstallId); @@ -656,6 +667,70 @@ describe("install identity, aggregation, disjoint tokens, and scheduler", () => expect(readTelemetryState(home).ineligibleThroughDay).toBe("2026-11-07"); }); + describe("D2: a proxy running under an env opt-out for several days", () => { + const config = { + telemetry: { + decision: "accepted" as const, + decidedAt: "2026-10-30T10:00:00.000Z", + noticeShownAt: "2026-10-30T10:00:00.000Z", + }, + }; + const proxyConfig = { port: 0, defaultProvider: "openai", providers: {} } as unknown as OcxConfig; + let sentDays: string[]; + let prevTelemetry: string | undefined; + const mockFetch = (async (_url: string | URL | Request, init?: RequestInit) => { + sentDays.push((JSON.parse(String(init?.body)) as TelemetryPayloadV1).day); + return new Response(JSON.stringify({ ok: true }), { status: 202 }); + }) as unknown as typeof fetch; + const tickAt = (iso: string) => + runTelemetryTick({ configDir: home, config, env: {}, now: Date.parse(iso), fetch: mockFetch }); + // The proxy reads the env once, when its background lifecycle starts; it runs no ticks while opted out. + const startOptedOutProxy = (iso: string) => { + setSystemTime(new Date(iso)); + process.env.OCX_TELEMETRY = "0"; + try { + return acquireServerBackgroundLifecycle(() => {}, proxyConfig); + } finally { + delete process.env.OCX_TELEMETRY; + } + }; + + beforeEach(() => { + sentDays = []; + prevTelemetry = process.env.OCX_TELEMETRY; + }); + + afterEach(() => { + setSystemTime(); + if (prevTelemetry === undefined) delete process.env.OCX_TELEMETRY; + else process.env.OCX_TELEMETRY = prevTelemetry; + }); + + test("never sends its opt-out days when it did not stop cleanly before a normal proxy ticks", async () => { + const lease = startOptedOutProxy("2026-11-02T09:00:00.000Z"); + try { + // Monday through Thursday ran opted out. A normal proxy takes over on Friday. + setSystemTime(new Date("2026-11-06T12:00:00.000Z")); + expect((await tickAt("2026-11-06T12:00:00.000Z")).sent).toBe(false); + expect((await tickAt("2026-11-07T12:00:00.000Z")).sent).toBe(false); + expect(await tickAt("2026-11-08T12:00:00.000Z")).toMatchObject({ sent: true, day: "2026-11-07" }); + expect(sentDays).toEqual(["2026-11-07"]); + } finally { + await lease.release(); + } + }); + + test("never sends its opt-out days after it stops cleanly", async () => { + const lease = startOptedOutProxy("2026-11-02T09:00:00.000Z"); + setSystemTime(new Date("2026-11-05T20:00:00.000Z")); + await lease.release(); + setSystemTime(new Date("2026-11-06T12:00:00.000Z")); + expect(await tickAt("2026-11-06T12:00:00.000Z")).toEqual({ sent: false, reason: "up-to-date" }); + expect(await tickAt("2026-11-07T12:00:00.000Z")).toMatchObject({ sent: true, day: "2026-11-06" }); + expect(sentDays).toEqual(["2026-11-06"]); + }); + }); + test("D7: re-reads consent and env before transmission and aborts in-flight ticks on stop or nudge", async () => { let fetchCalls = 0; const mockFetch = (async () => { From a09bb51698716537aafd20593c7ff65ac71963bc Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 18:26:49 +0900 Subject: [PATCH 09/37] fix(telemetry): keep a long-running env opt-out period ineligible until it ends Observing OCX_TELEMETRY=0 or DO_NOT_TRACK=1 now also persists an envOptOutActiveSince marker in telemetry-state.json. While it is set no day is eligible. The opted-out proxy's clean stop, or the first tick that runs without the opt-out, closes the period: every day through the closing day stays ineligible and the marker is cleared. A crashed opted-out proxy therefore costs at most the restart day and never leaks its opt-out days. SPONSORS.md no longer says opencodex collects no telemetry. --- SPONSORS.md | 5 +++- src/server/background-lifecycle.ts | 9 +++++- src/telemetry/scheduler.ts | 44 ++++++++++++++++++++++++++++-- structure/telemetry.md | 8 ++++-- 4 files changed, 60 insertions(+), 6 deletions(-) diff --git a/SPONSORS.md b/SPONSORS.md index 97506d9ce9a..bac68f3552c 100644 --- a/SPONSORS.md +++ b/SPONSORS.md @@ -95,7 +95,10 @@ draft agreement. request reaches. - No relaxation of the [security review](./MAINTAINERS.md) that applies to authentication, credentials, or workflow changes. -- No access to user data, request logs, or telemetry; opencodex does not collect any. +- No access to user data, request logs, or individual telemetry reports. The only telemetry opencodex + collects is the anonymous daily usage summary described in the + [privacy statement](./README.md#code-signing-policy), which users can turn off; its project-wide + aggregates are public, and sponsors see nothing beyond them. - No say over unrelated issues, pull requests, or the release schedule. ## Current sponsors diff --git a/src/server/background-lifecycle.ts b/src/server/background-lifecycle.ts index abfcc5e67d9..2eeb91221d1 100644 --- a/src/server/background-lifecycle.ts +++ b/src/server/background-lifecycle.ts @@ -33,7 +33,7 @@ import { type MemoryWatchdog, } from "./memory-watchdog"; import { ensureQuotaPoolIdentity } from "../quota-pool/identity"; -import { recordTelemetryEnvOptOut } from "../telemetry/scheduler"; +import { endTelemetryEnvOptOut, recordTelemetryEnvOptOut } from "../telemetry/scheduler"; type PolicyApply = (policy: StorageCleanupPolicy) => void; @@ -63,6 +63,8 @@ let processLoops: ProcessLoops | null = null; let cleanupInProgress = false; let telemetryLoopGeneration = 0; let stopTelemetryLoop: (() => void) | null = null; +// This process started under an env opt-out; its clean stop closes the opt-out period (D2). +let telemetryEnvOptOutObserved = false; function isTelemetryDisabledByEnv(env: NodeJS.ProcessEnv = process.env): boolean { const ocx = env.OCX_TELEMETRY?.trim().toLowerCase(); @@ -145,6 +147,7 @@ function startProcessLoops(applyPolicy: PolicyApply): ProcessLoops { // Optional background loop. }); } else { + telemetryEnvOptOutObserved = true; try { recordTelemetryEnvOptOut(); } catch { /* best-effort */ } } return { memoryWatchdog, stateStoreSweeper }; @@ -168,6 +171,10 @@ function stopProcessLoops(): void { const stopTelemetry = stopTelemetryLoop; stopTelemetryLoop = null; stopTelemetry?.(); + if (telemetryEnvOptOutObserved) { + telemetryEnvOptOutObserved = false; + try { endTelemetryEnvOptOut(); } catch { /* best-effort; the marker stays and the next tick ends it */ } + } const loops = processLoops; processLoops = null; loops?.memoryWatchdog.stop(); diff --git a/src/telemetry/scheduler.ts b/src/telemetry/scheduler.ts index 61ac771cc48..9583e9bf0ef 100644 --- a/src/telemetry/scheduler.ts +++ b/src/telemetry/scheduler.ts @@ -34,6 +34,13 @@ export interface TelemetryState { lastSentAt: string | null; ineligibleThroughDay: string | null; retryAfterUntil: string | null; + /** + * Set whenever an env opt-out is observed and cleared only by a clean opted-out shutdown or by the + * next tick that runs without the opt-out. While it is set, every day up to the current one is + * ineligible, because a proxy started under `OCX_TELEMETRY=0` reads the env once and runs no ticks + * that could keep `ineligibleThroughDay` current (D2). + */ + envOptOutActiveSince?: string | null; } export function telemetryStatePath(configDir = getConfigDir()): string { @@ -48,6 +55,7 @@ function emptyState(): TelemetryState { lastSentAt: null, ineligibleThroughDay: null, retryAfterUntil: null, + envOptOutActiveSince: null, }; } @@ -74,6 +82,10 @@ export function readTelemetryState(configDir = getConfigDir()): TelemetryState { typeof raw.retryAfterUntil === "string" && !Number.isNaN(Date.parse(raw.retryAfterUntil)) ? raw.retryAfterUntil : null; + const envOptOutActiveSince = + typeof raw.envOptOutActiveSince === "string" && !Number.isNaN(Date.parse(raw.envOptOutActiveSince)) + ? raw.envOptOutActiveSince + : null; return { version: 1, sentDays, @@ -81,6 +93,7 @@ export function readTelemetryState(configDir = getConfigDir()): TelemetryState { lastSentAt: typeof raw.lastSentAt === "string" ? raw.lastSentAt : null, ineligibleThroughDay, retryAfterUntil, + envOptOutActiveSince, }; } catch { return emptyState(); @@ -103,13 +116,15 @@ export function writeTelemetryState(state: TelemetryState, configDir = getConfig lastSentAt: state.lastSentAt ?? null, ineligibleThroughDay: state.ineligibleThroughDay ?? null, retryAfterUntil: state.retryAfterUntil ?? null, + envOptOutActiveSince: state.envOptOutActiveSince ?? null, }; atomicWriteFileNoFollow(path, `${JSON.stringify(payload, null, 2)}\n`); } /** - * Persist `ineligibleThroughDay = today's UTC day` in `telemetry-state.json` (atomic no-follow) - * and delete `telemetry-id.json` whenever an `OCX_TELEMETRY` or `DO_NOT_TRACK` opt-out is observed. + * Persist `ineligibleThroughDay = today's UTC day` and the active opt-out marker in + * `telemetry-state.json` (atomic no-follow) and delete `telemetry-id.json` whenever an + * `OCX_TELEMETRY` or `DO_NOT_TRACK` opt-out is observed. */ export function recordTelemetryEnvOptOut(configDir = getConfigDir(), now = Date.now()): void { const todayDay = formatUtcDay(utcMidnightMs(now)); @@ -117,6 +132,24 @@ export function recordTelemetryEnvOptOut(configDir = getConfigDir(), now = Date. if (!state.ineligibleThroughDay || state.ineligibleThroughDay < todayDay) { state.ineligibleThroughDay = todayDay; } + state.envOptOutActiveSince ??= new Date(now).toISOString(); + writeTelemetryState(state, configDir, now); + deleteTelemetryInstallId(configDir); +} + +/** + * End an observed env opt-out: every day up to today stays ineligible and the marker is cleared, so + * only days strictly after today can be sent. Called by a clean shutdown of an opted-out proxy and + * by the first tick that runs without the opt-out. + */ +export function endTelemetryEnvOptOut(configDir = getConfigDir(), now = Date.now()): void { + const state = readTelemetryState(configDir); + if (!state.envOptOutActiveSince) return; + const todayDay = formatUtcDay(utcMidnightMs(now)); + if (!state.ineligibleThroughDay || state.ineligibleThroughDay < todayDay) { + state.ineligibleThroughDay = todayDay; + } + state.envOptOutActiveSince = null; writeTelemetryState(state, configDir, now); deleteTelemetryInstallId(configDir); } @@ -139,6 +172,7 @@ export function latestCompletedUtcDay(now = Date.now()): string { * - `status.effective` must be true. * - Strictly AFTER the UTC day of `decidedAt ?? noticeShownAt` (D1). * - Strictly AFTER `state.ineligibleThroughDay` when set (D2). + * - Nothing while an env opt-out marker is still set: that period may have run until today (D2). * - Not already recorded in `state.sentDays`. */ export function resolveNextEligibleTelemetryDay( @@ -149,6 +183,9 @@ export function resolveNextEligibleTelemetryDay( if (!status.effective) { return { day: null, reason: status.reason }; } + if (state.envOptOutActiveSince) { + return { day: null, reason: "env-opt-out-pending" }; + } const consentTimestamp = status.decidedAt ?? status.noticeShownAt; if (!consentTimestamp) { return { day: null, reason: "missing-consent-timestamp" }; @@ -244,6 +281,9 @@ export async function runTelemetryTick(options: TelemetryTickOptions = {}): Prom return { sent: false, reason: status.reason }; } + if (readTelemetryState(configDir).envOptOutActiveSince) { + endTelemetryEnvOptOut(configDir, now); + } const state = readTelemetryState(configDir); if (state.retryAfterUntil) { const retryUntilMs = Date.parse(state.retryAfterUntil); diff --git a/structure/telemetry.md b/structure/telemetry.md index 82dc673dde6..b42c4f87ec2 100644 --- a/structure/telemetry.md +++ b/structure/telemetry.md @@ -63,8 +63,12 @@ with a 10-second `signalWithTimeout` and parses `Retry-After` on HTTP 429/503 re `src/telemetry/scheduler.ts` runs an unref'd background timer with startup and interval jitter started from `src/server/background-lifecycle.ts`. Whenever an `OCX_TELEMETRY` or `DO_NOT_TRACK` opt-out is observed (at startup in `src/server/background-lifecycle.ts` or on a tick), -`recordTelemetryEnvOptOut` persists `ineligibleThroughDay` (today's UTC day) in -`telemetry-state.json` and deletes `telemetry-id.json`. Each tick sends at most one completed UTC +`recordTelemetryEnvOptOut` persists `ineligibleThroughDay` (today's UTC day) and the +`envOptOutActiveSince` marker in `telemetry-state.json` and deletes `telemetry-id.json`. A proxy +started under the opt-out reads the env once and runs no ticks, so the marker stands for the whole +period it ran: while it is set no day is eligible, and `endTelemetryEnvOptOut` closes the period +(ineligible through the closing day, marker cleared) on that proxy's clean stop or on the first tick +that runs without the opt-out. Each tick sends at most one completed UTC day strictly after the UTC day of `decidedAt ?? noticeShownAt` and strictly after `ineligibleThroughDay`, honors `retryAfterUntil` backoffs, re-reads persisted consent and env immediately before transmission, and carries an `AbortSignal` cancelled on stop or consent changes. From b6c9314fa227e4774e7502ef725a0c8eae0c04c3 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 18:27:50 +0900 Subject: [PATCH 10/37] docs(devlog): plan the env opt-out marker, privacy statement files, and the open SignPath condition - 000: the no-backfill rule covers env opt-out days and the disclosure day; the privacy statement changes in layer 2. - 040: lists every file that carries the published privacy statement (README, seven translations, both code-signing pages, SPONSORS.md) and says they land in layer 2. - 060: D2 adds the envOptOutActiveSince marker for a proxy that runs opted out for days; records SignPath's install-time condition verbatim as an open owner decision instead of assuming first-run disclosure satisfies it. --- .../261107_telemetry_dashboard/000_plan.md | 7 +++++- .../040_docs_privacy.md | 18 +++++++++++++++ .../060_review_round.md | 23 +++++++++++++++---- 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/devlog/_plan/261107_telemetry_dashboard/000_plan.md b/devlog/_plan/261107_telemetry_dashboard/000_plan.md index 317491c152e..63da28bb8c8 100644 --- a/devlog/_plan/261107_telemetry_dashboard/000_plan.md +++ b/devlog/_plan/261107_telemetry_dashboard/000_plan.md @@ -41,7 +41,12 @@ footer and in the PR descriptions. - The collector stores no IP address and no raw install id: reports carry a per-day install hash, rate limiting uses a salted hourly IP hash pruned after two hours, and only the first report per install and day counts. -- No backfill: days before the disclosure are never sent; at most one day is sent per scheduler tick. +- No backfill: only UTC days strictly after the day of the disclosure or decision are sent, days + covered by an `OCX_TELEMETRY=0` / `DO_NOT_TRACK=1` opt-out are never sent (060 D2), and at most one + day is sent per scheduler tick. +- The published privacy statement (README and its seven translations, the docs-site code-signing + page in English and Turkish, `SPONSORS.md`) describes default-on telemetry and every way to turn it + off. It changes in layer 2, the first layer that can send, never later (060 D12). - The collector URL is a constant; it is not configurable from `config.json`. - Telemetry code stays off the core request path: `src/router.ts`, `src/server/lifecycle.ts` and `src/server/responses/core.ts` must not import `src/telemetry/`. diff --git a/devlog/_plan/261107_telemetry_dashboard/040_docs_privacy.md b/devlog/_plan/261107_telemetry_dashboard/040_docs_privacy.md index b50b0589b7e..8f399f4ff06 100644 --- a/devlog/_plan/261107_telemetry_dashboard/040_docs_privacy.md +++ b/devlog/_plan/261107_telemetry_dashboard/040_docs_privacy.md @@ -3,6 +3,24 @@ Stack layer 5. Lands before the dashboard (050) because the dashboard links to this guide and the docs build rejects links to missing pages. +## Privacy statement (lands in layer 2, not here) + +The project's published privacy statement must stop saying that nothing is sent before the first +layer that can send merges, so these files change in layer 2 (060 D12) and layer 5 only links them to +the guide: + +- `README.md` (Code signing policy → Privacy) and the same line in `readme/README.fr.md`, + `README.ja.md`, `README.ko.md`, `README.ru.md`, `README.tr.md`, `README.zh-CN.md` and + `README.zh-TW.md`, with `readme/i18n-manifest.json` refreshed. +- `docs-site/src/content/docs/reference/code-signing.md` and its Turkish copy + `docs-site/src/content/docs/tr/reference/code-signing.md` (the Privacy section, including the + "carry none of your … usage data" sentence). +- `SPONSORS.md` ("opencodex does not collect any" telemetry). + +The statement replaces SignPath's "will not transfer any information …" sentence, which SignPath +allows only as an alternative to linking a privacy policy. Whether first-run disclosure satisfies +SignPath's install-time conditions is an open owner decision (060, "SignPath conditions"). + ## Public docs - `docs-site/src/content/docs/guides/telemetry.md` (English source): what is collected, what is never diff --git a/devlog/_plan/261107_telemetry_dashboard/060_review_round.md b/devlog/_plan/261107_telemetry_dashboard/060_review_round.md index eceb29c3fc1..b85b03b89a9 100644 --- a/devlog/_plan/261107_telemetry_dashboard/060_review_round.md +++ b/devlog/_plan/261107_telemetry_dashboard/060_review_round.md @@ -12,13 +12,28 @@ systems unless specifically requested…". With default-on telemetry that senten conditions (https://signpath.org/terms.html) allow software that sends data to systems the user did not specify only if the behaviour is described in a privacy policy, shown during installation, and can be disabled at installation. The replacement statement (D12) says exactly that, lands on layer 2 (the first -layer that can send), and layer 5 links it to the telemetry guide. The interactive install-time prompt and -the first-run dashboard popup are the "shown during installation, can be disabled" points. +layer that can send), and layer 5 links it to the telemetry guide. + +## SignPath conditions (open owner decision) + +SignPath Foundation's conditions (https://signpath.org/terms, read 2026-11-08), "Respect user privacy and +security": "Software that collects user data and transfers it to systems not specified by the user must +a) describe this behavior in a privacy policy, b) display this policy during installation and c) include +installation options to disable these functions." Its website condition allows either a privacy-policy link +or the "will not transfer" sentence. + +(a) is met by the D12 statement. (b) and (c) are not clearly met. SignPath signs the standalone `ocx.exe`, +which has no installer, and the MSI, whose installer shows no policy and has no disable option. The CLI +prompt and the dashboard popup appear on first run, after installation. The release workflow still uses the +`test-signing` policy, so no production-signed build exists yet. Round 1 treated first-run disclosure as +"during installation"; that reading is not confirmed. The owner decides whether to add an MSI dialog +(policy and a disable checkbox) before switching to `release-signing`, to ask SignPath first, or to +accept first-run disclosure. No layer that can send merges before that decision. ## Decisions D1 Consent-day cutoff: only UTC days strictly AFTER the UTC day of (decidedAt ?? noticeShownAt) are ever sent. -D2 Env opt-out: whenever OCX_TELEMETRY or DO_NOT_TRACK opt-out is observed (proxy startup in background-lifecycle even though the scheduler is not started, and on every tick), persist ineligibleThroughDay = today's UTC day in telemetry-state.json (atomic no-follow write) and delete the install id; only days strictly after ineligibleThroughDay are sent. Tests: consented user runs with OCX_TELEMETRY=0 for days, removes it, those days are never sent and a fresh id is minted. +D2 Env opt-out: whenever OCX_TELEMETRY or DO_NOT_TRACK opt-out is observed (proxy startup in background-lifecycle even though the scheduler is not started, and on every tick), persist ineligibleThroughDay = today's UTC day and an envOptOutActiveSince marker in telemetry-state.json (atomic no-follow write) and delete the install id; only days strictly after ineligibleThroughDay are sent. A proxy started under the opt-out reads the env once and runs no ticks, so the marker covers the whole time it runs: while it is set nothing is eligible, and the period closes (ineligible through the closing day, marker cleared) on that proxy's clean stop or on the first tick without the opt-out. Tests: consented user runs with OCX_TELEMETRY=0 for days, removes it, those days are never sent and a fresh id is minted; a proxy that runs opted out from Monday to Thursday, ended by a crash and by a clean stop, never lets those days be sent (failing-first in layer 2). D3 Version regex everywhere (contract): ^\d+\.\d+\.\d+(-preview\.\d{8}(\.\d+)?)?$ ; test vectors 2.40.1, 2.6.15-preview.20260701, 2.6.15-preview.20260701.1, 2.7.9-preview.20260712.2; reject build metadata. D4 ocx telemetry enable: requires an interactive human terminal (isatty stdin AND stdout, not OCX_SERVICE, not isAgentDriven); prints the disclosure block, then asks a confirm (default yes) and records accepted; otherwise exits non-zero with agent_consent_required / tty_required pointing to the dashboard. disable always allowed. D5 Disclosure block printed before every CLI Y/n prompt (ocx start/init/service install and enable): what is sent, what is never sent, that aggregates are public at opencodex.me/dashboard, how to see the exact payload (ocx telemetry status --json), how to turn it off (ocx telemetry disable, OCX_TELEMETRY=0, DO_NOT_TRACK=1, Proxy details toggle). A CLI test pins the text. @@ -28,7 +43,7 @@ D8 Aggregation accumulates counters inside onEntry for the target day only; neve D9 CLI enable/disable: if mutatePersistedConfig returns unavailable (or throws), exit non-zero with an actionable error, print no success line, and do not delete the id unless the decline persisted. D10 GET /api/telemetry preview uses the same day the scheduler would send next (oldest eligible unsent day). If none is eligible, preview the latest completed day and return previewEligible:false (field added); never imply it will be sent. D11 Client honors HTTP 429 Retry-After (and 503): persist retryAfterUntil in telemetry-state.json, skip ticks until then; spreads sends with jitter. -D12 Privacy statement (README.md, all 7 readme/README.*.md, docs-site/src/content/docs/reference/code-signing.md): replace the SignPath "will not transfer any information..." sentence with an accurate statement: opencodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to telemetry.opencodex.me only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off at install time in that prompt and anytime with ocx telemetry disable, OCX_TELEMETRY=0, DO_NOT_TRACK=1 or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. Keep the README Privacy line in English in every translation (as today) unless readme/i18n-manifest.json rules require otherwise. code-signing.md: also fix the "carry none of your ... usage data" claim. +D12 Privacy statement (README.md, all 7 readme/README.*.md, docs-site/src/content/docs/reference/code-signing.md and its tr copy, SPONSORS.md): replace the SignPath "will not transfer any information..." sentence with an accurate statement: opencodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to telemetry.opencodex.me only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off at install time in that prompt and anytime with ocx telemetry disable, OCX_TELEMETRY=0, DO_NOT_TRACK=1 or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. Keep the README Privacy line in English in every translation (as today) unless readme/i18n-manifest.json rules require otherwise. code-signing.md: also fix the "carry none of your ... usage data" claim. D13 GUI popup dismissal (Escape/backdrop/close) records NOTHING; only Continue (PUT /api/telemetry) records decision and notice; the popup returns on the next app start while undecided. Remove POST /api/telemetry/notice entirely (server route, registry entry, capability exemptions, GUI call, tests, docs). D14 Stats semantics: activeInstalls in totals and installs in breakdowns are INSTALL-DAYS (sum of daily reporting installs); add totals.avgDailyActiveInstalls (install-days / days in range, rounded to 1 decimal). Dashboard shows "Avg daily active installs" as the KPI with install-days in the tooltip. D15 Small-cohort protection (collector): add an installs column (count of contributing reports) to daily_models and daily_errors (and dailyByProvider derivation); in every breakdown (providers, models, versions, platforms, channels, errors, dailyByProvider per day) fold rows with fewer than 10 install-days in the selected range into "other"; drop "other" if it is itself below 10; totals/daily/heatmap unchanged. Document the residual subtraction risk. From 11a0f8ff10fbebf62582600a7acf0555e7bbdd2a Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 18:44:55 +0900 Subject: [PATCH 11/37] test(telemetry): pin that an unreachable or undeployed collector fails a tick quietly --- tests/usage/usage-telemetry-client.test.ts | 26 ++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/usage/usage-telemetry-client.test.ts b/tests/usage/usage-telemetry-client.test.ts index f602b56cd20..c0dcd5106e7 100644 --- a/tests/usage/usage-telemetry-client.test.ts +++ b/tests/usage/usage-telemetry-client.test.ts @@ -731,6 +731,32 @@ describe("install identity, aggregation, disjoint tokens, and scheduler", () => }); }); + test("an unreachable or undeployed collector fails the tick quietly and leaves the day for a later retry", async () => { + const config = { + telemetry: { + decision: "accepted" as const, + decidedAt: "2026-11-01T10:00:00.000Z", + noticeShownAt: "2026-11-01T10:00:00.000Z", + }, + }; + const consoleSpies = [spyOn(console, "error"), spyOn(console, "warn"), spyOn(console, "log")]; + try { + const dnsFailure = (async () => { + throw new TypeError("getaddrinfo ENOTFOUND telemetry.opencodex.me"); + }) as unknown as typeof fetch; + const notDeployed = (async () => new Response("Not Found", { status: 404 })) as unknown as typeof fetch; + for (const fetchImpl of [dnsFailure, notDeployed]) { + const result = await runTelemetryTick({ configDir: home, config, env: {}, now: NOW, fetch: fetchImpl }); + expect(result).toMatchObject({ sent: false, day: "2026-11-02", reason: "send-failed" }); + expect(readTelemetryState(home).sentDays).toEqual({}); + expect(readTelemetryState(home).retryAfterUntil).toBeNull(); + } + for (const spy of consoleSpies) expect(spy).not.toHaveBeenCalled(); + } finally { + for (const spy of consoleSpies) spy.mockRestore(); + } + }); + test("D7: re-reads consent and env before transmission and aborts in-flight ticks on stop or nudge", async () => { let fetchCalls = 0; const mockFetch = (async () => { From ed53c2e99c28a1d3a8617a2b38fb38474df10bc1 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 18:54:01 +0900 Subject: [PATCH 12/37] test(telemetry): prove zero sends before noticeShownAt across ten undecided days --- tests/usage/usage-telemetry-client.test.ts | 34 ++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/usage/usage-telemetry-client.test.ts b/tests/usage/usage-telemetry-client.test.ts index c0dcd5106e7..e520969a731 100644 --- a/tests/usage/usage-telemetry-client.test.ts +++ b/tests/usage/usage-telemetry-client.test.ts @@ -439,6 +439,40 @@ describe("install identity, aggregation, disjoint tokens, and scheduler", () => expect(resolveInstallChannel({ env: {}, exists: () => false, detectOwnership: () => ({ installer: "mystery" }) })).toBe("unknown"); }); + test("nothing is ever sent before noticeShownAt, however long the proxy runs undecided", async () => { + let fetchCalls = 0; + const sentDays: string[] = []; + const mockFetch = (async (_url: string | URL | Request, init?: RequestInit) => { + fetchCalls += 1; + sentDays.push((JSON.parse(String(init?.body)) as TelemetryPayloadV1).day); + return new Response(JSON.stringify({ ok: true }), { status: 202 }); + }) as unknown as typeof fetch; + // Undecided installs with no recorded notice: an absent block, an empty block, and explicit nulls. + const undisclosed: Array> = [ + {}, + { telemetry: {} }, + { telemetry: { decision: null, decidedAt: null, noticeShownAt: null } }, + ] as Array>; + for (const config of undisclosed) { + for (let day = 0; day < 10; day++) { + for (const hour of ["00:30", "06:30", "12:30", "18:30"]) { + const now = Date.parse(`2026-11-${String(day + 1).padStart(2, "0")}T${hour}:00.000Z`); + const result = await runTelemetryTick({ configDir: home, config, env: {}, now, fetch: mockFetch }); + expect(result).toEqual({ sent: false, reason: "awaiting-notice" }); + } + } + } + expect(fetchCalls).toBe(0); + expect(readTelemetryInstallId(home)).toBeNull(); + expect(readTelemetryState(home).sentDays).toEqual({}); + + // The notice is shown on 2026-11-10; only completed days after it are ever sent. + const disclosed = { telemetry: { decision: null, decidedAt: null, noticeShownAt: "2026-11-10T09:00:00.000Z" } } as Pick; + expect((await runTelemetryTick({ configDir: home, config: disclosed, env: {}, now: Date.parse("2026-11-11T12:00:00.000Z"), fetch: mockFetch })).sent).toBe(false); + expect(await runTelemetryTick({ configDir: home, config: disclosed, env: {}, now: Date.parse("2026-11-12T12:00:00.000Z"), fetch: mockFetch })).toMatchObject({ sent: true, day: "2026-11-11" }); + expect(sentDays).toEqual(["2026-11-11"]); + }); + test("scheduler sends nothing when not effective, skips pre-consent days, and sends at most one day per tick oldest first", async () => { const sentPayloads: TelemetryPayloadV1[] = []; const mockFetch = (async (_url: string | URL | Request, init?: RequestInit) => { From b4dd9340487e286ffce144f6d573fb1341438350 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 19:00:45 +0900 Subject: [PATCH 13/37] fix(telemetry): let ocx telemetry disable create config.json on a fresh install A decline that arrives before the first start (the Windows installer opt-out, or a user running ocx telemetry disable first) failed with 'config.json is missing'. A missing config is now created the way a first start creates it, carrying the decision; a concurrent first start that wins leaves a file to patch instead. --- src/cli/telemetry.ts | 19 +++++++++++++++---- tests/cli/cli-telemetry.test.ts | 21 ++++++++++++++++++++- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/src/cli/telemetry.ts b/src/cli/telemetry.ts index b89cfd2fa5e..4fb890460f5 100644 --- a/src/cli/telemetry.ts +++ b/src/cli/telemetry.ts @@ -1,7 +1,7 @@ import { lstatSync, mkdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { isatty } from "node:tty"; -import { getConfigDir, mutatePersistedConfig, readConfigDiagnostics } from "../config"; +import { getConfigDir, getDefaultConfig, initializePersistedConfigIfMissing, mutatePersistedConfig, readConfigDiagnostics } from "../config"; import { atomicWriteFileNoFollow } from "../config/atomic-write"; import { withConfigDirHardeningSettled } from "../config/paths"; import { recordOwnedConfigPath } from "../lib/config-ownership"; @@ -76,7 +76,7 @@ function isInteractiveTty(): boolean { async function persistTelemetryDecision(decision: OcxTelemetryDecision): Promise { const nowIso = new Date().toISOString(); const dir = getConfigDir(); - const outcome = await withConfigDirHardeningSettled(dir, () => + const mutate = () => mutatePersistedConfig(fresh => { const prev = fresh.telemetry; const next: OcxTelemetryConfig = { @@ -87,8 +87,19 @@ async function persistTelemetryDecision(decision: OcxTelemetryDecision): Promise const changed = JSON.stringify(prev) !== JSON.stringify(next); fresh.telemetry = next; return { changed, value: next }; - }), - ); + }); + const outcome = await withConfigDirHardeningSettled(dir, () => { + const first = mutate(); + if (first.status !== "unavailable" || first.reason !== "missing") return first; + // A fresh install has no config.json yet; the Windows installer's opt-out arrives exactly + // then. Create it the way a first start would, carrying the decision. A concurrent first + // start that wins the race leaves a file to patch instead. + const created = initializePersistedConfigIfMissing({ + ...getDefaultConfig(), + telemetry: { decision, decidedAt: nowIso, noticeShownAt: nowIso }, + }); + return created === "created" ? { status: "committed" as const, value: null } : mutate(); + }); if (outcome.status === "unavailable") { throw new Error( `Cannot update telemetry configuration: config.json is ${outcome.reason}. Fix or restore config.json before retrying.`, diff --git a/tests/cli/cli-telemetry.test.ts b/tests/cli/cli-telemetry.test.ts index e8b25bf6a0a..3f6d897db6e 100644 --- a/tests/cli/cli-telemetry.test.ts +++ b/tests/cli/cli-telemetry.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; -import { existsSync, lstatSync, mkdtempSync, readdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; +import { existsSync, lstatSync, mkdtempSync, readdirSync, readFileSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { handleConfigCommand } from "../../src/cli/config-command"; @@ -12,6 +12,7 @@ import { setTelemetryPromptDepsForTests, } from "../../src/cli/telemetry"; import { readConfigDiagnostics } from "../../src/config/diagnostics"; +import { resolveTelemetryStatus } from "../../src/telemetry/consent"; import { ensureTelemetryInstallId, readTelemetryInstallId, @@ -309,6 +310,24 @@ describe("ocx telemetry CLI and interactive prompt", () => { } }); + test("disable on a fresh install with no config.json creates it with the decline", async () => { + const log = spyOn(console, "log").mockImplementation(() => {}); + try { + // The Windows installer's opt-out runs before the first proxy start has written any config. + unlinkSync(join(home, "config.json")); + expect(await handleTelemetryCommand(["disable", "--json"])).toBe(0); + const saved = JSON.parse(readFileSync(join(home, "config.json"), "utf8")) as { telemetry?: { decision?: string } }; + expect(saved.telemetry?.decision).toBe("declined"); + expect(readConfigDiagnostics().config.telemetry?.decision).toBe("declined"); + expect(resolveTelemetryStatus(readConfigDiagnostics().config, {}).effective).toBe(false); + // A second decline leaves the created file in place. + expect(await handleTelemetryCommand(["disable", "--json"])).toBe(0); + expect(readConfigDiagnostics().config.telemetry?.decision).toBe("declined"); + } finally { + log.mockRestore(); + } + }); + test("D9: enable and disable exit non-zero with no success line and preserve telemetry-id.json when config.json is unavailable", async () => { const log = spyOn(console, "log").mockImplementation(() => {}); const err = spyOn(console, "error").mockImplementation(() => {}); From 4ba7d13518d01d6baf58701d43966e5265928d12 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 19:02:26 +0900 Subject: [PATCH 14/37] feat(desktop): Windows installer page to disclose telemetry and opt out at install time SignPath Foundation's conditions say software that sends usage data must describe it in a privacy policy, display that policy during installation, and include an installation option to disable it. - The MSI gains a page between Welcome and InstallDir, generated into desktop/src-tauri/wix/telemetry-choice.wxs from src/telemetry/disclosure.ts (the text the CLI prompt prints). It links the privacy statement and has a "Send anonymous usage statistics" checkbox, checked by default. The choice is written to HKCU\Software\OpenCodex\Telemetry\InstallerChoice; silent installs pass OCX_TELEMETRY_CHOICE=optout. - Before resolving, attaching to or starting a proxy, the app carries an opt-out into the user's config with the bundled ocx telemetry disable, then marks the value applied. If the decline did not persist, the sidecar runs with OCX_TELEMETRY=0 for that session. - README (and translations), the code signing privacy statement (en, tr) and the desktop guide describe the installer option. --- README.md | 2 +- desktop/scripts/telemetry-installer-dialog.ts | 146 ++++++++++++ desktop/src-tauri/src/installer_telemetry.rs | 223 ++++++++++++++++++ desktop/src-tauri/src/lib.rs | 1 + desktop/src-tauri/src/sidecar.rs | 6 +- desktop/src-tauri/src/startup.rs | 3 + desktop/src-tauri/tauri.conf.json | 8 +- desktop/src-tauri/wix/telemetry-choice.wxs | 45 ++++ .../src/content/docs/guides/desktop-app.md | 13 + .../content/docs/reference/code-signing.md | 3 +- .../content/docs/tr/reference/code-signing.md | 7 +- readme/README.fr.md | 2 +- readme/README.ja.md | 2 +- readme/README.ko.md | 2 +- readme/README.ru.md | 2 +- readme/README.tr.md | 2 +- readme/README.zh-CN.md | 2 +- readme/README.zh-TW.md | 2 +- readme/i18n-manifest.json | 14 +- scripts/test-layout/layout-clients.json | 1 + src/cli/telemetry.ts | 9 +- src/telemetry/disclosure.ts | 19 ++ structure/desktop-shell.md | 23 ++ structure/telemetry.md | 4 + .../desktop-telemetry-installer.test.ts | 152 ++++++++++++ tests/fixtures/test-layout-expected.json | 2 +- 26 files changed, 667 insertions(+), 28 deletions(-) create mode 100644 desktop/scripts/telemetry-installer-dialog.ts create mode 100644 desktop/src-tauri/src/installer_telemetry.rs create mode 100644 desktop/src-tauri/wix/telemetry-choice.wxs create mode 100644 src/telemetry/disclosure.ts create mode 100644 tests/clients/desktop-telemetry-installer.test.ts diff --git a/README.md b/README.md index e7f3df50651..b280d86f3c1 100644 --- a/README.md +++ b/README.md @@ -498,7 +498,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off at install time in that prompt and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice, in the Windows installer (a "Send anonymous usage statistics" checkbox, checked by default) and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## Disclaimer diff --git a/desktop/scripts/telemetry-installer-dialog.ts b/desktop/scripts/telemetry-installer-dialog.ts new file mode 100644 index 00000000000..75d6b2fc003 --- /dev/null +++ b/desktop/scripts/telemetry-installer-dialog.ts @@ -0,0 +1,146 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { + TELEMETRY_DISCLOSURE_HEADING, + TELEMETRY_DISCLOSURE_POINTS, + TELEMETRY_POLICY_URL, +} from "../../src/telemetry/disclosure"; + +/** + * Generates the WiX fragment that adds the telemetry choice to the Windows installer. + * + * SignPath Foundation's conditions say software that sends usage data must describe it in a privacy + * policy, display that policy during installation, and include an installation option to disable + * it. The fragment inserts a dialog between the welcome and install-directory pages of Tauri's + * WixUI_InstallDir sequence. It shows the same disclosure the CLI prompt prints, links the policy, + * and offers a "Send anonymous usage statistics" checkbox, checked by default. The choice is + * written to HKCU; the desktop app (`installer_telemetry.rs`) persists an opt-out into the user's + * opencodex config before it starts or attaches to a proxy. + * + * Silent installs choose with the public property: `msiexec /i OpenCodex.msi /qn OCX_TELEMETRY_CHOICE=optout`. + * + * `bun desktop/scripts/telemetry-installer-dialog.ts --write` regenerates the committed file; + * `--check` fails when it is stale. + */ + +export const TELEMETRY_INSTALLER_FRAGMENT_PATH = "wix/telemetry-choice.wxs"; +export const TELEMETRY_INSTALLER_COMPONENT_GROUP = "OcxTelemetryInstallerChoice"; +export const TELEMETRY_INSTALLER_DIALOG = "OcxTelemetryDlg"; +export const TELEMETRY_INSTALLER_REGISTRY_KEY = "Software\\OpenCodex\\Telemetry"; +export const TELEMETRY_INSTALLER_REGISTRY_VALUE = "InstallerChoice"; +export const TELEMETRY_INSTALLER_CHOICE_PROPERTY = "OCX_TELEMETRY_CHOICE"; +export const TELEMETRY_INSTALLER_CHECKBOX_PROPERTY = "OCX_TELEMETRY_SEND"; +export const TELEMETRY_INSTALLER_CHECKBOX_TEXT = "Send anonymous usage statistics"; +export const TELEMETRY_INSTALLER_SEND = "send"; +export const TELEMETRY_INSTALLER_OPT_OUT = "optout"; +// Fixed so upgrades keep one component identity for the registry value. +const COMPONENT_GUID = "6F2B8A41-3C7D-4E59-9A1F-0D8C5B7E2A93"; + +// Dialog units. WiX's standard dialogs are 370 x 270 with the button row below y = 234. +const TEXT_LEFT = 20; +const TEXT_WIDTH = 330; +const CHARS_PER_LINE = 72; +const LINE_HEIGHT = 10; +const POINTS_TOP = 50; +const POINT_GAP = 2; + +function xmlAttr(value: string): string { + // MSI formats control text, so brackets would be read as property references. + if (/[[\]{}]/.test(value)) throw new Error(`Installer text must not contain brackets or braces: ${value}`); + // The en-US MSI database uses code page 1252; keep to ASCII plus the bullet it also carries. + if (!/^[\x20-\x7E•]*$/.test(value)) throw new Error(`Installer text must stay within code page 1252: ${value}`); + return value + .replaceAll("&", "&") + .replaceAll('"', """) + .replaceAll("<", "<") + .replaceAll(">", ">"); +} + +function wrappedLines(text: string): number { + let lines = 1; + let width = 0; + for (const word of text.split(" ")) { + const next = width === 0 ? word.length : width + 1 + word.length; + if (next > CHARS_PER_LINE && width > 0) { + lines += 1; + width = word.length; + } else { + width = next; + } + } + return lines; +} + +export function renderTelemetryInstallerFragment(): string { + const controls: string[] = []; + let y = POINTS_TOP; + TELEMETRY_DISCLOSURE_POINTS.forEach((point, index) => { + const height = wrappedLines(point) * LINE_HEIGHT; + controls.push( + ` `, + ); + y += height + POINT_GAP; + }); + const linkY = y + 2; + const checkboxY = linkY + 14; + if (checkboxY + 17 > 232) { + throw new Error(`The telemetry disclosure no longer fits the installer dialog (checkbox ends at ${checkboxY + 17}).`); + } + return ` + + + + + + + + + + + + + + + + + +${controls.join("\n")} + + Read the full privacy policy]]> + + + + 1 + + + ${TELEMETRY_INSTALLER_CHECKBOX_PROPERTY} = "1" + NOT (${TELEMETRY_INSTALLER_CHECKBOX_PROPERTY} = "1") + 1 + + + 1 + + + + 1 + 1 + + + +`; +} + +if (import.meta.main) { + const target = join(import.meta.dir, "..", "src-tauri", TELEMETRY_INSTALLER_FRAGMENT_PATH); + const expected = renderTelemetryInstallerFragment(); + if (process.argv.includes("--write")) { + writeFileSync(target, expected, "utf8"); + } else { + let actual = ""; + try { actual = readFileSync(target, "utf8"); } catch { /* missing counts as stale */ } + if (actual !== expected) { + console.error(`${TELEMETRY_INSTALLER_FRAGMENT_PATH} is stale; run bun desktop/scripts/telemetry-installer-dialog.ts --write`); + process.exit(1); + } + } +} diff --git a/desktop/src-tauri/src/installer_telemetry.rs b/desktop/src-tauri/src/installer_telemetry.rs new file mode 100644 index 00000000000..c5fa5c65ec1 --- /dev/null +++ b/desktop/src-tauri/src/installer_telemetry.rs @@ -0,0 +1,223 @@ +//! The Windows installer's "Send anonymous usage statistics" choice. +//! +//! SignPath Foundation's conditions require software that sends usage data to include an +//! installation option that disables it. The MSI dialog generated into +//! `desktop/src-tauri/wix/telemetry-choice.wxs` records the person's choice under +//! `HKCU\Software\OpenCodex\Telemetry\InstallerChoice`. Before the app resolves, attaches to or +//! starts a proxy, this module carries an opt-out into the user's opencodex config by running the +//! bundled `ocx telemetry disable`, which persists `telemetry.decision = "declined"`. The value is +//! then rewritten to `applied`, so a later choice made in the dashboard is never overridden. +//! +//! When the decline could not be persisted, the sidecar this app starts runs with +//! `OCX_TELEMETRY=0`, so nothing is sent in that session, and the next launch tries again. +//! +//! An MSI install does not put `ocx.exe` on PATH; the terminal command is installed by this app. +//! The app therefore runs before any proxy from that install can. + +use std::sync::atomic::{AtomicBool, Ordering}; +use tauri::AppHandle; +use tauri_plugin_shell::ShellExt; +use tokio::time::{timeout_at, Instant}; + +// Read and written only on Windows; the names are pinned against the WiX fragment by a test. +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +pub const REGISTRY_KEY: &str = r"Software\OpenCodex\Telemetry"; +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +pub const REGISTRY_VALUE: &str = "InstallerChoice"; +pub const OPT_OUT: &str = "optout"; +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +pub const APPLIED: &str = "applied"; + +static SIDECAR_ENV_OPT_OUT: AtomicBool = AtomicBool::new(false); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Outcome { + /// No opt-out is pending: no value, `send`, or one that was already applied. + NothingPending, + /// The decline is in the user's config and the registry value now reads `applied`. + Applied, + /// The decline is in the config, but the registry value could not be rewritten. + AppliedUnmarked, + /// The decline could not be persisted; this session's sidecar runs with `OCX_TELEMETRY=0`. + NotPersisted, +} + +/// Whether a recorded installer choice still has to be carried into the config. +pub fn is_pending_opt_out(value: Option<&str>) -> bool { + value.is_some_and(|value| value.trim().eq_ignore_ascii_case(OPT_OUT)) +} + +/// `ocx telemetry disable` exits 0 only once the decline is persisted. +pub fn decline_persisted(exit_code: Option) -> bool { + exit_code == Some(0) +} + +/// Decide what a pending choice leads to, given how persisting and marking went. +pub fn settle( + value: Option<&str>, + persist: impl FnOnce() -> bool, + mark_applied: impl FnOnce() -> bool, +) -> Outcome { + if !is_pending_opt_out(value) { + return Outcome::NothingPending; + } + if !persist() { + return Outcome::NotPersisted; + } + if mark_applied() { + Outcome::Applied + } else { + Outcome::AppliedUnmarked + } +} + +/// Whether the sidecar this app starts must carry `OCX_TELEMETRY=0`. +pub fn sidecar_env_opt_out() -> bool { + SIDECAR_ENV_OPT_OUT.load(Ordering::SeqCst) +} + +/// Carry a pending installer opt-out into the user's opencodex config. +pub async fn apply(app: &AppHandle, deadline: Instant) -> Outcome { + let value = os::read(); + let persisted = if is_pending_opt_out(value.as_deref()) { + disable(app, deadline).await + } else { + false + }; + let outcome = settle(value.as_deref(), || persisted, os::mark_applied); + match outcome { + Outcome::NotPersisted => { + SIDECAR_ENV_OPT_OUT.store(true, Ordering::SeqCst); + crate::logging::log_once( + "the installer's telemetry opt-out could not be saved", + "this session runs with OCX_TELEMETRY=0 and the next launch tries again", + ); + } + Outcome::AppliedUnmarked => crate::logging::log_once( + "the installer's telemetry opt-out was saved but not marked as applied", + "the next launch saves the decline again", + ), + Outcome::NothingPending | Outcome::Applied => {} + } + outcome +} + +async fn disable(app: &AppHandle, deadline: Instant) -> bool { + let command = match app.shell().sidecar("ocx") { + Ok(command) => command.args(["telemetry", "disable", "--json"]), + Err(_) => return false, + }; + match timeout_at( + deadline, + crate::process_spawn::with_spawn_async(command.output()), + ) + .await + { + Ok(Ok(output)) => decline_persisted(output.status.code()), + _ => false, + } +} + +#[cfg(target_os = "windows")] +mod os { + use winreg::enums::{HKEY_CURRENT_USER, KEY_READ, KEY_SET_VALUE}; + use winreg::RegKey; + + pub fn read() -> Option { + RegKey::predef(HKEY_CURRENT_USER) + .open_subkey_with_flags(super::REGISTRY_KEY, KEY_READ) + .ok()? + .get_value::(super::REGISTRY_VALUE) + .ok() + } + + pub fn mark_applied() -> bool { + RegKey::predef(HKEY_CURRENT_USER) + .open_subkey_with_flags(super::REGISTRY_KEY, KEY_SET_VALUE) + .and_then(|key| key.set_value(super::REGISTRY_VALUE, &super::APPLIED.to_owned())) + .is_ok() + } +} + +#[cfg(not(target_os = "windows"))] +mod os { + // Only the Windows installer records a choice. + pub fn read() -> Option { + None + } + + pub fn mark_applied() -> bool { + true + } +} + +#[cfg(test)] +mod tests { + use super::{decline_persisted, is_pending_opt_out, settle, Outcome}; + use std::cell::Cell; + + #[test] + fn only_a_recorded_opt_out_is_pending() { + assert!(is_pending_opt_out(Some("optout"))); + assert!(is_pending_opt_out(Some(" OptOut "))); + for value in [ + None, + Some(""), + Some("send"), + Some("applied"), + Some("opt-out-later"), + ] { + assert!(!is_pending_opt_out(value), "{value:?}"); + } + } + + #[test] + fn only_a_clean_exit_counts_as_persisted() { + assert!(decline_persisted(Some(0))); + for code in [None, Some(1), Some(2), Some(-1)] { + assert!(!decline_persisted(code), "{code:?}"); + } + } + + #[test] + fn nothing_runs_without_a_pending_opt_out() { + for value in [None, Some("send"), Some("applied")] { + let ran = Cell::new(false); + let outcome = settle( + value, + || { + ran.set(true); + true + }, + || { + ran.set(true); + true + }, + ); + assert_eq!(outcome, Outcome::NothingPending); + assert!(!ran.get(), "{value:?}"); + } + } + + #[test] + fn an_opt_out_is_marked_only_after_the_decline_persisted() { + let marked = Cell::new(false); + assert_eq!( + settle( + Some("optout"), + || false, + || { + marked.set(true); + true + } + ), + Outcome::NotPersisted + ); + assert!(!marked.get()); + assert_eq!(settle(Some("optout"), || true, || true), Outcome::Applied); + assert_eq!( + settle(Some("optout"), || true, || false), + Outcome::AppliedUnmarked + ); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index fdc10147b1d..2b17ef86d54 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -13,6 +13,7 @@ mod exit; mod first_run; mod formatting; mod identity; +mod installer_telemetry; mod logging; mod login_autostart; // The judgment is portable so Linux CI runs its fixture parity tests; only macOS reads launchd. diff --git a/desktop/src-tauri/src/sidecar.rs b/desktop/src-tauri/src/sidecar.rs index e601498fe0f..f055f1e6020 100644 --- a/desktop/src-tauri/src/sidecar.rs +++ b/desktop/src-tauri/src/sidecar.rs @@ -198,13 +198,17 @@ pub fn start( .map_err(|error| error.to_string())? .join("gui") .join("dist"); - let command = app + let mut command = app .shell() .sidecar("ocx") .map_err(|error| error.to_string())? .args(["start", "--port", &endpoint.port.to_string()]) .env("OPENCODEX_GUI_DIST", gui_dist) .env(SUPERVISED_ENV, "1"); + // The installer's opt-out could not be saved: keep this session from sending anything. + if crate::installer_telemetry::sidecar_env_opt_out() { + command = command.env("OCX_TELEMETRY", "0"); + } let (events, child) = crate::process_spawn::with_spawn(|| command.spawn()).map_err(|error| error.to_string())?; let watch = watch.for_child(child.pid()); diff --git a/desktop/src-tauri/src/startup.rs b/desktop/src-tauri/src/startup.rs index 0b3578e05ce..d7a5e86b620 100644 --- a/desktop/src-tauri/src/startup.rs +++ b/desktop/src-tauri/src/startup.rs @@ -809,6 +809,9 @@ async fn run(app: &AppHandle, started: Instant) { ); report(app, started, Phase::Resolving, None); + // SignPath's installation-option condition: an opt-out chosen in the Windows installer is + // saved into the user's config before any proxy is resolved, attached to or started. + crate::installer_telemetry::apply(app, deadline).await; // D5: the shell no longer resolves the home, the port or liveness. It asks the bundled CLI, // which owns the tuned probe budgets that exist because a shell-side reimplementation answered // "nobody is listening" twice and started duplicate proxies. The call is inside the sequence, so diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 892d5c37df0..c21b2ae41a1 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -52,7 +52,13 @@ "type": "downloadBootstrapper" }, "wix": { - "language": "en-US" + "language": "en-US", + "fragmentPaths": [ + "wix/telemetry-choice.wxs" + ], + "componentGroupRefs": [ + "OcxTelemetryInstallerChoice" + ] } }, "linux": { diff --git a/desktop/src-tauri/wix/telemetry-choice.wxs b/desktop/src-tauri/wix/telemetry-choice.wxs new file mode 100644 index 00000000000..5f8e93e75c9 --- /dev/null +++ b/desktop/src-tauri/wix/telemetry-choice.wxs @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + Read the full privacy policy]]> + + + + 1 + + + OCX_TELEMETRY_SEND = "1" + NOT (OCX_TELEMETRY_SEND = "1") + 1 + + + 1 + + + + 1 + 1 + + + diff --git a/docs-site/src/content/docs/guides/desktop-app.md b/docs-site/src/content/docs/guides/desktop-app.md index 33a593d15e4..a9c387f8895 100644 --- a/docs-site/src/content/docs/guides/desktop-app.md +++ b/docs-site/src/content/docs/guides/desktop-app.md @@ -28,6 +28,19 @@ SignPath Foundation is rolling out ([Windows Code Signing](/reference/code-signi release carries the signature, Windows SmartScreen may warn about an unsigned installer; choose **More info → Run anyway** after confirming that you downloaded it from the release page. +One installer page describes the anonymous daily usage summary OpenCodex sends, links the +[privacy statement](/reference/code-signing/#privacy), and has a **Send anonymous usage +statistics** checkbox, checked by default. Clear it and the app saves the opt-out to your +opencodex config the first time it starts, before any proxy runs. For a silent install, pass +`OCX_TELEMETRY_CHOICE=optout`: + +```powershell +msiexec /i OpenCodex--windows-x64.msi /qn OCX_TELEMETRY_CHOICE=optout +``` + +Leaving the box checked does not start sending by itself: the dashboard still shows its one-time +notice first. Updates install without this page and never change an earlier opt-out. + ### Linux Download `OpenCodex--linux-x86_64.AppImage` or diff --git a/docs-site/src/content/docs/reference/code-signing.md b/docs-site/src/content/docs/reference/code-signing.md index 356c5937c68..fb9d515574a 100644 --- a/docs-site/src/content/docs/reference/code-signing.md +++ b/docs-site/src/content/docs/reference/code-signing.md @@ -33,7 +33,8 @@ SignPath Foundation policy forbids signing them with the project certificate. OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and -has not declined; it can be turned off at install time in that prompt and anytime with +has not declined; it can be turned off in that notice, in the Windows installer (a "Send +anonymous usage statistics" checkbox, checked by default) and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. In practice, OpenCodex forwards requests to the providers you configure. To look for new versions it also diff --git a/docs-site/src/content/docs/tr/reference/code-signing.md b/docs-site/src/content/docs/tr/reference/code-signing.md index 890146fa9bf..4485af4c644 100644 --- a/docs-site/src/content/docs/tr/reference/code-signing.md +++ b/docs-site/src/content/docs/tr/reference/code-signing.md @@ -35,9 +35,10 @@ OpenCodex, yalnızca programı kuran veya çalıştıran kişi bildirimi (kontro açılır penceresi veya etkileşimli CLI istemi) gördükten ve reddetmedikten sonra `telemetry.opencodex.me` adresine günde bir anonim kullanım özeti (sürüm, platform, sayımlar; istemler, çıktılar, anahtarlar, hesaplar, ana bilgisayar adları veya dosya -yolları asla dahil edilmez) gönderir; kurulum sırasında bu istemden ve istediğiniz -zaman `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` veya Proxy -details üzerinden kapatılabilir; proje genelindeki toplamlar herkese açıktır; bunun +yolları asla dahil edilmez) gönderir; bu bildirimde, Windows yükleyicisinde +(varsayılan olarak işaretli "Send anonymous usage statistics" onay kutusu) ve +istediğiniz zaman `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` +veya Proxy details üzerinden kapatılabilir; proje genelindeki toplamlar herkese açıktır; bunun dışında veriler yalnızca kullanıcının yapılandırdığı yerlere gider. Uygulamada OpenCodex, istekleri yapılandırdığınız sağlayıcılara iletir. Yeni sürümler için GitHub ve npm kayıt deposundan yayın meta verilerini de okur; bu güncelleme diff --git a/readme/README.fr.md b/readme/README.fr.md index cc8d08dff5f..e4378aaade3 100644 --- a/readme/README.fr.md +++ b/readme/README.fr.md @@ -496,7 +496,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off at install time in that prompt and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice, in the Windows installer (a "Send anonymous usage statistics" checkbox, checked by default) and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## Avis de non-responsabilité diff --git a/readme/README.ja.md b/readme/README.ja.md index 5762cd62484..71fe1ef9459 100644 --- a/readme/README.ja.md +++ b/readme/README.ja.md @@ -490,7 +490,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off at install time in that prompt and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice, in the Windows installer (a "Send anonymous usage statistics" checkbox, checked by default) and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## 免責事項 diff --git a/readme/README.ko.md b/readme/README.ko.md index 8693f1e68ff..e1187537221 100644 --- a/readme/README.ko.md +++ b/readme/README.ko.md @@ -473,7 +473,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off at install time in that prompt and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice, in the Windows installer (a "Send anonymous usage statistics" checkbox, checked by default) and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## 면책 조항 diff --git a/readme/README.ru.md b/readme/README.ru.md index 368a5899490..3432caacf1f 100644 --- a/readme/README.ru.md +++ b/readme/README.ru.md @@ -502,7 +502,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off at install time in that prompt and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice, in the Windows installer (a "Send anonymous usage statistics" checkbox, checked by default) and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## Отказ от ответственности diff --git a/readme/README.tr.md b/readme/README.tr.md index fe0aae5f81f..99e038306c7 100644 --- a/readme/README.tr.md +++ b/readme/README.tr.md @@ -498,7 +498,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off at install time in that prompt and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice, in the Windows installer (a "Send anonymous usage statistics" checkbox, checked by default) and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## Sorumluluk reddi diff --git a/readme/README.zh-CN.md b/readme/README.zh-CN.md index 7c1aa50300d..67e3d3d8a6a 100644 --- a/readme/README.zh-CN.md +++ b/readme/README.zh-CN.md @@ -471,7 +471,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off at install time in that prompt and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice, in the Windows installer (a "Send anonymous usage statistics" checkbox, checked by default) and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## 免责声明 diff --git a/readme/README.zh-TW.md b/readme/README.zh-TW.md index 8cc2b645b03..7de3bde6e79 100644 --- a/readme/README.zh-TW.md +++ b/readme/README.zh-TW.md @@ -470,7 +470,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off at install time in that prompt and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice, in the Windows installer (a "Send anonymous usage statistics" checkbox, checked by default) and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## 免責聲明 diff --git a/readme/i18n-manifest.json b/readme/i18n-manifest.json index 86421b84262..f796946aeff 100644 --- a/readme/i18n-manifest.json +++ b/readme/i18n-manifest.json @@ -6,43 +6,43 @@ "file": "readme/README.fr.md", "label": "Français", "docsPath": "fr", - "sourceSha256": "b47453dc6ff893a9ddeccfcab475d11b99954943a7ddcb5252aadfa751d4f3e4" + "sourceSha256": "34f27fb53db0f49ceec4c04d15e06817f6d327769c148936b58497040249305f" }, "ko": { "file": "readme/README.ko.md", "label": "한국어", "docsPath": "ko", - "sourceSha256": "b47453dc6ff893a9ddeccfcab475d11b99954943a7ddcb5252aadfa751d4f3e4" + "sourceSha256": "34f27fb53db0f49ceec4c04d15e06817f6d327769c148936b58497040249305f" }, "zh-CN": { "file": "readme/README.zh-CN.md", "label": "简体中文", "docsPath": "zh-cn", - "sourceSha256": "b47453dc6ff893a9ddeccfcab475d11b99954943a7ddcb5252aadfa751d4f3e4" + "sourceSha256": "34f27fb53db0f49ceec4c04d15e06817f6d327769c148936b58497040249305f" }, "zh-TW": { "file": "readme/README.zh-TW.md", "label": "繁體中文", "docsPath": "zh-tw", - "sourceSha256": "b47453dc6ff893a9ddeccfcab475d11b99954943a7ddcb5252aadfa751d4f3e4" + "sourceSha256": "34f27fb53db0f49ceec4c04d15e06817f6d327769c148936b58497040249305f" }, "ru": { "file": "readme/README.ru.md", "label": "Русский", "docsPath": "ru", - "sourceSha256": "b47453dc6ff893a9ddeccfcab475d11b99954943a7ddcb5252aadfa751d4f3e4" + "sourceSha256": "34f27fb53db0f49ceec4c04d15e06817f6d327769c148936b58497040249305f" }, "ja": { "file": "readme/README.ja.md", "label": "日本語", "docsPath": "ja", - "sourceSha256": "b47453dc6ff893a9ddeccfcab475d11b99954943a7ddcb5252aadfa751d4f3e4" + "sourceSha256": "34f27fb53db0f49ceec4c04d15e06817f6d327769c148936b58497040249305f" }, "tr": { "file": "readme/README.tr.md", "label": "Türkçe", "docsPath": "tr", - "sourceSha256": "b47453dc6ff893a9ddeccfcab475d11b99954943a7ddcb5252aadfa751d4f3e4" + "sourceSha256": "34f27fb53db0f49ceec4c04d15e06817f6d327769c148936b58497040249305f" } } } diff --git a/scripts/test-layout/layout-clients.json b/scripts/test-layout/layout-clients.json index bc7ece5d730..8695af65593 100644 --- a/scripts/test-layout/layout-clients.json +++ b/scripts/test-layout/layout-clients.json @@ -99,6 +99,7 @@ "desktop-runtime-identity.test.ts": "clients", "desktop-start-at-login-default.test.ts": "clients", "desktop-startup-surface.test.ts": "clients", + "desktop-telemetry-installer.test.ts": "clients", "desktop-update-surface.test.ts": "clients", "desktop-tray-availability.test.ts": "clients", "desktop-widget-entry.test.ts": "clients", diff --git a/src/cli/telemetry.ts b/src/cli/telemetry.ts index 4fb890460f5..5a213a6fcb7 100644 --- a/src/cli/telemetry.ts +++ b/src/cli/telemetry.ts @@ -6,6 +6,7 @@ import { atomicWriteFileNoFollow } from "../config/atomic-write"; import { withConfigDirHardeningSettled } from "../config/paths"; import { recordOwnedConfigPath } from "../lib/config-ownership"; import { resolveTelemetryStatus } from "../telemetry/consent"; +import { TELEMETRY_DISCLOSURE_HEADING, TELEMETRY_DISCLOSURE_POINTS } from "../telemetry/disclosure"; import { deleteTelemetryInstallId } from "../telemetry/install-id"; import { buildTelemetryStatusSnapshot, type TelemetryStatusSnapshot } from "../telemetry/routes"; import type { OcxTelemetryConfig, OcxTelemetryDecision } from "../types"; @@ -31,12 +32,8 @@ export const TELEMETRY_DEFERRAL_FILE = ".telemetry-deferred"; const DEFERRAL_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; export const TELEMETRY_CLI_DISCLOSURE_LINES = Object.freeze([ - " Anonymous daily usage telemetry:", - " • Sent once per day to telemetry.opencodex.me: version, OS/arch, install channel, provider kind, coarse model family, active minutes, and request/token/error counts.", - " • Never sent: prompts, model outputs, request/response bodies, API keys, OAuth tokens, account IDs or emails, custom base URLs, hostnames, file paths, or error text.", - " • Public aggregates: project-wide totals are published at opencodex.me/dashboard.", - " • See the exact payload: ocx telemetry status --json", - " • Turn off anytime: ocx telemetry disable, OCX_TELEMETRY=0, DO_NOT_TRACK=1, or the Proxy details toggle in the dashboard.", + ` ${TELEMETRY_DISCLOSURE_HEADING}`, + ...TELEMETRY_DISCLOSURE_POINTS.map(point => ` • ${point}`), ]); export function printTelemetryDisclosure(): void { diff --git a/src/telemetry/disclosure.ts b/src/telemetry/disclosure.ts new file mode 100644 index 00000000000..524bb698376 --- /dev/null +++ b/src/telemetry/disclosure.ts @@ -0,0 +1,19 @@ +/** + * The telemetry disclosure a person sees before sending can start: the interactive CLI prompt prints + * these points, and the Windows installer dialog (`desktop/src-tauri/wix/telemetry-choice.wxs`, + * generated by `desktop/scripts/telemetry-installer-dialog.ts`) shows the same text with a link to + * the full policy. Import-free so the installer generator can read it without loading the CLI. + */ + +/** The published privacy policy, linked from the installer dialog and the code signing policy. */ +export const TELEMETRY_POLICY_URL = "https://opencodex.me/reference/code-signing/#privacy"; + +export const TELEMETRY_DISCLOSURE_HEADING = "Anonymous daily usage telemetry:"; + +export const TELEMETRY_DISCLOSURE_POINTS: readonly string[] = Object.freeze([ + "Sent once per day to telemetry.opencodex.me: version, OS/arch, install channel, provider kind, coarse model family, active minutes, and request/token/error counts.", + "Never sent: prompts, model outputs, request/response bodies, API keys, OAuth tokens, account IDs or emails, custom base URLs, hostnames, file paths, or error text.", + "Public aggregates: project-wide totals are published at opencodex.me/dashboard.", + "See the exact payload: ocx telemetry status --json", + "Turn off anytime: ocx telemetry disable, OCX_TELEMETRY=0, DO_NOT_TRACK=1, or the Proxy details toggle in the dashboard.", +]); diff --git a/structure/desktop-shell.md b/structure/desktop-shell.md index 38810c5ed0f..7697480c242 100644 --- a/structure/desktop-shell.md +++ b/structure/desktop-shell.md @@ -361,6 +361,29 @@ Installing, repairing and removing the Desktop-owned `ocx` command on PATH is do ## Release packaging and updater +### Windows installer telemetry choice + +SignPath Foundation's conditions require software that sends usage data to display its privacy +policy during installation and to include an installation option that disables it. The MSI links +`desktop/src-tauri/wix/telemetry-choice.wxs` through `bundle.windows.wix.fragmentPaths` and +`componentGroupRefs` in `tauri.conf.json`. The fragment is generated by +`desktop/scripts/telemetry-installer-dialog.ts` from `src/telemetry/disclosure.ts`, the text the +CLI prompt prints, and `tests/clients/desktop-telemetry-installer.test.ts` fails when the committed +file is stale. It inserts `OcxTelemetryDlg` between Tauri's `WelcomeDlg` and `InstallDirDlg` +(`Order="3"` overrides the template's `Order="2"` route) with the disclosure, a policy link and a +"Send anonymous usage statistics" checkbox, checked by default, and writes the choice to +`HKCU\Software\OpenCodex\Telemetry\InstallerChoice` (`send` or `optout`; silent installs pass +`OCX_TELEMETRY_CHOICE=optout`; updates install without the page and write `send`, which is never +acted on). + +`desktop/src-tauri/src/installer_telemetry.rs` runs in the startup sequence before `resolve::run`, +so before the app resolves, attaches to or starts any proxy. A pending `optout` runs the bundled +`ocx telemetry disable --json` (which creates `config.json` on a fresh install) and, after a clean +exit, rewrites the value to `applied` so a later dashboard choice is never overridden. If the +decline did not persist, `sidecar::start` adds `OCX_TELEMETRY=0` for that session and the next +launch tries again. An MSI install puts no `ocx.exe` on PATH; the terminal command comes from this +app, so the app always runs before any proxy from that install. + ### Linux packaged-shell acceptance The ordinary hosted Linux lane builds both AppImage and deb bundles with updater artifacts disabled, diff --git a/structure/telemetry.md b/structure/telemetry.md index b42c4f87ec2..a55952675f5 100644 --- a/structure/telemetry.md +++ b/structure/telemetry.md @@ -42,6 +42,10 @@ Only a human can transition an undecided install to active sending: - Interactive prompts run from `src/cli/star-prompt.ts`, `src/service/cli.ts`, and `src/cli/init.ts`; agent-driven, non-TTY, or service starts print a once-per-version notice and record neither `noticeShownAt` nor `decision`. +- `src/telemetry/disclosure.ts` is the one source for the disclosure text: the CLI prompt prints + it and the Windows installer dialog is generated from it (`structure/desktop-shell.md`, + "Windows installer telemetry choice"). A decline on a fresh install creates `config.json` + through `initializePersistedConfigIfMissing`. ## Identity, aggregation, and scheduling diff --git a/tests/clients/desktop-telemetry-installer.test.ts b/tests/clients/desktop-telemetry-installer.test.ts new file mode 100644 index 00000000000..73b408b4138 --- /dev/null +++ b/tests/clients/desktop-telemetry-installer.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + renderTelemetryInstallerFragment, + TELEMETRY_INSTALLER_CHECKBOX_PROPERTY, + TELEMETRY_INSTALLER_CHECKBOX_TEXT, + TELEMETRY_INSTALLER_CHOICE_PROPERTY, + TELEMETRY_INSTALLER_COMPONENT_GROUP, + TELEMETRY_INSTALLER_DIALOG, + TELEMETRY_INSTALLER_FRAGMENT_PATH, + TELEMETRY_INSTALLER_OPT_OUT, + TELEMETRY_INSTALLER_REGISTRY_KEY, + TELEMETRY_INSTALLER_REGISTRY_VALUE, + TELEMETRY_INSTALLER_SEND, +} from "../../desktop/scripts/telemetry-installer-dialog"; +import { TELEMETRY_CLI_DISCLOSURE_LINES } from "../../src/cli/telemetry"; +import { resolveTelemetryStatus } from "../../src/telemetry/consent"; +import { + TELEMETRY_DISCLOSURE_HEADING, + TELEMETRY_DISCLOSURE_POINTS, + TELEMETRY_POLICY_URL, +} from "../../src/telemetry/disclosure"; +import { runTelemetryTick } from "../../src/telemetry/scheduler"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { repoPath } from "../helpers/repo-root"; + +/** + * SignPath Foundation's conditions: software that sends usage data must describe it in a privacy + * policy, display that policy during installation, and include an installation option to disable + * it. The MSI shows the disclosure from the same source the CLI prompt prints, links the policy, + * and records an unchecked "Send anonymous usage statistics" box as an opt-out that the desktop + * app saves into the user's config before any proxy can run. + */ +const FRAGMENT = repoPath("desktop", "src-tauri", TELEMETRY_INSTALLER_FRAGMENT_PATH); +const RUST = repoPath("desktop", "src-tauri", "src", "installer_telemetry.rs"); + +function rustCode(path: string): string { + return readFileSync(path, "utf8").replace(/\/\/[^\n]*/g, ""); +} + +function rustConst(name: string): string { + const match = new RegExp(`pub const ${name}: &str = r?"([^"]*)";`).exec(readFileSync(RUST, "utf8")); + if (!match) throw new Error(`installer_telemetry.rs lost ${name}`); + return match[1]!; +} + +function xmlUnescape(value: string): string { + return value.replaceAll(""", '"').replaceAll("<", "<").replaceAll(">", ">").replaceAll("&", "&"); +} + +describe("Windows installer telemetry choice", () => { + const wxs = readFileSync(FRAGMENT, "utf8"); + + test("the committed fragment is generated from the shared disclosure source", () => { + expect(wxs).toBe(renderTelemetryInstallerFragment()); + const shown = [...wxs.matchAll(/]* Text="([^"]*)"/g)].map(match => xmlUnescape(match[1]!)); + expect(shown).toEqual(TELEMETRY_DISCLOSURE_POINTS.map(point => `• ${point}`)); + expect(wxs).toContain(`Text="${TELEMETRY_DISCLOSURE_HEADING}"`); + // The CLI prompt prints the same text, so the two disclosures cannot drift apart. + expect(TELEMETRY_CLI_DISCLOSURE_LINES).toEqual([ + ` ${TELEMETRY_DISCLOSURE_HEADING}`, + ...TELEMETRY_DISCLOSURE_POINTS.map(point => ` • ${point}`), + ]); + }); + + test("the dialog links the policy and offers a disable option that is checked by default", () => { + expect(wxs).toContain(`Read the full privacy policy`); + expect(wxs).toContain(``); + expect(wxs).toMatch(new RegExp(`Type="CheckBox"[^>]*Property="${TELEMETRY_INSTALLER_CHECKBOX_PROPERTY}" CheckBoxValue="1" Text="${TELEMETRY_INSTALLER_CHECKBOX_TEXT}"`)); + // Unchecking records an opt-out; silent installs default to send and opt out with the property. + expect(wxs).toContain(``); + expect(wxs).toContain( + `NOT (${TELEMETRY_INSTALLER_CHECKBOX_PROPERTY} = "1")`, + ); + expect(wxs).toContain( + ``, + ); + }); + + test("the dialog sits between the welcome and install-directory pages of Tauri's sequence", () => { + // Tauri's main.wxs routes WelcomeDlg -> InstallDirDlg at Order 2; the later NewDialog wins. + expect(wxs).toContain(`1`); + expect(wxs).toContain(`1`); + const dialog = //.exec(wxs)?.[0] ?? ""; + expect(dialog).toMatch(/ { + const config = JSON.parse(readFileSync(repoPath("desktop", "src-tauri", "tauri.conf.json"), "utf8")); + expect(config.bundle.windows.wix.fragmentPaths).toEqual([TELEMETRY_INSTALLER_FRAGMENT_PATH]); + expect(config.bundle.windows.wix.componentGroupRefs).toEqual([TELEMETRY_INSTALLER_COMPONENT_GROUP]); + expect(wxs).toContain(``); + expect(rustConst("REGISTRY_KEY")).toBe(TELEMETRY_INSTALLER_REGISTRY_KEY); + expect(rustConst("REGISTRY_VALUE")).toBe(TELEMETRY_INSTALLER_REGISTRY_VALUE); + expect(rustConst("OPT_OUT")).toBe(TELEMETRY_INSTALLER_OPT_OUT); + }); + + test("the app saves the opt-out before it resolves or starts a proxy, and falls back to the env opt-out", () => { + const startup = rustCode(repoPath("desktop", "src-tauri", "src", "startup.rs")); + const run = startup.slice(startup.indexOf("async fn run(app: &AppHandle, started: Instant)")); + const applyAt = run.indexOf("crate::installer_telemetry::apply(app, deadline).await"); + expect(applyAt).toBeGreaterThan(0); + expect(applyAt).toBeLessThan(run.indexOf("resolve::run(app, deadline)")); + expect(applyAt).toBeLessThan(run.indexOf("spawn_runtime(")); + const sidecar = rustCode(repoPath("desktop", "src-tauri", "src", "sidecar.rs")); + expect(sidecar).toMatch(/if crate::installer_telemetry::sidecar_env_opt_out\(\) \{\s*command = command\.env\("OCX_TELEMETRY", "0"\);/); + const module = rustCode(RUST); + expect(module).toContain(`.args(["telemetry", "disable", "--json"])`); + }); + + test("the command the app runs persists the opt-out, and nothing is sent afterwards", async () => { + const home = mkdtempSync(join(tmpdir(), "ocx-installer-optout-")); + try { + const env: Record = { + HOME: join(home, "home"), + USERPROFILE: join(home, "home"), + OPENCODEX_HOME: join(home, "ocx"), + PATH: process.env.PATH ?? "", + }; + const result = Bun.spawnSync([process.execPath, repoPath("src", "cli", "index.ts"), "telemetry", "disable", "--json"], { + env, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + expect(result.exitCode).toBe(0); + const saved = JSON.parse(readFileSync(join(home, "ocx", "config.json"), "utf8")) as Pick; + expect(saved.telemetry?.decision).toBe("declined"); + expect(resolveTelemetryStatus(saved, {}).effective).toBe(false); + + let fetchCalls = 0; + const tick = await runTelemetryTick({ + configDir: join(home, "ocx"), + readConfig: () => JSON.parse(readFileSync(join(home, "ocx", "config.json"), "utf8")) as Pick, + env: {}, + now: Date.parse("2026-11-12T12:00:00.000Z"), + fetch: (async () => { + fetchCalls += 1; + return new Response("{}", { status: 202 }); + }) as unknown as typeof fetch, + }); + expect(tick.sent).toBe(false); + expect(fetchCalls).toBe(0); + } finally { + removeTreeWithRetry(home); + } + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 46504f81156..50b379336e5 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -830,7 +830,7 @@ "desktop-host-visibility.test.ts": "clients", "desktop-install-identity.test.ts": "clients", "desktop-profile.test.ts": "clients", "desktop-proxy-direct-transport.test.ts": "clients", "desktop-remote-store.test.ts": "clients", "desktop-runtime-identity.test.ts": "clients", - "desktop-start-at-login-default.test.ts": "clients", "desktop-startup-surface.test.ts": "clients", + "desktop-start-at-login-default.test.ts": "clients", "desktop-startup-surface.test.ts": "clients", "desktop-telemetry-installer.test.ts": "clients", "desktop-update-surface.test.ts": "clients", "desktop-tray-availability.test.ts": "clients", "desktop-widget-entry.test.ts": "clients", "destination-policy-resolved.test.ts": "routing", "devin-adapter-reset-wait.test.ts": "providers", "devin-adapter.test.ts": "providers", From 0e3c03a041ae77eb6131e836df46952f9bdf8835 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 19:04:37 +0900 Subject: [PATCH 15/37] docs(devlog): record the owner's SignPath decision (D21) and the Windows installer layer --- .../261107_telemetry_dashboard/000_plan.md | 1 + .../040_docs_privacy.md | 5 +++-- .../060_review_round.md | 22 +++++++++++++++---- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/devlog/_plan/261107_telemetry_dashboard/000_plan.md b/devlog/_plan/261107_telemetry_dashboard/000_plan.md index 63da28bb8c8..36750627e48 100644 --- a/devlog/_plan/261107_telemetry_dashboard/000_plan.md +++ b/devlog/_plan/261107_telemetry_dashboard/000_plan.md @@ -58,6 +58,7 @@ footer and in the PR descriptions. | --- | --- | --- | | 1 | this unit | Docs-only roadmap. | | 2 | [010_client_core.md](010_client_core.md) | Config field, consent state, install id, aggregate builder, payload v1, sender, management API, `ocx telemetry`, `structure/telemetry.md`. | +| 2b | [060_review_round.md](060_review_round.md) D21 | Windows installer page (disclosure, policy link, opt-out checkbox) and the desktop app carrying an installer opt-out into the config. | | 3 | [020_gui_consent.md](020_gui_consent.md) | First-run card, post-update popup, Advanced toggle, preview dialog, all locales. | | 4 | [030_collector.md](030_collector.md) | Cloudflare Worker + D1: ingest validation, limits, daily rollups, public stats API. | | 5 | [040_docs_privacy.md](040_docs_privacy.md) | Telemetry guide, sidebar, configuration reference. | diff --git a/devlog/_plan/261107_telemetry_dashboard/040_docs_privacy.md b/devlog/_plan/261107_telemetry_dashboard/040_docs_privacy.md index 8f399f4ff06..7d944d29330 100644 --- a/devlog/_plan/261107_telemetry_dashboard/040_docs_privacy.md +++ b/devlog/_plan/261107_telemetry_dashboard/040_docs_privacy.md @@ -18,8 +18,9 @@ the guide: - `SPONSORS.md` ("opencodex does not collect any" telemetry). The statement replaces SignPath's "will not transfer any information …" sentence, which SignPath -allows only as an alternative to linking a privacy policy. Whether first-run disclosure satisfies -SignPath's install-time conditions is an open owner decision (060, "SignPath conditions"). +allows only as an alternative to linking a privacy policy. How the stack meets SignPath's +install-time conditions is recorded in 060 ("SignPath conditions", D21); layer 2b adds the Windows +installer option to these files. ## Public docs diff --git a/devlog/_plan/261107_telemetry_dashboard/060_review_round.md b/devlog/_plan/261107_telemetry_dashboard/060_review_round.md index b85b03b89a9..1567b0c898d 100644 --- a/devlog/_plan/261107_telemetry_dashboard/060_review_round.md +++ b/devlog/_plan/261107_telemetry_dashboard/060_review_round.md @@ -14,7 +14,7 @@ specify only if the behaviour is described in a privacy policy, shown during ins disabled at installation. The replacement statement (D12) says exactly that, lands on layer 2 (the first layer that can send), and layer 5 links it to the telemetry guide. -## SignPath conditions (open owner decision) +## SignPath conditions SignPath Foundation's conditions (https://signpath.org/terms, read 2026-11-08), "Respect user privacy and security": "Software that collects user data and transfers it to systems not specified by the user must @@ -26,9 +26,23 @@ or the "will not transfer" sentence. which has no installer, and the MSI, whose installer shows no policy and has no disable option. The CLI prompt and the dashboard popup appear on first run, after installation. The release workflow still uses the `test-signing` policy, so no production-signed build exists yet. Round 1 treated first-run disclosure as -"during installation"; that reading is not confirmed. The owner decides whether to add an MSI dialog -(policy and a disable checkbox) before switching to `release-signing`, to ask SignPath first, or to -accept first-run disclosure. No layer that can send merges before that decision. +"during installation"; that reading is not confirmed. + +Owner decision (2026-11-08), D21: telemetry stays default-on and the stack satisfies all three: + +- (a) The D12 privacy statement (README and translations, code-signing page in English and Turkish, + `SPONSORS.md`) and the layer 5 guide describe exactly what is sent and how to opt out. +- (b, c) MSI: a new layer between 2 and 3 adds an installer page generated from + `src/telemetry/disclosure.ts`, the same text the CLI prompt prints. It links the policy and has a + "Send anonymous usage statistics" checkbox, checked by default. Unchecked, the choice is recorded in + HKCU and the desktop app persists `telemetry.decision = "declined"` into the user's config before it + resolves, attaches to or starts any proxy. Tests pin the dialog text source and the persisted opt-out. +- (b, c) Standalone `ocx.exe` and npm installs have no installer: nothing is sent before + `noticeShownAt`, and the notice (the CLI prompt or the GUI popup) shows the policy summary and offers + disabling at that moment. A test proves zero sends before `noticeShownAt`. + +Layers that can send (2 and above) merge only after the installer layer and the zero-send test are in +the stack and have passed their reviews and CI. ## Decisions From 04abd39ee23ce83fbbe4c3e98e0311942f3de186 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 19:20:12 +0900 Subject: [PATCH 16/37] fix(telemetry): hold a pre-config decision instead of creating config.json b4dd934048 created a full default config.json when telemetry was declined on a fresh install, so the next ocx init kept the "existing" config and skipped provider setup. The Windows installer opt-out and the dashboard's first-run popup reach the same path; the popup even failed with 503 there. A decision made before config.json exists is now held as pendingDecision in telemetry-state.json. withPendingTelemetryDecision applies it wherever consent is resolved (scheduler ticks, the status route, the CLI prompt) until a decision is written into the config, which clears it. Tests: disable then init still sees a missing config, and the decline survives a config created later; a dashboard PUT on a fresh install returns 200 without writing config.json (503 before). Also count an id minted after an opt-out as a returning install (everReported), reuse isTelemetryEnvOptOut in background-lifecycle, and say in the disclosure that env opt-outs must reach the proxy's own environment. --- src/cli/telemetry.ts | 31 ++++----- src/server/background-lifecycle.ts | 10 +-- src/telemetry/routes.ts | 18 +++-- src/telemetry/scheduler.ts | 70 +++++++++++++++++++- structure/telemetry.md | 5 ++ tests/cli/cli-telemetry.test.ts | 39 ++++++++--- tests/server/server-telemetry-routes.test.ts | 23 ++++++- tests/usage/usage-telemetry-client.test.ts | 18 +++++ 8 files changed, 173 insertions(+), 41 deletions(-) diff --git a/src/cli/telemetry.ts b/src/cli/telemetry.ts index 4fb890460f5..78579f7abfd 100644 --- a/src/cli/telemetry.ts +++ b/src/cli/telemetry.ts @@ -1,13 +1,18 @@ import { lstatSync, mkdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { isatty } from "node:tty"; -import { getConfigDir, getDefaultConfig, initializePersistedConfigIfMissing, mutatePersistedConfig, readConfigDiagnostics } from "../config"; +import { getConfigDir, mutatePersistedConfig, readConfigDiagnostics } from "../config"; import { atomicWriteFileNoFollow } from "../config/atomic-write"; import { withConfigDirHardeningSettled } from "../config/paths"; import { recordOwnedConfigPath } from "../lib/config-ownership"; import { resolveTelemetryStatus } from "../telemetry/consent"; import { deleteTelemetryInstallId } from "../telemetry/install-id"; import { buildTelemetryStatusSnapshot, type TelemetryStatusSnapshot } from "../telemetry/routes"; +import { + clearPendingTelemetryDecision, + recordPendingTelemetryDecision, + withPendingTelemetryDecision, +} from "../telemetry/scheduler"; import type { OcxTelemetryConfig, OcxTelemetryDecision } from "../types"; import { currentVersion } from "../update/index"; import { isAgentDriven } from "./agent-driven"; @@ -36,7 +41,7 @@ export const TELEMETRY_CLI_DISCLOSURE_LINES = Object.freeze([ " • Never sent: prompts, model outputs, request/response bodies, API keys, OAuth tokens, account IDs or emails, custom base URLs, hostnames, file paths, or error text.", " • Public aggregates: project-wide totals are published at opencodex.me/dashboard.", " • See the exact payload: ocx telemetry status --json", - " • Turn off anytime: ocx telemetry disable, OCX_TELEMETRY=0, DO_NOT_TRACK=1, or the Proxy details toggle in the dashboard.", + " • Turn off anytime: ocx telemetry disable, the Proxy details toggle in the dashboard, or OCX_TELEMETRY=0 / DO_NOT_TRACK=1 in the proxy's own environment (a service does not see variables set in your shell).", ]); export function printTelemetryDisclosure(): void { @@ -88,22 +93,18 @@ async function persistTelemetryDecision(decision: OcxTelemetryDecision): Promise fresh.telemetry = next; return { changed, value: next }; }); - const outcome = await withConfigDirHardeningSettled(dir, () => { - const first = mutate(); - if (first.status !== "unavailable" || first.reason !== "missing") return first; + const outcome = await withConfigDirHardeningSettled(dir, () => mutate()); + if (outcome.status === "unavailable" && outcome.reason === "missing") { // A fresh install has no config.json yet; the Windows installer's opt-out arrives exactly - // then. Create it the way a first start would, carrying the decision. A concurrent first - // start that wins the race leaves a file to patch instead. - const created = initializePersistedConfigIfMissing({ - ...getDefaultConfig(), - telemetry: { decision, decidedAt: nowIso, noticeShownAt: nowIso }, - }); - return created === "created" ? { status: "committed" as const, value: null } : mutate(); - }); - if (outcome.status === "unavailable") { + // then. Creating the config would make `ocx init` skip setup, so hold the decision in + // telemetry-state.json; it applies until a decision is written into the config. + recordPendingTelemetryDecision({ decision, decidedAt: nowIso, noticeShownAt: nowIso }, dir); + } else if (outcome.status === "unavailable") { throw new Error( `Cannot update telemetry configuration: config.json is ${outcome.reason}. Fix or restore config.json before retrying.`, ); + } else { + clearPendingTelemetryDecision(dir); } if (decision === "declined") { deleteTelemetryInstallId(dir); @@ -118,7 +119,7 @@ async function persistTelemetryDecision(decision: OcxTelemetryDecision): Promise export async function maybeShowTelemetryPrompt(): Promise { try { const diagnostics = readConfigDiagnostics({ passive: true }); - const status = resolveTelemetryStatus(diagnostics.config, process.env); + const status = resolveTelemetryStatus(withPendingTelemetryDecision(diagnostics.config), process.env); if (status.envOverride !== null || status.decision !== null || status.noticeShownAt !== null) { return; } diff --git a/src/server/background-lifecycle.ts b/src/server/background-lifecycle.ts index 2eeb91221d1..d52636579d1 100644 --- a/src/server/background-lifecycle.ts +++ b/src/server/background-lifecycle.ts @@ -33,6 +33,7 @@ import { type MemoryWatchdog, } from "./memory-watchdog"; import { ensureQuotaPoolIdentity } from "../quota-pool/identity"; +import { isTelemetryEnvOptOut } from "../telemetry/consent"; import { endTelemetryEnvOptOut, recordTelemetryEnvOptOut } from "../telemetry/scheduler"; type PolicyApply = (policy: StorageCleanupPolicy) => void; @@ -66,13 +67,6 @@ let stopTelemetryLoop: (() => void) | null = null; // This process started under an env opt-out; its clean stop closes the opt-out period (D2). let telemetryEnvOptOutObserved = false; -function isTelemetryDisabledByEnv(env: NodeJS.ProcessEnv = process.env): boolean { - const ocx = env.OCX_TELEMETRY?.trim().toLowerCase(); - if (ocx === "0" || ocx === "false" || ocx === "off" || ocx === "no") return true; - const dnt = env.DO_NOT_TRACK?.trim().toLowerCase(); - return dnt === "1" || dnt === "true" || dnt === "yes"; -} - function setLivePolicyOwner(applyPolicy: PolicyApply | null): void { setStorageCleanupPolicyLiveSink(applyPolicy); setStorageCleanupPolicyJobLiveApply(applyPolicy); @@ -137,7 +131,7 @@ function startProcessLoops(applyPolicy: PolicyApply): ProcessLoops { // The next poll tick retries. }); const generation = ++telemetryLoopGeneration; - if (!isTelemetryDisabledByEnv()) { + if (isTelemetryEnvOptOut(process.env) === null) { void import("../telemetry/scheduler") .then(scheduler => { if (generation !== telemetryLoopGeneration) return; diff --git a/src/telemetry/routes.ts b/src/telemetry/routes.ts index 13fb34bdd59..13af9ffb841 100644 --- a/src/telemetry/routes.ts +++ b/src/telemetry/routes.ts @@ -16,10 +16,13 @@ import { } from "./install-id"; import { buildTelemetryPayloadV1, resolveInstallChannel } from "./payload"; import { + clearPendingTelemetryDecision, latestCompletedUtcDay, nudgeTelemetryScheduler, readTelemetryState, + recordPendingTelemetryDecision, resolveNextEligibleTelemetryDay, + withPendingTelemetryDecision, } from "./scheduler"; export interface TelemetryStatusSnapshot extends TelemetryConsentStatus { @@ -37,7 +40,7 @@ export async function buildTelemetryStatusSnapshot( const now = options.now ?? Date.now(); const env = options.env ?? process.env; const configDir = options.configDir ?? getConfigDir(); - const status = resolveTelemetryStatus(config, env); + const status = resolveTelemetryStatus(withPendingTelemetryDecision(config, configDir), env); const installChannel = resolveInstallChannel({ env }); const state = readTelemetryState(configDir); const { day: nextEligibleDay } = resolveNextEligibleTelemetryDay(status, state, now); @@ -48,9 +51,7 @@ export async function buildTelemetryStatusSnapshot( const installDayHash = identity ? computeInstallDayHash(identity.installId, day) : TELEMETRY_PREVIEW_HASH_PLACEHOLDER; - const newInstall = identity - ? identity.firstReportSent !== true - : true; + const newInstall = state.everReported !== true && identity?.firstReportSent !== true; const preview = buildTelemetryPayloadV1({ aggregate, installDayHash, @@ -126,13 +127,20 @@ export async function handlePutTelemetryRoute(ctx: ManagementContext): Promise; + if (value.decision !== "accepted" && value.decision !== "declined") return null; + const stamp = (key: string): string | null => + typeof value[key] === "string" && !Number.isNaN(Date.parse(value[key] as string)) ? (value[key] as string) : null; + const decidedAt = stamp("decidedAt"); + if (!decidedAt) return null; + return { decision: value.decision, decidedAt, noticeShownAt: stamp("noticeShownAt") ?? decidedAt }; +} + export function readTelemetryState(configDir = getConfigDir()): TelemetryState { const path = telemetryStatePath(configDir); try { @@ -94,6 +116,8 @@ export function readTelemetryState(configDir = getConfigDir()): TelemetryState { ineligibleThroughDay, retryAfterUntil, envOptOutActiveSince, + pendingDecision: parsePendingDecision(raw.pendingDecision), + everReported: raw.everReported === true, }; } catch { return emptyState(); @@ -117,10 +141,45 @@ export function writeTelemetryState(state: TelemetryState, configDir = getConfig ineligibleThroughDay: state.ineligibleThroughDay ?? null, retryAfterUntil: state.retryAfterUntil ?? null, envOptOutActiveSince: state.envOptOutActiveSince ?? null, + pendingDecision: state.pendingDecision ?? null, + everReported: state.everReported === true, }; atomicWriteFileNoFollow(path, `${JSON.stringify(payload, null, 2)}\n`); } +/** Hold a decision made before `config.json` exists. */ +export function recordPendingTelemetryDecision( + decision: OcxTelemetryConfig, + configDir = getConfigDir(), + now = Date.now(), +): void { + const state = readTelemetryState(configDir); + state.pendingDecision = structuredClone(decision); + writeTelemetryState(state, configDir, now); +} + +/** Drop the pending decision once a decision is written into `config.json`. */ +export function clearPendingTelemetryDecision(configDir = getConfigDir(), now = Date.now()): void { + const state = readTelemetryState(configDir); + if (!state.pendingDecision) return; + state.pendingDecision = null; + writeTelemetryState(state, configDir, now); +} + +/** + * The telemetry block consent is resolved from: the config's own decision when it has one, + * otherwise a decision held from before `config.json` existed, otherwise whatever the config says. + */ +export function withPendingTelemetryDecision>( + config: T | null | undefined, + configDir = getConfigDir(), +): Pick | null | undefined { + if (config?.telemetry?.decision) return config; + const pending = readTelemetryState(configDir).pendingDecision; + if (!pending) return config; + return { ...(config ?? {}), telemetry: structuredClone(pending) }; +} + /** * Persist `ineligibleThroughDay = today's UTC day` and the active opt-out marker in * `telemetry-state.json` (atomic no-follow) and delete `telemetry-id.json` whenever an @@ -254,7 +313,7 @@ export async function runTelemetryTick(options: TelemetryTickOptions = {}): Prom const now = options.now ?? Date.now(); const configDir = options.configDir ?? getConfigDir(); const readCurrentEnv = (): Record => options.env ?? process.env; - const readCurrentConfig = (): Pick | undefined => { + const readRawConfig = (): Pick | undefined => { if (options.readConfig) return options.readConfig(); const diskConfig = readConfigDiagnostics({ passive: true }).config; if (options.config) { @@ -268,6 +327,9 @@ export async function runTelemetryTick(options: TelemetryTickOptions = {}): Prom } return diskConfig; }; + // A decision held from before config.json existed applies until the config carries its own. + const readCurrentConfig = (): Pick | undefined => + withPendingTelemetryDecision(readRawConfig(), configDir) ?? undefined; const initialEnv = readCurrentEnv(); if (isTelemetryEnvOptOut(initialEnv) !== null) { @@ -340,7 +402,8 @@ export async function runTelemetryTick(options: TelemetryTickOptions = {}): Prom } const { identity } = ensureTelemetryInstallId(configDir); - const newInstall = identity.firstReportSent !== true; + // An id minted after an opt-out is a returning install, not a new one. + const newInstall = identity.firstReportSent !== true && preSendState.everReported !== true; const installDayHash = computeInstallDayHash(identity.installId, targetDay); const payload = buildTelemetryPayloadV1({ aggregate, @@ -370,6 +433,7 @@ export async function runTelemetryTick(options: TelemetryTickOptions = {}): Prom const freshState = readTelemetryState(configDir); const nowIso = new Date(now).toISOString(); freshState.sentDays[targetDay] = nowIso; + freshState.everReported = true; freshState.lastSentDay = targetDay; freshState.lastSentAt = nowIso; freshState.retryAfterUntil = null; diff --git a/structure/telemetry.md b/structure/telemetry.md index b42c4f87ec2..4f1029a6aaf 100644 --- a/structure/telemetry.md +++ b/structure/telemetry.md @@ -42,6 +42,11 @@ Only a human can transition an undecided install to active sending: - Interactive prompts run from `src/cli/star-prompt.ts`, `src/service/cli.ts`, and `src/cli/init.ts`; agent-driven, non-TTY, or service starts print a once-per-version notice and record neither `noticeShownAt` nor `decision`. +- A decision made before `config.json` exists (`ocx telemetry disable`, the dashboard popup on a + fresh install, the Windows installer opt-out) is held as `pendingDecision` in + `telemetry-state.json` instead of creating the config, so `ocx init` still runs setup. + `withPendingTelemetryDecision` applies it wherever consent is resolved (scheduler, status route, + CLI prompt) until a decision is written into the config, which clears it. ## Identity, aggregation, and scheduling diff --git a/tests/cli/cli-telemetry.test.ts b/tests/cli/cli-telemetry.test.ts index 3f6d897db6e..ee3f36415de 100644 --- a/tests/cli/cli-telemetry.test.ts +++ b/tests/cli/cli-telemetry.test.ts @@ -11,7 +11,9 @@ import { maybeShowTelemetryPrompt, setTelemetryPromptDepsForTests, } from "../../src/cli/telemetry"; -import { readConfigDiagnostics } from "../../src/config/diagnostics"; +import { getDefaultConfig, initializePersistedConfigIfMissing } from "../../src/config"; +import { observeInitialConfigState, readConfigDiagnostics } from "../../src/config/diagnostics"; +import { buildTelemetryStatusSnapshot } from "../../src/telemetry/routes"; import { resolveTelemetryStatus } from "../../src/telemetry/consent"; import { ensureTelemetryInstallId, @@ -310,17 +312,38 @@ describe("ocx telemetry CLI and interactive prompt", () => { } }); - test("disable on a fresh install with no config.json creates it with the decline", async () => { + test("disable on a fresh install keeps the install fresh for ocx init and the decline survives config creation", async () => { const log = spyOn(console, "log").mockImplementation(() => {}); try { - // The Windows installer's opt-out runs before the first proxy start has written any config. + // The Windows installer's opt-out (or a privacy-minded user) declines before any config exists. unlinkSync(join(home, "config.json")); expect(await handleTelemetryCommand(["disable", "--json"])).toBe(0); - const saved = JSON.parse(readFileSync(join(home, "config.json"), "utf8")) as { telemetry?: { decision?: string } }; - expect(saved.telemetry?.decision).toBe("declined"); - expect(readConfigDiagnostics().config.telemetry?.decision).toBe("declined"); - expect(resolveTelemetryStatus(readConfigDiagnostics().config, {}).effective).toBe(false); - // A second decline leaves the created file in place. + // No config.json is created, so `ocx init` still runs provider setup. + expect(existsSync(join(home, "config.json"))).toBe(false); + expect(observeInitialConfigState()).toBe("missing"); + expect((await buildTelemetryStatusSnapshot(readConfigDiagnostics().config, { env: {}, configDir: home }))).toMatchObject({ + effective: false, + decision: "declined", + }); + + // The first config is created later (init, OAuth login, dashboard); the decline still holds. + expect(initializePersistedConfigIfMissing(getDefaultConfig())).toBe("created"); + expect(readConfigDiagnostics().config.telemetry?.decision).toBeUndefined(); + expect((await buildTelemetryStatusSnapshot(readConfigDiagnostics().config, { env: {}, configDir: home })).effective).toBe(false); + let fetchCalls = 0; + const tick = await runTelemetryTick({ + configDir: home, + env: {}, + now: Date.parse("2026-11-12T12:00:00.000Z"), + fetch: (async () => { + fetchCalls += 1; + return new Response("{}", { status: 202 }); + }) as unknown as typeof fetch, + }); + expect(tick.sent).toBe(false); + expect(fetchCalls).toBe(0); + + // A decision written into the config later wins and clears the pending one. expect(await handleTelemetryCommand(["disable", "--json"])).toBe(0); expect(readConfigDiagnostics().config.telemetry?.decision).toBe("declined"); } finally { diff --git a/tests/server/server-telemetry-routes.test.ts b/tests/server/server-telemetry-routes.test.ts index c22ae8a8d93..e0a180e0989 100644 --- a/tests/server/server-telemetry-routes.test.ts +++ b/tests/server/server-telemetry-routes.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, lstatSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, lstatSync, mkdtempSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { handleManagementAPI } from "../../src/server/management-api"; @@ -7,7 +7,7 @@ import type { ManagementPrincipal } from "../../src/server/management-auth"; import type { ManagementRequestIngress } from "../../src/server/management/context"; import { TELEMETRY_PREVIEW_HASH_PLACEHOLDER, readTelemetryInstallId, resetTelemetryInstallIdMemoForTests, telemetryIdPath } from "../../src/telemetry/install-id"; import { buildTelemetryStatusSnapshot } from "../../src/telemetry/routes"; -import { latestCompletedUtcDay, runTelemetryTick } from "../../src/telemetry/scheduler"; +import { latestCompletedUtcDay, readTelemetryState, runTelemetryTick } from "../../src/telemetry/scheduler"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -237,4 +237,23 @@ describe("/api/telemetry management routes", () => { } } }); + + test("PUT /api/telemetry on a fresh install holds the decision without creating config.json", async () => { + // A first-run popup can be answered before any config.json exists (fresh desktop install). + unlinkSync(join(home, "config.json")); + config = { port: 10100, defaultProvider: "openai", providers: {} } as unknown as OcxConfig; + const declined = await call("PUT", "/api/telemetry", { + body: { decision: "declined" }, + principal: "gui-session", + ingress: { trustedLoopback: true, guiSessionIssuance: "loopback-bootstrap" }, + }); + expect(declined.status).toBe(200); + expect(declined.body).toMatchObject({ ok: true, effective: false, decision: "declined" }); + expect(existsSync(join(home, "config.json"))).toBe(false); + expect(readTelemetryState(home).pendingDecision?.decision).toBe("declined"); + // A later GET from a process that only sees the defaults still reports the held decline. + config = { port: 10100, defaultProvider: "openai", providers: {} } as unknown as OcxConfig; + const status = await call("GET", "/api/telemetry"); + expect(status.body).toMatchObject({ effective: false, decision: "declined" }); + }); }); diff --git a/tests/usage/usage-telemetry-client.test.ts b/tests/usage/usage-telemetry-client.test.ts index e520969a731..0ce6fa8b831 100644 --- a/tests/usage/usage-telemetry-client.test.ts +++ b/tests/usage/usage-telemetry-client.test.ts @@ -791,6 +791,24 @@ describe("install identity, aggregation, disjoint tokens, and scheduler", () => } }); + test("an id minted after an opt-out reports as a returning install, not a new one", async () => { + const sent: TelemetryPayloadV1[] = []; + const mockFetch = (async (_url: string | URL | Request, init?: RequestInit) => { + sent.push(JSON.parse(String(init?.body)) as TelemetryPayloadV1); + return new Response(JSON.stringify({ ok: true }), { status: 202 }); + }) as unknown as typeof fetch; + const config = { + telemetry: { decision: "accepted" as const, decidedAt: "2026-11-01T10:00:00.000Z", noticeShownAt: "2026-11-01T10:00:00.000Z" }, + }; + expect(await runTelemetryTick({ configDir: home, config, env: {}, now: Date.parse("2026-11-03T12:00:00.000Z"), fetch: mockFetch })).toMatchObject({ sent: true }); + expect(sent[0]!.newInstall).toBe(true); + // A decline or env opt-out deletes the id; the next accepted send mints a fresh one. + deleteTelemetryInstallId(home); + expect(await runTelemetryTick({ configDir: home, config, env: {}, now: Date.parse("2026-11-04T12:00:00.000Z"), fetch: mockFetch })).toMatchObject({ sent: true }); + expect(sent[1]!.newInstall).toBe(false); + expect(readTelemetryState(home).everReported).toBe(true); + }); + test("D7: re-reads consent and env before transmission and aborts in-flight ticks on stop or nudge", async () => { let fetchCalls = 0; const mockFetch = (async () => { From 6d387ff40e723949c2fe92234de7095087e1a62e Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 19:20:12 +0900 Subject: [PATCH 17/37] docs(telemetry): match the privacy statement and AGENTS_INSTALL to the code The statement no longer says telemetry can be turned off "at install time in that prompt"; the Windows installer option arrives in the next layer. AGENTS_INSTALL says ocx config set/unset telemetry refuse every caller, which is what the code does. --- AGENTS_INSTALL.md | 8 ++++---- README.md | 2 +- .../src/content/docs/reference/code-signing.md | 2 +- .../src/content/docs/tr/reference/code-signing.md | 2 +- readme/README.fr.md | 2 +- readme/README.ja.md | 2 +- readme/README.ko.md | 2 +- readme/README.ru.md | 2 +- readme/README.tr.md | 2 +- readme/README.zh-CN.md | 2 +- readme/README.zh-TW.md | 2 +- readme/i18n-manifest.json | 14 +++++++------- 12 files changed, 21 insertions(+), 21 deletions(-) diff --git a/AGENTS_INSTALL.md b/AGENTS_INSTALL.md index 62402cc7f71..fbc64bdb3dd 100644 --- a/AGENTS_INSTALL.md +++ b/AGENTS_INSTALL.md @@ -57,10 +57,10 @@ The following instructions concern **starring the repository on GitHub**. Anonymous daily usage telemetry defaults to on only after a human has seen the disclosure notice (in the GUI or an interactive TTY prompt). An agent must never enable telemetry or acknowledge the telemetry disclosure on the user's behalf: -`PUT /api/telemetry`, `ocx telemetry enable`, and -`ocx config set telemetry` refuse agent-driven callers with -`agent_consent_required`. Opting out (`ocx telemetry disable` or -`OCX_TELEMETRY=0`) is always permitted. +`PUT /api/telemetry` and `ocx telemetry enable` refuse agent-driven callers with +`agent_consent_required`, and `ocx config set`/`unset telemetry` refuse every +caller. Opting out (`ocx telemetry disable` or `OCX_TELEMETRY=0`) is always +permitted. ## Where things are installed diff --git a/README.md b/README.md index e7f3df50651..0dbab6f40f5 100644 --- a/README.md +++ b/README.md @@ -498,7 +498,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off at install time in that prompt and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## Disclaimer diff --git a/docs-site/src/content/docs/reference/code-signing.md b/docs-site/src/content/docs/reference/code-signing.md index 356c5937c68..61a75e9e001 100644 --- a/docs-site/src/content/docs/reference/code-signing.md +++ b/docs-site/src/content/docs/reference/code-signing.md @@ -33,7 +33,7 @@ SignPath Foundation policy forbids signing them with the project certificate. OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and -has not declined; it can be turned off at install time in that prompt and anytime with +has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. In practice, OpenCodex forwards requests to the providers you configure. To look for new versions it also diff --git a/docs-site/src/content/docs/tr/reference/code-signing.md b/docs-site/src/content/docs/tr/reference/code-signing.md index 890146fa9bf..1c1f53667f0 100644 --- a/docs-site/src/content/docs/tr/reference/code-signing.md +++ b/docs-site/src/content/docs/tr/reference/code-signing.md @@ -35,7 +35,7 @@ OpenCodex, yalnızca programı kuran veya çalıştıran kişi bildirimi (kontro açılır penceresi veya etkileşimli CLI istemi) gördükten ve reddetmedikten sonra `telemetry.opencodex.me` adresine günde bir anonim kullanım özeti (sürüm, platform, sayımlar; istemler, çıktılar, anahtarlar, hesaplar, ana bilgisayar adları veya dosya -yolları asla dahil edilmez) gönderir; kurulum sırasında bu istemden ve istediğiniz +yolları asla dahil edilmez) gönderir; bu bildirimde ve istediğiniz zaman `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` veya Proxy details üzerinden kapatılabilir; proje genelindeki toplamlar herkese açıktır; bunun dışında veriler yalnızca kullanıcının yapılandırdığı yerlere gider. Uygulamada diff --git a/readme/README.fr.md b/readme/README.fr.md index cc8d08dff5f..ab4d40ccf78 100644 --- a/readme/README.fr.md +++ b/readme/README.fr.md @@ -496,7 +496,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off at install time in that prompt and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## Avis de non-responsabilité diff --git a/readme/README.ja.md b/readme/README.ja.md index 5762cd62484..bce4e0b3d0b 100644 --- a/readme/README.ja.md +++ b/readme/README.ja.md @@ -490,7 +490,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off at install time in that prompt and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## 免責事項 diff --git a/readme/README.ko.md b/readme/README.ko.md index 8693f1e68ff..ff86a86a490 100644 --- a/readme/README.ko.md +++ b/readme/README.ko.md @@ -473,7 +473,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off at install time in that prompt and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## 면책 조항 diff --git a/readme/README.ru.md b/readme/README.ru.md index 368a5899490..5c06c7b974e 100644 --- a/readme/README.ru.md +++ b/readme/README.ru.md @@ -502,7 +502,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off at install time in that prompt and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## Отказ от ответственности diff --git a/readme/README.tr.md b/readme/README.tr.md index fe0aae5f81f..b9c34551830 100644 --- a/readme/README.tr.md +++ b/readme/README.tr.md @@ -498,7 +498,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off at install time in that prompt and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## Sorumluluk reddi diff --git a/readme/README.zh-CN.md b/readme/README.zh-CN.md index 7c1aa50300d..8647bf6f080 100644 --- a/readme/README.zh-CN.md +++ b/readme/README.zh-CN.md @@ -471,7 +471,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off at install time in that prompt and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## 免责声明 diff --git a/readme/README.zh-TW.md b/readme/README.zh-TW.md index 8cc2b645b03..4a837326dfc 100644 --- a/readme/README.zh-TW.md +++ b/readme/README.zh-TW.md @@ -470,7 +470,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off at install time in that prompt and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## 免責聲明 diff --git a/readme/i18n-manifest.json b/readme/i18n-manifest.json index 86421b84262..3ace0af263d 100644 --- a/readme/i18n-manifest.json +++ b/readme/i18n-manifest.json @@ -6,43 +6,43 @@ "file": "readme/README.fr.md", "label": "Français", "docsPath": "fr", - "sourceSha256": "b47453dc6ff893a9ddeccfcab475d11b99954943a7ddcb5252aadfa751d4f3e4" + "sourceSha256": "093e29d37473affad3f323896ca58d3659acf7e956c02b4bad2dbb26c82ab616" }, "ko": { "file": "readme/README.ko.md", "label": "한국어", "docsPath": "ko", - "sourceSha256": "b47453dc6ff893a9ddeccfcab475d11b99954943a7ddcb5252aadfa751d4f3e4" + "sourceSha256": "093e29d37473affad3f323896ca58d3659acf7e956c02b4bad2dbb26c82ab616" }, "zh-CN": { "file": "readme/README.zh-CN.md", "label": "简体中文", "docsPath": "zh-cn", - "sourceSha256": "b47453dc6ff893a9ddeccfcab475d11b99954943a7ddcb5252aadfa751d4f3e4" + "sourceSha256": "093e29d37473affad3f323896ca58d3659acf7e956c02b4bad2dbb26c82ab616" }, "zh-TW": { "file": "readme/README.zh-TW.md", "label": "繁體中文", "docsPath": "zh-tw", - "sourceSha256": "b47453dc6ff893a9ddeccfcab475d11b99954943a7ddcb5252aadfa751d4f3e4" + "sourceSha256": "093e29d37473affad3f323896ca58d3659acf7e956c02b4bad2dbb26c82ab616" }, "ru": { "file": "readme/README.ru.md", "label": "Русский", "docsPath": "ru", - "sourceSha256": "b47453dc6ff893a9ddeccfcab475d11b99954943a7ddcb5252aadfa751d4f3e4" + "sourceSha256": "093e29d37473affad3f323896ca58d3659acf7e956c02b4bad2dbb26c82ab616" }, "ja": { "file": "readme/README.ja.md", "label": "日本語", "docsPath": "ja", - "sourceSha256": "b47453dc6ff893a9ddeccfcab475d11b99954943a7ddcb5252aadfa751d4f3e4" + "sourceSha256": "093e29d37473affad3f323896ca58d3659acf7e956c02b4bad2dbb26c82ab616" }, "tr": { "file": "readme/README.tr.md", "label": "Türkçe", "docsPath": "tr", - "sourceSha256": "b47453dc6ff893a9ddeccfcab475d11b99954943a7ddcb5252aadfa751d4f3e4" + "sourceSha256": "093e29d37473affad3f323896ca58d3659acf7e956c02b4bad2dbb26c82ab616" } } } From 5f0e29c10a61a978bcd66ddbbea82cc5eb9705d6 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 19:29:42 +0900 Subject: [PATCH 18/37] fix(config): keep a telemetry decision on disk through a live settings save The CLI and the consent route write telemetry consent to config.json while a proxy may hold an older in-memory config. A later settings save from that proxy dropped a decision the snapshot never had, so the install fell back to awaiting-notice and the user was asked again. saveConfigPreservingClaudeCode now carries the on-disk telemetry block into the write and adopts it in memory. Failing-first test in tests/config/telemetry-live-save.test.ts. --- scripts/test-layout/layout.json | 2 +- src/config/live-reconcile.ts | 9 ++++ tests/config/telemetry-live-save.test.ts | 61 ++++++++++++++++++++++++ tests/fixtures/test-layout-expected.json | 2 +- 4 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 tests/config/telemetry-live-save.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 1184447a5eb..9a133deb7bd 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -780,7 +780,7 @@ "config-save-boundary.test.ts": "config", "config-spend-ceilings.test.ts": "config", "config-spend-pool-collision.test.ts": "config", - "config-user-edits.test.ts": "config", "hub-disable-live-save.test.ts": "config", + "config-user-edits.test.ts": "config", "hub-disable-live-save.test.ts": "config", "telemetry-live-save.test.ts": "config", "config.test.ts": "server", "configured-native-models.test.ts": "codex-integration", "gui-desktop-sidecar-signing.test.ts": "gui", diff --git a/src/config/live-reconcile.ts b/src/config/live-reconcile.ts index e836e31add0..2b5f34a73eb 100644 --- a/src/config/live-reconcile.ts +++ b/src/config/live-reconcile.ts @@ -636,6 +636,15 @@ export function saveConfigPreservingClaudeCode(config: OcxConfig): void { ); if (provenanceProjection.configRebaseProvenance === undefined) delete projectedConfig.configRebaseProvenance; else projectedConfig.configRebaseProvenance = provenanceProjection.configRebaseProvenance; + // Telemetry consent is owned by the file: the CLI and the consent route write it there while a + // proxy may hold an older snapshot. A settings save must carry the decision on disk, never drop + // or roll it back. + if (onDisk !== undefined) { + const diskTelemetry = (onDisk as Partial).telemetry; + if (diskTelemetry === undefined) delete projectedConfig.telemetry; + else projectedConfig.telemetry = structuredClone(diskTelemetry); + adoptPersistedTelemetry(config, diskTelemetry); + } const persistedBinding = (bindingBaseline || diskDeletedKeys.has("hostname")) && onDisk ? readPersistedServerBinding(onDisk, bindingBaseline ?? { port: config.port, hostname: config.hostname }) : bindingBaseline; diff --git a/tests/config/telemetry-live-save.test.ts b/tests/config/telemetry-live-save.test.ts new file mode 100644 index 00000000000..c41aaccec16 --- /dev/null +++ b/tests/config/telemetry-live-save.test.ts @@ -0,0 +1,61 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { armClaudeCodeBaseline, getConfigPath, loadConfig, mutatePersistedConfig, saveConfig, saveConfigPreservingClaudeCode } from "../../src/config"; +import type { OcxConfig, OcxTelemetryConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +/** + * Telemetry consent is written to disk by the CLI (`ocx telemetry enable|disable`, the start + * prompt) and by the consent route, often while a proxy runs with an older in-memory config. + * A later settings save from that proxy must carry the decision on disk, never drop or roll it back. + */ +const seed = (telemetry?: OcxTelemetryConfig): OcxConfig => ({ + port: 10100, + logLevel: "info", + providers: { fixture: { adapter: "openai-chat", baseUrl: "https://fixture.example.test" } }, + defaultProvider: "fixture", + ...(telemetry ? { telemetry } : {}), +} as OcxConfig); + +const declined: OcxTelemetryConfig = { decision: "declined", decidedAt: "2026-11-08T10:00:00.000Z", noticeShownAt: "2026-11-08T10:00:00.000Z" }; +const accepted: OcxTelemetryConfig = { decision: "accepted", decidedAt: "2026-11-01T10:00:00.000Z", noticeShownAt: "2026-11-01T10:00:00.000Z" }; + +function liveSaveAfterDiskDecision(initial: OcxConfig, onDisk: OcxTelemetryConfig): { live: OcxConfig; saved: OcxConfig } { + const oldHome = process.env.OPENCODEX_HOME; + const home = mkdtempSync(join(tmpdir(), "ocx-telemetry-live-save-")); + process.env.OPENCODEX_HOME = home; + try { + saveConfig(initial); + const live = loadConfig(); + armClaudeCodeBaseline(live); + // A CLI process records a decision while the proxy keeps its older in-memory config. + expect(mutatePersistedConfig(config => { + config.telemetry = structuredClone(onDisk); + return { changed: true, value: null }; + }).status).toBe("committed"); + live.logLevel = "debug"; + saveConfigPreservingClaudeCode(live); + live.logLevel = "warn"; + saveConfigPreservingClaudeCode(live); + return { live, saved: JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig }; + } finally { + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldHome; + removeTreeWithRetry(home); + } +} + +test("a live save keeps a telemetry decision written to disk after the proxy loaded its config", () => { + const { live, saved } = liveSaveAfterDiskDecision(seed(), declined); + expect(saved.logLevel).toBe("warn"); + expect(saved.telemetry).toEqual(declined); + expect(live.telemetry).toEqual(declined); +}); + +test("a live save never rolls a newer decline on disk back to the accept it loaded", () => { + const { live, saved } = liveSaveAfterDiskDecision(seed(accepted), declined); + expect(saved.telemetry).toEqual(declined); + expect(live.telemetry).toEqual(declined); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 46504f81156..b9d4526a709 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -764,7 +764,7 @@ "protocol-settings.test.ts": "config", "config-mutation-lock.test.ts": "config", "config-non-object-backup.test.ts": "config", "config-ownership-uninstall.test.ts": "config", "config-ownership-concurrent-record.test.ts": "config", "config-ownership-removal.test.ts": "config", "config-rebase-provenance-writers.test.ts": "config", "config-save-boundary.test.ts": "config", - "config-spend-ceilings.test.ts": "config", "config-spend-pool-collision.test.ts": "config", "config-user-edits.test.ts": "config", "hub-disable-live-save.test.ts": "config", + "config-spend-ceilings.test.ts": "config", "config-spend-pool-collision.test.ts": "config", "config-user-edits.test.ts": "config", "hub-disable-live-save.test.ts": "config", "telemetry-live-save.test.ts": "config", "config.test.ts": "server", "configured-native-models.test.ts": "codex-integration", "gui-desktop-sidecar-signing.test.ts": "gui", "linux-desktop-packaged-ci.test.ts": "ci-workflows", "probe-timeout-env.test.ts": "server", "subagent-roster-migration.test.ts": "routing", From 5b88d16aea117181899551b9e5affb968e4ef1d9 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 19:34:08 +0900 Subject: [PATCH 19/37] fix(telemetry): keep a running opted-out proxy's days unsent, fail closed on bad state, and stop leaking the scheduler into server start From the round-2 privacy and code reviews of #8300: - Two proxies can share one home. An opted-out proxy now records its pid in envOptOutOwners; a tick without the opt-out ends the period only once no recorded owner is alive and otherwise extends ineligibleThroughDay to today, and a clean stop always covers its stop day. Failing-first: the second proxy sent 2026-11-03 while the first still ran opted out. - A telemetry-state.json that exists but cannot be read stops sending (state-unreadable) instead of reading as empty and reopening opt-out days. - readTelemetryInstallId re-checks the file before trusting its memo, so an id deleted by another process is never reused or rewritten. - State code moved to src/telemetry/state.ts; background-lifecycle imports only it, so the scheduler, aggregation and sender load only when telemetry can run. - GET /api/telemetry reads consent from disk like the scheduler, and reuses a day's preview aggregate for 10 minutes because the dashboard panel polls it. - Removed the migration of a state key no release ever wrote; AGENTS_INSTALL names tty_required and DO_NOT_TRACK; plan 010/000 wording. --- AGENTS_INSTALL.md | 7 +- .../261107_telemetry_dashboard/000_plan.md | 2 +- .../010_client_core.md | 2 +- src/server/background-lifecycle.ts | 8 +- src/telemetry/install-id.ts | 20 +- src/telemetry/routes.ts | 29 +- src/telemetry/scheduler.ts | 243 +++------------- src/telemetry/state.ts | 275 ++++++++++++++++++ structure/telemetry.md | 19 +- tests/server/server-telemetry-routes.test.ts | 10 + tests/usage/usage-telemetry-client.test.ts | 66 ++++- 11 files changed, 443 insertions(+), 238 deletions(-) create mode 100644 src/telemetry/state.ts diff --git a/AGENTS_INSTALL.md b/AGENTS_INSTALL.md index fbc64bdb3dd..bbe5f606d95 100644 --- a/AGENTS_INSTALL.md +++ b/AGENTS_INSTALL.md @@ -58,9 +58,10 @@ Anonymous daily usage telemetry defaults to on only after a human has seen the disclosure notice (in the GUI or an interactive TTY prompt). An agent must never enable telemetry or acknowledge the telemetry disclosure on the user's behalf: `PUT /api/telemetry` and `ocx telemetry enable` refuse agent-driven callers with -`agent_consent_required`, and `ocx config set`/`unset telemetry` refuse every -caller. Opting out (`ocx telemetry disable` or `OCX_TELEMETRY=0`) is always -permitted. +`agent_consent_required` (and `ocx telemetry enable` refuses a non-interactive +terminal with `tty_required`), and `ocx config set`/`unset telemetry` refuse every +caller. Opting out (`ocx telemetry disable`, `OCX_TELEMETRY=0` or `DO_NOT_TRACK=1`) +is always permitted. ## Where things are installed diff --git a/devlog/_plan/261107_telemetry_dashboard/000_plan.md b/devlog/_plan/261107_telemetry_dashboard/000_plan.md index 36750627e48..688441c38da 100644 --- a/devlog/_plan/261107_telemetry_dashboard/000_plan.md +++ b/devlog/_plan/261107_telemetry_dashboard/000_plan.md @@ -100,6 +100,6 @@ rejects `/` tokens and host identifiers there (010). ## PR review round 1 -Reviewer feedback on the open stack is recorded, with owner decisions D1–D20, in +Reviewer feedback on the open stack is recorded, with owner decisions D1–D21, in [060_review_round.md](060_review_round.md). Those decisions supersede conflicting text in 000–050, including the public privacy statement change that must land with the first layer able to send. diff --git a/devlog/_plan/261107_telemetry_dashboard/010_client_core.md b/devlog/_plan/261107_telemetry_dashboard/010_client_core.md index d6d39999357..da33a0404b3 100644 --- a/devlog/_plan/261107_telemetry_dashboard/010_client_core.md +++ b/devlog/_plan/261107_telemetry_dashboard/010_client_core.md @@ -121,7 +121,7 @@ run through `validateTelemetryPayloadV1` before sending or previewing. - Each tick (startup jitter, then every 6 h) re-reads config passively from disk and re-evaluates `resolveTelemetryStatus`, so CLI and GUI changes take effect without a restart. The management routes also call `adoptPersistedTelemetry` and nudge the scheduler. -- It sends only completed days on or after the UTC day of `decidedAt ?? noticeShownAt` (no pre-consent +- It sends only completed days strictly after the UTC day of `decidedAt ?? noticeShownAt` (060 D1; no pre-consent backfill), at most **one day per tick**, oldest first, at most the last 7 days; 10 s `signalWithTimeout`, no in-tick retry. Sent days live in `/telemetry-state.json`. - `tests/preload.ts` sets `OCX_TELEMETRY=0` so no test process ever sends. diff --git a/src/server/background-lifecycle.ts b/src/server/background-lifecycle.ts index d52636579d1..549ad5e406f 100644 --- a/src/server/background-lifecycle.ts +++ b/src/server/background-lifecycle.ts @@ -34,7 +34,8 @@ import { } from "./memory-watchdog"; import { ensureQuotaPoolIdentity } from "../quota-pool/identity"; import { isTelemetryEnvOptOut } from "../telemetry/consent"; -import { endTelemetryEnvOptOut, recordTelemetryEnvOptOut } from "../telemetry/scheduler"; +// The state module alone: the scheduler and its aggregation stay behind the dynamic import below. +import { endTelemetryEnvOptOut, recordTelemetryEnvOptOut } from "../telemetry/state"; type PolicyApply = (policy: StorageCleanupPolicy) => void; @@ -142,7 +143,8 @@ function startProcessLoops(applyPolicy: PolicyApply): ProcessLoops { }); } else { telemetryEnvOptOutObserved = true; - try { recordTelemetryEnvOptOut(); } catch { /* best-effort */ } + // Recorded under this pid, so a proxy sharing the home cannot end the period while this one runs. + try { recordTelemetryEnvOptOut(undefined, undefined, process.pid); } catch { /* best-effort */ } } return { memoryWatchdog, stateStoreSweeper }; } catch (error) { @@ -167,7 +169,7 @@ function stopProcessLoops(): void { stopTelemetry?.(); if (telemetryEnvOptOutObserved) { telemetryEnvOptOutObserved = false; - try { endTelemetryEnvOptOut(); } catch { /* best-effort; the marker stays and the next tick ends it */ } + try { endTelemetryEnvOptOut(undefined, undefined, process.pid); } catch { /* best-effort; the next tick ends it once this pid has exited */ } } const loops = processLoops; processLoops = null; diff --git a/src/telemetry/install-id.ts b/src/telemetry/install-id.ts index 2c2a7dd1e0e..977b68cc8c7 100644 --- a/src/telemetry/install-id.ts +++ b/src/telemetry/install-id.ts @@ -59,13 +59,18 @@ function parseTelemetryId(text: string): TelemetryInstallIdentity | null { /** Read-only: returns the persisted telemetry install identity, or null when absent/invalid. */ export function readTelemetryInstallId(configDir = getConfigDir()): TelemetryInstallIdentity | null { const path = telemetryIdPath(configDir); - if (memo?.path === path) return memo.identity; try { const stat = lstatSync(path); - if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024) return null; + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024) { + if (memo?.path === path) memo = null; + return null; + } } catch { + // Another process (`ocx telemetry disable`, an env opt-out) may have deleted it: forget the memo. + if (memo?.path === path) memo = null; return null; } + if (memo?.path === path) return memo.identity; let identity: TelemetryInstallIdentity | null; try { identity = parseTelemetryId(readFileSync(path, "utf8")); @@ -209,17 +214,6 @@ export function deleteTelemetryInstallId(configDir = getConfigDir()): boolean { } catch (error) { if (errorCode(error) !== "ENOENT") deleted = false; } - const statePath = join(configDir, "telemetry-state.json"); - try { - const stat = lstatSync(statePath); - if (!stat.isSymbolicLink() && stat.isFile() && stat.size <= 64 * 1024) { - const raw = JSON.parse(readFileSync(statePath, "utf8")) as Record; - if (raw && typeof raw === "object" && !Array.isArray(raw) && "firstReportSentForInstallId" in raw) { - delete raw.firstReportSentForInstallId; - atomicWriteFileNoFollow(statePath, `${JSON.stringify(raw, null, 2)}\n`); - } - } - } catch { /* best-effort */ } return deleted; } diff --git a/src/telemetry/routes.ts b/src/telemetry/routes.ts index 13af9ffb841..1ba32b455e0 100644 --- a/src/telemetry/routes.ts +++ b/src/telemetry/routes.ts @@ -1,11 +1,12 @@ import { mutatePersistedConfig } from "../config"; +import { readConfigDiagnostics } from "../config/diagnostics"; import { adoptPersistedTelemetry } from "../config/live-reconcile"; import { getConfigDir, withConfigDirHardeningSettled } from "../config/paths"; import { jsonResponse } from "../server/auth-cors"; import { hasHumanDashboardSession } from "../server/management/consent-session"; import type { ManagementContext } from "../server/management/context"; import type { OcxConfig, OcxTelemetryConfig, OcxTelemetryDecision } from "../types"; -import { aggregateTelemetryForDay } from "./aggregate"; +import { aggregateTelemetryForDay, type DailyTelemetryAggregate } from "./aggregate"; import { resolveTelemetryStatus, type TelemetryConsentStatus } from "./consent"; import type { TelemetryInstallChannel, TelemetryPayloadV1 } from "./contract"; import { @@ -33,9 +34,28 @@ export interface TelemetryStatusSnapshot extends TelemetryConsentStatus { preview: TelemetryPayloadV1; } +/** How long the dashboard's preview may reuse a completed day's aggregate (the panel polls). */ +export const TELEMETRY_PREVIEW_CACHE_MS = 10 * 60 * 1000; +let previewAggregateCache: { configDir: string; day: string; at: number; aggregate: DailyTelemetryAggregate } | null = null; + +export function resetTelemetryPreviewCacheForTests(): void { + previewAggregateCache = null; +} + +async function previewAggregate(day: string, configDir: string, now: number, cache: boolean): Promise { + const hit = previewAggregateCache; + if (cache && hit && hit.configDir === configDir && hit.day === day && now - hit.at >= 0 && now - hit.at < TELEMETRY_PREVIEW_CACHE_MS) { + return hit.aggregate; + } + // A completed day's aggregate scans usage.jsonl from the start; the dashboard polls this route. + const aggregate = await aggregateTelemetryForDay(day); + if (cache) previewAggregateCache = { configDir, day, at: now, aggregate }; + return aggregate; +} + export async function buildTelemetryStatusSnapshot( config: Pick | null | undefined, - options: { now?: number; env?: Record; configDir?: string } = {}, + options: { now?: number; env?: Record; configDir?: string; cachePreview?: boolean } = {}, ): Promise { const now = options.now ?? Date.now(); const env = options.env ?? process.env; @@ -46,7 +66,7 @@ export async function buildTelemetryStatusSnapshot( const { day: nextEligibleDay } = resolveNextEligibleTelemetryDay(status, state, now); const previewEligible = nextEligibleDay !== null; const day = nextEligibleDay ?? latestCompletedUtcDay(now); - const aggregate = await aggregateTelemetryForDay(day); + const aggregate = await previewAggregate(day, configDir, now, options.cachePreview === true); const identity = readTelemetryInstallId(configDir); const installDayHash = identity ? computeInstallDayHash(identity.installId, day) @@ -85,7 +105,8 @@ function consentRefusedResponse(ctx: ManagementContext): Response { } export async function handleGetTelemetryRoute(ctx: ManagementContext): Promise { - const snapshot = await buildTelemetryStatusSnapshot(ctx.config); + // Consent is read from disk, like the scheduler: a CLI decision while the proxy runs must show here. + const snapshot = await buildTelemetryStatusSnapshot(readConfigDiagnostics({ passive: true }).config, { cachePreview: true }); return jsonResponse(snapshot, 200, ctx.req, ctx.config); } diff --git a/src/telemetry/scheduler.ts b/src/telemetry/scheduler.ts index ba914dd50d4..c56029ffed9 100644 --- a/src/telemetry/scheduler.ts +++ b/src/telemetry/scheduler.ts @@ -1,226 +1,51 @@ -import { lstatSync, mkdirSync, readFileSync } from "node:fs"; -import { join } from "node:path"; -import { atomicWriteFileNoFollow } from "../config/atomic-write"; import { readConfigDiagnostics } from "../config/diagnostics"; import { getConfigDir } from "../config/paths"; -import { recordOwnedConfigPath } from "../lib/config-ownership"; import { registerOptionalShutdownHook } from "../lib/optional-shutdown-hooks"; -import type { OcxConfig, OcxTelemetryConfig } from "../types"; +import type { OcxConfig } from "../types"; import { aggregateTelemetryForDay, type DailyTelemetryAggregate } from "./aggregate"; import { isTelemetryEnvOptOut, resolveTelemetryStatus, type TelemetryConsentStatus } from "./consent"; -import { parseCalendarUtcDayMs } from "./contract"; import { computeInstallDayHash, - deleteTelemetryInstallId, ensureTelemetryInstallId, markTelemetryFirstReportSent, } from "./install-id"; import { buildTelemetryPayloadV1, resolveInstallChannel, type ResolveInstallChannelOptions } from "./payload"; import { sendTelemetryPayload, type TelemetrySendOptions, type TelemetrySendResult } from "./sender"; +import { + endTelemetryEnvOptOut, + formatUtcDay, + readTelemetryState, + readTelemetryStateFile, + recordTelemetryEnvOptOut, + utcMidnightMs, + withPendingTelemetryDecision, + writeTelemetryState, + type TelemetryState, +} from "./state"; + +export { + TELEMETRY_STATE_FILE, + clearPendingTelemetryDecision, + endTelemetryEnvOptOut, + formatUtcDay, + readTelemetryState, + readTelemetryStateFile, + recordPendingTelemetryDecision, + recordTelemetryEnvOptOut, + telemetryStatePath, + utcMidnightMs, + withPendingTelemetryDecision, + writeTelemetryState, + type TelemetryEnvOptOutOwner, + type TelemetryState, +} from "./state"; -export const TELEMETRY_STATE_FILE = "telemetry-state.json"; export const TELEMETRY_TICK_INTERVAL_MS = 6 * 60 * 60 * 1000; export const TELEMETRY_STARTUP_BASE_DELAY_MS = 30_000; export const TELEMETRY_STARTUP_JITTER_MS = 60_000; export const TELEMETRY_INTERVAL_JITTER_MS = 15 * 60 * 1000; const DAY_MS = 86_400_000; const MAX_BACKLOG_DAYS = 7; -const STATE_RETENTION_DAYS = 30; - -export interface TelemetryState { - version: 1; - sentDays: Record; - lastSentDay: string | null; - lastSentAt: string | null; - ineligibleThroughDay: string | null; - retryAfterUntil: string | null; - /** - * Set whenever an env opt-out is observed and cleared only by a clean opted-out shutdown or by the - * next tick that runs without the opt-out. While it is set, every day up to the current one is - * ineligible, because a proxy started under `OCX_TELEMETRY=0` reads the env once and runs no ticks - * that could keep `ineligibleThroughDay` current (D2). - */ - envOptOutActiveSince?: string | null; - /** - * A decision made before `config.json` exists (the Windows installer opt-out, `ocx telemetry - * disable` or the dashboard on a fresh install). Writing the config would turn the install into a - * configured one and make `ocx init` skip setup, so the decision waits here and applies until a - * decision is written into the config. - */ - pendingDecision?: OcxTelemetryConfig | null; - /** Set once any report was accepted, so an id minted after an opt-out is not counted as a new install. */ - everReported?: boolean; -} - -export function telemetryStatePath(configDir = getConfigDir()): string { - return join(configDir, TELEMETRY_STATE_FILE); -} - -function emptyState(): TelemetryState { - return { - version: 1, - sentDays: {}, - lastSentDay: null, - lastSentAt: null, - ineligibleThroughDay: null, - retryAfterUntil: null, - envOptOutActiveSince: null, - pendingDecision: null, - everReported: false, - }; -} - -function parsePendingDecision(raw: unknown): OcxTelemetryConfig | null { - if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; - const value = raw as Record; - if (value.decision !== "accepted" && value.decision !== "declined") return null; - const stamp = (key: string): string | null => - typeof value[key] === "string" && !Number.isNaN(Date.parse(value[key] as string)) ? (value[key] as string) : null; - const decidedAt = stamp("decidedAt"); - if (!decidedAt) return null; - return { decision: value.decision, decidedAt, noticeShownAt: stamp("noticeShownAt") ?? decidedAt }; -} - -export function readTelemetryState(configDir = getConfigDir()): TelemetryState { - const path = telemetryStatePath(configDir); - try { - const stat = lstatSync(path); - if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 64 * 1024) return emptyState(); - const raw = JSON.parse(readFileSync(path, "utf8")) as Record; - if (raw?.version !== 1 || !raw.sentDays || typeof raw.sentDays !== "object" || Array.isArray(raw.sentDays)) { - return emptyState(); - } - const sentDays: Record = {}; - for (const [day, at] of Object.entries(raw.sentDays as Record)) { - if (/^\d{4}-\d{2}-\d{2}$/.test(day) && typeof at === "string") { - sentDays[day] = at; - } - } - const ineligibleThroughDay = - typeof raw.ineligibleThroughDay === "string" && parseCalendarUtcDayMs(raw.ineligibleThroughDay) !== null - ? raw.ineligibleThroughDay - : null; - const retryAfterUntil = - typeof raw.retryAfterUntil === "string" && !Number.isNaN(Date.parse(raw.retryAfterUntil)) - ? raw.retryAfterUntil - : null; - const envOptOutActiveSince = - typeof raw.envOptOutActiveSince === "string" && !Number.isNaN(Date.parse(raw.envOptOutActiveSince)) - ? raw.envOptOutActiveSince - : null; - return { - version: 1, - sentDays, - lastSentDay: typeof raw.lastSentDay === "string" ? raw.lastSentDay : null, - lastSentAt: typeof raw.lastSentAt === "string" ? raw.lastSentAt : null, - ineligibleThroughDay, - retryAfterUntil, - envOptOutActiveSince, - pendingDecision: parsePendingDecision(raw.pendingDecision), - everReported: raw.everReported === true, - }; - } catch { - return emptyState(); - } -} - -export function writeTelemetryState(state: TelemetryState, configDir = getConfigDir(), now = Date.now()): void { - mkdirSync(configDir, { recursive: true, mode: 0o700 }); - const path = telemetryStatePath(configDir); - try { recordOwnedConfigPath(configDir, path); } catch { /* best-effort */ } - const minKeepDay = formatUtcDay(utcMidnightMs(now) - STATE_RETENTION_DAYS * DAY_MS); - const prunedSentDays: Record = {}; - for (const [day, at] of Object.entries(state.sentDays ?? {})) { - if (day >= minKeepDay) prunedSentDays[day] = at; - } - const payload: TelemetryState = { - version: 1, - sentDays: prunedSentDays, - lastSentDay: state.lastSentDay ?? null, - lastSentAt: state.lastSentAt ?? null, - ineligibleThroughDay: state.ineligibleThroughDay ?? null, - retryAfterUntil: state.retryAfterUntil ?? null, - envOptOutActiveSince: state.envOptOutActiveSince ?? null, - pendingDecision: state.pendingDecision ?? null, - everReported: state.everReported === true, - }; - atomicWriteFileNoFollow(path, `${JSON.stringify(payload, null, 2)}\n`); -} - -/** Hold a decision made before `config.json` exists. */ -export function recordPendingTelemetryDecision( - decision: OcxTelemetryConfig, - configDir = getConfigDir(), - now = Date.now(), -): void { - const state = readTelemetryState(configDir); - state.pendingDecision = structuredClone(decision); - writeTelemetryState(state, configDir, now); -} - -/** Drop the pending decision once a decision is written into `config.json`. */ -export function clearPendingTelemetryDecision(configDir = getConfigDir(), now = Date.now()): void { - const state = readTelemetryState(configDir); - if (!state.pendingDecision) return; - state.pendingDecision = null; - writeTelemetryState(state, configDir, now); -} - -/** - * The telemetry block consent is resolved from: the config's own decision when it has one, - * otherwise a decision held from before `config.json` existed, otherwise whatever the config says. - */ -export function withPendingTelemetryDecision>( - config: T | null | undefined, - configDir = getConfigDir(), -): Pick | null | undefined { - if (config?.telemetry?.decision) return config; - const pending = readTelemetryState(configDir).pendingDecision; - if (!pending) return config; - return { ...(config ?? {}), telemetry: structuredClone(pending) }; -} - -/** - * Persist `ineligibleThroughDay = today's UTC day` and the active opt-out marker in - * `telemetry-state.json` (atomic no-follow) and delete `telemetry-id.json` whenever an - * `OCX_TELEMETRY` or `DO_NOT_TRACK` opt-out is observed. - */ -export function recordTelemetryEnvOptOut(configDir = getConfigDir(), now = Date.now()): void { - const todayDay = formatUtcDay(utcMidnightMs(now)); - const state = readTelemetryState(configDir); - if (!state.ineligibleThroughDay || state.ineligibleThroughDay < todayDay) { - state.ineligibleThroughDay = todayDay; - } - state.envOptOutActiveSince ??= new Date(now).toISOString(); - writeTelemetryState(state, configDir, now); - deleteTelemetryInstallId(configDir); -} - -/** - * End an observed env opt-out: every day up to today stays ineligible and the marker is cleared, so - * only days strictly after today can be sent. Called by a clean shutdown of an opted-out proxy and - * by the first tick that runs without the opt-out. - */ -export function endTelemetryEnvOptOut(configDir = getConfigDir(), now = Date.now()): void { - const state = readTelemetryState(configDir); - if (!state.envOptOutActiveSince) return; - const todayDay = formatUtcDay(utcMidnightMs(now)); - if (!state.ineligibleThroughDay || state.ineligibleThroughDay < todayDay) { - state.ineligibleThroughDay = todayDay; - } - state.envOptOutActiveSince = null; - writeTelemetryState(state, configDir, now); - deleteTelemetryInstallId(configDir); -} - -export function utcMidnightMs(epochMs: number): number { - const d = new Date(epochMs); - return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); -} - -export function formatUtcDay(epochMs: number): string { - return new Date(epochMs).toISOString().slice(0, 10); -} export function latestCompletedUtcDay(now = Date.now()): string { return formatUtcDay(utcMidnightMs(now) - DAY_MS); @@ -242,7 +67,7 @@ export function resolveNextEligibleTelemetryDay( if (!status.effective) { return { day: null, reason: status.reason }; } - if (state.envOptOutActiveSince) { + if (state.envOptOutActiveSince || (state.envOptOutOwners?.length ?? 0) > 0) { return { day: null, reason: "env-opt-out-pending" }; } const consentTimestamp = status.decidedAt ?? status.noticeShownAt; @@ -343,7 +168,13 @@ export async function runTelemetryTick(options: TelemetryTickOptions = {}): Prom return { sent: false, reason: status.reason }; } - if (readTelemetryState(configDir).envOptOutActiveSince) { + const stateFile = readTelemetryStateFile(configDir); + if (stateFile.unreadable) { + // A state file that exists but cannot be read may have held opt-out days; never send over it. + return { sent: false, reason: "state-unreadable" }; + } + if (stateFile.state.envOptOutActiveSince || (stateFile.state.envOptOutOwners?.length ?? 0) > 0) { + // Ends the period only once no opted-out proxy is still running; otherwise extends it to today. endTelemetryEnvOptOut(configDir, now); } const state = readTelemetryState(configDir); diff --git a/src/telemetry/state.ts b/src/telemetry/state.ts new file mode 100644 index 00000000000..e945bcc4a40 --- /dev/null +++ b/src/telemetry/state.ts @@ -0,0 +1,275 @@ +import { lstatSync, mkdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { atomicWriteFileNoFollow } from "../config/atomic-write"; +import { getConfigDir } from "../config/paths"; +import { recordOwnedConfigPath } from "../lib/config-ownership"; +import type { OcxConfig, OcxTelemetryConfig } from "../types"; +import { parseCalendarUtcDayMs } from "./contract"; +import { deleteTelemetryInstallId } from "./install-id"; + +/** + * `telemetry-state.json`: sent days, opt-out bookkeeping and the pending decision. Kept apart from + * the scheduler so the server's background lifecycle can record an env opt-out without loading the + * scheduler, aggregation, payload and sender modules. + */ + +export const TELEMETRY_STATE_FILE = "telemetry-state.json"; +const DAY_MS = 86_400_000; +const STATE_RETENTION_DAYS = 30; + +/** A proxy that started under an env opt-out and has not stopped cleanly yet. */ +export interface TelemetryEnvOptOutOwner { + pid: number; + since: string; +} +export interface TelemetryState { + version: 1; + sentDays: Record; + lastSentDay: string | null; + lastSentAt: string | null; + ineligibleThroughDay: string | null; + retryAfterUntil: string | null; + /** + * Set whenever an env opt-out is observed and cleared only by a clean opted-out shutdown or by the + * next tick that runs without the opt-out. While it is set, every day up to the current one is + * ineligible, because a proxy started under `OCX_TELEMETRY=0` reads the env once and runs no ticks + * that could keep `ineligibleThroughDay` current (D2). + */ + envOptOutActiveSince?: string | null; + /** + * Proxies running under an env opt-out. A tick without the opt-out ends the period only once all + * of them have exited, so a second proxy sharing the home cannot reopen days the first still covers. + */ + envOptOutOwners?: TelemetryEnvOptOutOwner[]; + /** + * A decision made before `config.json` exists (the Windows installer opt-out, `ocx telemetry + * disable` or the dashboard on a fresh install). Writing the config would turn the install into a + * configured one and make `ocx init` skip setup, so the decision waits here and applies until a + * decision is written into the config. + */ + pendingDecision?: OcxTelemetryConfig | null; + /** Set once any report was accepted, so an id minted after an opt-out is not counted as a new install. */ + everReported?: boolean; +} + +export function telemetryStatePath(configDir = getConfigDir()): string { + return join(configDir, TELEMETRY_STATE_FILE); +} + +function emptyState(): TelemetryState { + return { + version: 1, + sentDays: {}, + lastSentDay: null, + lastSentAt: null, + ineligibleThroughDay: null, + retryAfterUntil: null, + envOptOutActiveSince: null, + envOptOutOwners: [], + pendingDecision: null, + everReported: false, + }; +} + +function parseOwners(raw: unknown): TelemetryEnvOptOutOwner[] { + if (!Array.isArray(raw)) return []; + const owners: TelemetryEnvOptOutOwner[] = []; + for (const entry of raw.slice(0, 64)) { + if (!entry || typeof entry !== "object") continue; + const { pid, since } = entry as Record; + if (typeof pid === "number" && Number.isSafeInteger(pid) && pid > 0 && typeof since === "string" && !Number.isNaN(Date.parse(since))) { + owners.push({ pid, since }); + } + } + return owners; +} + +function parsePendingDecision(raw: unknown): OcxTelemetryConfig | null { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; + const value = raw as Record; + if (value.decision !== "accepted" && value.decision !== "declined") return null; + const stamp = (key: string): string | null => + typeof value[key] === "string" && !Number.isNaN(Date.parse(value[key] as string)) ? (value[key] as string) : null; + const decidedAt = stamp("decidedAt"); + if (!decidedAt) return null; + return { decision: value.decision, decidedAt, noticeShownAt: stamp("noticeShownAt") ?? decidedAt }; +} + +/** + * Read the state, noting whether a file exists but cannot be trusted (a symlink, oversized or + * malformed). Such a file must not be read as empty: that would forget opt-out days. + */ +export function readTelemetryStateFile(configDir = getConfigDir()): { state: TelemetryState; unreadable: boolean } { + const path = telemetryStatePath(configDir); + let stat; + try { + stat = lstatSync(path); + } catch { + return { state: emptyState(), unreadable: false }; + } + try { + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 64 * 1024) return { state: emptyState(), unreadable: true }; + const raw = JSON.parse(readFileSync(path, "utf8")) as Record; + if (raw?.version !== 1 || !raw.sentDays || typeof raw.sentDays !== "object" || Array.isArray(raw.sentDays)) { + return { state: emptyState(), unreadable: true }; + } + const sentDays: Record = {}; + for (const [day, at] of Object.entries(raw.sentDays as Record)) { + if (/^\d{4}-\d{2}-\d{2}$/.test(day) && typeof at === "string") { + sentDays[day] = at; + } + } + const ineligibleThroughDay = + typeof raw.ineligibleThroughDay === "string" && parseCalendarUtcDayMs(raw.ineligibleThroughDay) !== null + ? raw.ineligibleThroughDay + : null; + const retryAfterUntil = + typeof raw.retryAfterUntil === "string" && !Number.isNaN(Date.parse(raw.retryAfterUntil)) + ? raw.retryAfterUntil + : null; + const envOptOutActiveSince = + typeof raw.envOptOutActiveSince === "string" && !Number.isNaN(Date.parse(raw.envOptOutActiveSince)) + ? raw.envOptOutActiveSince + : null; + return { unreadable: false, state: { + version: 1, + sentDays, + lastSentDay: typeof raw.lastSentDay === "string" ? raw.lastSentDay : null, + lastSentAt: typeof raw.lastSentAt === "string" ? raw.lastSentAt : null, + ineligibleThroughDay, + retryAfterUntil, + envOptOutActiveSince, + envOptOutOwners: parseOwners(raw.envOptOutOwners), + pendingDecision: parsePendingDecision(raw.pendingDecision), + everReported: raw.everReported === true, + } }; + } catch { + return { state: emptyState(), unreadable: true }; + } +} + +export function readTelemetryState(configDir = getConfigDir()): TelemetryState { + return readTelemetryStateFile(configDir).state; +} + +export function writeTelemetryState(state: TelemetryState, configDir = getConfigDir(), now = Date.now()): void { + mkdirSync(configDir, { recursive: true, mode: 0o700 }); + const path = telemetryStatePath(configDir); + try { recordOwnedConfigPath(configDir, path); } catch { /* best-effort */ } + const minKeepDay = formatUtcDay(utcMidnightMs(now) - STATE_RETENTION_DAYS * DAY_MS); + const prunedSentDays: Record = {}; + for (const [day, at] of Object.entries(state.sentDays ?? {})) { + if (day >= minKeepDay) prunedSentDays[day] = at; + } + const payload: TelemetryState = { + version: 1, + sentDays: prunedSentDays, + lastSentDay: state.lastSentDay ?? null, + lastSentAt: state.lastSentAt ?? null, + ineligibleThroughDay: state.ineligibleThroughDay ?? null, + retryAfterUntil: state.retryAfterUntil ?? null, + envOptOutActiveSince: state.envOptOutActiveSince ?? null, + envOptOutOwners: state.envOptOutOwners ?? [], + pendingDecision: state.pendingDecision ?? null, + everReported: state.everReported === true, + }; + atomicWriteFileNoFollow(path, `${JSON.stringify(payload, null, 2)}\n`); +} + +/** Hold a decision made before `config.json` exists. */ +export function recordPendingTelemetryDecision( + decision: OcxTelemetryConfig, + configDir = getConfigDir(), + now = Date.now(), +): void { + const state = readTelemetryState(configDir); + state.pendingDecision = structuredClone(decision); + writeTelemetryState(state, configDir, now); +} + +/** Drop the pending decision once a decision is written into `config.json`. */ +export function clearPendingTelemetryDecision(configDir = getConfigDir(), now = Date.now()): void { + const state = readTelemetryState(configDir); + if (!state.pendingDecision) return; + state.pendingDecision = null; + writeTelemetryState(state, configDir, now); +} + +/** + * The telemetry block consent is resolved from: the config's own decision when it has one, + * otherwise a decision held from before `config.json` existed, otherwise whatever the config says. + */ +export function withPendingTelemetryDecision>( + config: T | null | undefined, + configDir = getConfigDir(), +): Pick | null | undefined { + if (config?.telemetry?.decision) return config; + const pending = readTelemetryState(configDir).pendingDecision; + if (!pending) return config; + return { ...(config ?? {}), telemetry: structuredClone(pending) }; +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException)?.code === "EPERM"; + } +} + +function extendIneligibleThroughToday(state: TelemetryState, now: number): void { + const todayDay = formatUtcDay(utcMidnightMs(now)); + if (!state.ineligibleThroughDay || state.ineligibleThroughDay < todayDay) { + state.ineligibleThroughDay = todayDay; + } +} + +/** + * Persist `ineligibleThroughDay = today's UTC day` and the active opt-out marker in + * `telemetry-state.json` (atomic no-follow) and delete `telemetry-id.json` whenever an + * `OCX_TELEMETRY` or `DO_NOT_TRACK` opt-out is observed. A proxy that runs under the opt-out + * passes its pid as `owner`; a one-off observation (a CLI command, a tick) passes none. + */ +export function recordTelemetryEnvOptOut(configDir = getConfigDir(), now = Date.now(), owner?: number): void { + const state = readTelemetryState(configDir); + extendIneligibleThroughToday(state, now); + if (owner === undefined) { + state.envOptOutActiveSince ??= new Date(now).toISOString(); + } else { + const owners = (state.envOptOutOwners ?? []).filter(entry => entry.pid !== owner); + state.envOptOutOwners = [...owners, { pid: owner, since: new Date(now).toISOString() }]; + } + writeTelemetryState(state, configDir, now); + deleteTelemetryInstallId(configDir); +} + +/** + * Close an env opt-out period so only days strictly after today can be sent. A clean stop of an + * opted-out proxy passes its pid and always covers its stop day. A tick passes none: it ends the + * period only when no recorded opted-out proxy is still running, and otherwise extends + * `ineligibleThroughDay` to today. Returns true while an opted-out proxy is still running. + */ +export function endTelemetryEnvOptOut(configDir = getConfigDir(), now = Date.now(), owner?: number): boolean { + const state = readTelemetryState(configDir); + const recorded = state.envOptOutOwners ?? []; + const others = owner === undefined ? recorded : recorded.filter(entry => entry.pid !== owner); + const running = others.filter(entry => isProcessAlive(entry.pid)); + if (owner === undefined && !state.envOptOutActiveSince && recorded.length === 0) return false; + extendIneligibleThroughToday(state, now); + state.envOptOutOwners = running; + if (running.length === 0) state.envOptOutActiveSince = null; + writeTelemetryState(state, configDir, now); + deleteTelemetryInstallId(configDir); + return running.length > 0; +} + + +export function utcMidnightMs(epochMs: number): number { + const d = new Date(epochMs); + return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); +} + +export function formatUtcDay(epochMs: number): string { + return new Date(epochMs).toISOString().slice(0, 10); +} diff --git a/structure/telemetry.md b/structure/telemetry.md index 4f1029a6aaf..e44ee3af428 100644 --- a/structure/telemetry.md +++ b/structure/telemetry.md @@ -32,7 +32,9 @@ Only a human can transition an undecided install to active sending: from `src/server/management/telemetry-routes.ts` and lazily mounted in `src/server/management-api.ts`) requires `hasHumanDashboardSession` from `src/server/management/consent-session.ts`. `GET /api/telemetry` previews the oldest eligible unsent day (or the latest completed day with - `previewEligible: false` when no day is eligible). + `previewEligible: false` when no day is eligible). It reads consent from `config.json` on disk, + like the scheduler, and reuses a day's aggregate for `TELEMETRY_PREVIEW_CACHE_MS` because the + dashboard polls it. - `ocx telemetry enable` in `src/cli/telemetry.ts` requires an interactive human terminal (`isatty` stdin and stdout, not `OCX_SERVICE`, not agent-driven), prints the disclosure block, and asks for confirmation before recording `accepted`; otherwise it exits non-zero with @@ -69,11 +71,16 @@ with a 10-second `signalWithTimeout` and parses `Retry-After` on HTTP 429/503 re started from `src/server/background-lifecycle.ts`. Whenever an `OCX_TELEMETRY` or `DO_NOT_TRACK` opt-out is observed (at startup in `src/server/background-lifecycle.ts` or on a tick), `recordTelemetryEnvOptOut` persists `ineligibleThroughDay` (today's UTC day) and the -`envOptOutActiveSince` marker in `telemetry-state.json` and deletes `telemetry-id.json`. A proxy -started under the opt-out reads the env once and runs no ticks, so the marker stands for the whole -period it ran: while it is set no day is eligible, and `endTelemetryEnvOptOut` closes the period -(ineligible through the closing day, marker cleared) on that proxy's clean stop or on the first tick -that runs without the opt-out. Each tick sends at most one completed UTC +`envOptOutActiveSince` marker in `telemetry-state.json` and deletes `telemetry-id.json`; a proxy +started under the opt-out records its pid in `envOptOutOwners` instead. Such a proxy reads the env +once and runs no ticks, so the marker or owner stands for the whole period: while either is set no +day is eligible. `endTelemetryEnvOptOut` closes the period (ineligible through the closing day) on +that proxy's clean stop, which always covers its stop day, or on the first tick without the opt-out, +which ends it only once no recorded owner pid is still alive and otherwise extends it to today. A +`telemetry-state.json` that exists but cannot be read (symlink, oversized, malformed) stops sending +(`state-unreadable`) rather than being read as empty. The state code lives in +`src/telemetry/state.ts`, which `background-lifecycle.ts` imports statically; the scheduler, its +aggregation and sender stay behind the dynamic import. Each tick sends at most one completed UTC day strictly after the UTC day of `decidedAt ?? noticeShownAt` and strictly after `ineligibleThroughDay`, honors `retryAfterUntil` backoffs, re-reads persisted consent and env immediately before transmission, and carries an `AbortSignal` cancelled on stop or consent changes. diff --git a/tests/server/server-telemetry-routes.test.ts b/tests/server/server-telemetry-routes.test.ts index e0a180e0989..986d751bed0 100644 --- a/tests/server/server-telemetry-routes.test.ts +++ b/tests/server/server-telemetry-routes.test.ts @@ -256,4 +256,14 @@ describe("/api/telemetry management routes", () => { const status = await call("GET", "/api/telemetry"); expect(status.body).toMatchObject({ effective: false, decision: "declined" }); }); + + test("GET /api/telemetry reports a decision written to disk while the server's config is stale", async () => { + writeFileSync(join(home, "config.json"), JSON.stringify({ + ...config, + telemetry: { decision: "declined", decidedAt: "2026-11-08T10:00:00.000Z", noticeShownAt: "2026-11-08T10:00:00.000Z" }, + }, null, 2) + "\n", "utf8"); + expect(config.telemetry).toBeUndefined(); + const status = await call("GET", "/api/telemetry"); + expect(status.body).toMatchObject({ effective: false, decision: "declined" }); + }); }); diff --git a/tests/usage/usage-telemetry-client.test.ts b/tests/usage/usage-telemetry-client.test.ts index 0ce6fa8b831..1e3db899269 100644 --- a/tests/usage/usage-telemetry-client.test.ts +++ b/tests/usage/usage-telemetry-client.test.ts @@ -40,6 +40,7 @@ import { computeTelemetryIntervalDelayMs, computeTelemetryStartupDelayMs, nudgeTelemetryScheduler, + endTelemetryEnvOptOut, readTelemetryState, recordTelemetryEnvOptOut, runTelemetryTick, @@ -743,7 +744,11 @@ describe("install identity, aggregation, disjoint tokens, and scheduler", () => test("never sends its opt-out days when it did not stop cleanly before a normal proxy ticks", async () => { const lease = startOptedOutProxy("2026-11-02T09:00:00.000Z"); try { - // Monday through Thursday ran opted out. A normal proxy takes over on Friday. + // Monday through Thursday ran opted out, then the proxy crashed: its recorded pid is gone. + const crashed = readTelemetryState(home); + expect(crashed.envOptOutOwners?.map(owner => owner.pid)).toEqual([process.pid]); + writeTelemetryState({ ...crashed, envOptOutOwners: [{ pid: 2_000_000_000, since: crashed.envOptOutOwners![0]!.since }] }, home); + // A normal proxy takes over on Friday. setSystemTime(new Date("2026-11-06T12:00:00.000Z")); expect((await tickAt("2026-11-06T12:00:00.000Z")).sent).toBe(false); expect((await tickAt("2026-11-07T12:00:00.000Z")).sent).toBe(false); @@ -809,6 +814,65 @@ describe("install identity, aggregation, disjoint tokens, and scheduler", () => expect(readTelemetryState(home).everReported).toBe(true); }); + test("D2: a proxy without the opt-out never ends the period of an opted-out proxy that is still running", async () => { + const sentDays: string[] = []; + const mockFetch = (async (_url: string | URL | Request, init?: RequestInit) => { + sentDays.push((JSON.parse(String(init?.body)) as TelemetryPayloadV1).day); + return new Response(JSON.stringify({ ok: true }), { status: 202 }); + }) as unknown as typeof fetch; + const config = { + telemetry: { decision: "accepted" as const, decidedAt: "2026-10-30T10:00:00.000Z", noticeShownAt: "2026-10-30T10:00:00.000Z" }, + }; + const tickAt = (iso: string) => runTelemetryTick({ configDir: home, config, env: {}, now: Date.parse(iso), fetch: mockFetch }); + + // Process B (this test process, so it is alive) starts opted out on 2026-11-02 and keeps running. + recordTelemetryEnvOptOut(home, Date.parse("2026-11-02T09:00:00.000Z"), process.pid); + // Process A, sharing the home without the opt-out, ticks on the same and later days. + expect((await tickAt("2026-11-02T12:00:00.000Z")).sent).toBe(false); + expect((await tickAt("2026-11-04T12:00:00.000Z")).sent).toBe(false); + expect(sentDays).toEqual([]); + expect(readTelemetryState(home).ineligibleThroughDay).toBe("2026-11-04"); + + // B stops cleanly on 2026-11-05: every day through its stop day stays unsent. + endTelemetryEnvOptOut(home, Date.parse("2026-11-05T08:00:00.000Z"), process.pid); + expect((await tickAt("2026-11-06T12:00:00.000Z")).sent).toBe(false); + expect(await tickAt("2026-11-07T12:00:00.000Z")).toMatchObject({ sent: true, day: "2026-11-06" }); + expect(sentDays).toEqual(["2026-11-06"]); + }); + + test("D2: an opted-out proxy that died is closed by the next tick", async () => { + const config = { + telemetry: { decision: "accepted" as const, decidedAt: "2026-10-30T10:00:00.000Z", noticeShownAt: "2026-10-30T10:00:00.000Z" }, + }; + // A pid far above any real one stands for a process that has exited. + recordTelemetryEnvOptOut(home, Date.parse("2026-11-02T09:00:00.000Z"), 2_000_000_000); + const result = await runTelemetryTick({ configDir: home, config, env: {}, now: Date.parse("2026-11-04T12:00:00.000Z"), fetch: (async () => new Response("{}", { status: 202 })) as unknown as typeof fetch }); + expect(result.sent).toBe(false); + expect(readTelemetryState(home)).toMatchObject({ ineligibleThroughDay: "2026-11-04", envOptOutActiveSince: null }); + expect(readTelemetryState(home).envOptOutOwners ?? []).toEqual([]); + }); + + test("an unreadable telemetry-state.json blocks sending instead of forgetting opt-out days", async () => { + let fetchCalls = 0; + const mockFetch = (async () => { + fetchCalls += 1; + return new Response("{}", { status: 202 }); + }) as unknown as typeof fetch; + const config = { + telemetry: { decision: "accepted" as const, decidedAt: "2026-10-30T10:00:00.000Z", noticeShownAt: "2026-10-30T10:00:00.000Z" }, + }; + writeFileSync(telemetryStatePath(home), "{ not json", "utf8"); + expect(await runTelemetryTick({ configDir: home, config, env: {}, now: NOW, fetch: mockFetch })).toEqual({ sent: false, reason: "state-unreadable" }); + expect(fetchCalls).toBe(0); + }); + + test("a telemetry-id.json deleted by another process is not served from memory", () => { + const { identity } = ensureTelemetryInstallId(home); + expect(readTelemetryInstallId(home)?.installId).toBe(identity.installId); + unlinkSync(telemetryIdPath(home)); + expect(readTelemetryInstallId(home)).toBeNull(); + }); + test("D7: re-reads consent and env before transmission and aborts in-flight ticks on stop or nudge", async () => { let fetchCalls = 0; const mockFetch = (async () => { From 5ded86367debc2a8c31244091de9c4e3166582c0 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 19:39:37 +0900 Subject: [PATCH 20/37] fix(desktop): make the installer opt-out machine-wide, keep it through upgrades, and apply it once per user From the round-2 privacy and code reviews of #8348: - A silent install run as SYSTEM (Intune, SCCM) or approved by another administrator wrote the choice to that account's HKCU, so real users never saw the opt-out. The choice and a stamp of when it was made now go to HKLM; the app applies an opt-out once per user and records the applied stamp in that user's HKCU, so a later dashboard choice is never overridden. - OCX_TELEMETRY_CHOICE has no default: a command-line value gets a new stamp, and otherwise AppSearch carries the previous choice and stamp, so an opt-out survives in-app updates and the major upgrade's early removal of the old product. - With the full UI, the box starts unchecked when the install already carries an opt-out (a command-line value or an earlier one); the page note says what each state does. - The opt-out step has its own 10-second cap inside the startup deadline. - Docs: the structure doc and the desktop guide describe the machine-wide choice and in-app updates. Rust: the pure logic passes 4 unit tests compiled standalone; the Windows registry code passes cargo clippy -D warnings for x86_64-pc-windows-msvc against winreg 0.55. --- desktop/scripts/telemetry-installer-dialog.ts | 50 +++- desktop/src-tauri/src/installer_telemetry.rs | 238 ++++++++++++------ desktop/src-tauri/wix/telemetry-choice.wxs | 34 ++- .../src/content/docs/guides/desktop-app.md | 11 +- structure/desktop-shell.md | 45 ++-- .../desktop-telemetry-installer.test.ts | 44 +++- 6 files changed, 294 insertions(+), 128 deletions(-) diff --git a/desktop/scripts/telemetry-installer-dialog.ts b/desktop/scripts/telemetry-installer-dialog.ts index 0747961ba18..8427b99a05f 100644 --- a/desktop/scripts/telemetry-installer-dialog.ts +++ b/desktop/scripts/telemetry-installer-dialog.ts @@ -13,9 +13,11 @@ import { * policy, display that policy during installation, and include an installation option to disable * it. The fragment inserts a dialog between the welcome and install-directory pages of Tauri's * WixUI_InstallDir sequence. It shows the same disclosure the CLI prompt prints, links the policy, - * and offers a "Send anonymous usage statistics" checkbox, checked by default. The choice is - * written to HKCU; the desktop app (`installer_telemetry.rs`) persists an opt-out into the user's - * opencodex config before it starts or attaches to a proxy. + * and offers a "Send anonymous usage statistics" checkbox, checked by default (unchecked when the + * install already carries an opt-out). The choice and a stamp of when it was made go to HKLM, so a + * silent install run as SYSTEM covers every user, and an upgrade carries the previous choice forward. + * The desktop app (`installer_telemetry.rs`) persists an opt-out into each user's opencodex config + * once per stamp, before it starts or attaches to a proxy. * * Silent installs choose with the public property: `msiexec /i OpenCodex.msi /qn OCX_TELEMETRY_CHOICE=optout`. * @@ -28,16 +30,18 @@ export const TELEMETRY_INSTALLER_COMPONENT_GROUP = "OcxTelemetryInstallerChoice" export const TELEMETRY_INSTALLER_DIALOG = "OcxTelemetryDlg"; export const TELEMETRY_INSTALLER_REGISTRY_KEY = "Software\\OpenCodex\\Telemetry"; export const TELEMETRY_INSTALLER_REGISTRY_VALUE = "InstallerChoice"; +export const TELEMETRY_INSTALLER_STAMP_VALUE = "InstallerChoiceStamp"; +export const TELEMETRY_INSTALLER_STAMP_PROPERTY = "OCX_TELEMETRY_STAMP"; export const TELEMETRY_INSTALLER_CHOICE_PROPERTY = "OCX_TELEMETRY_CHOICE"; export const TELEMETRY_INSTALLER_CHECKBOX_PROPERTY = "OCX_TELEMETRY_SEND"; export const TELEMETRY_INSTALLER_CHECKBOX_TEXT = "Send anonymous usage statistics"; -// Shown under the checkbox: a checked box writes "send", which is never acted on. +// Shown under the checkbox: "send" is never acted on, and an opt-out applies to every account. export const TELEMETRY_INSTALLER_KEEP_NOTE = - "Leaving it checked keeps your current choice; it never turns telemetry back on."; + "Checked keeps each account's current choice; unchecked turns it off for all."; export const TELEMETRY_INSTALLER_SEND = "send"; export const TELEMETRY_INSTALLER_OPT_OUT = "optout"; -// Fixed so upgrades keep one component identity for the registry value. -const COMPONENT_GUID = "6F2B8A41-3C7D-4E59-9A1F-0D8C5B7E2A93"; +// Fixed so upgrades keep one component identity for the HKLM values (new when the key path moved to HKLM). +const COMPONENT_GUID = "B3E1C7D2-58A4-4F0E-9C6B-7A2D41E95F08"; // Dialog units. WiX's standard dialogs are 370 x 270 with the button row below y = 234. const TEXT_LEFT = 20; @@ -92,13 +96,36 @@ export function renderTelemetryInstallerFragment(): string { } return ` + + + + + - - + + + + + + + + + + + + + ${TELEMETRY_INSTALLER_CHOICE_PROPERTY} + NOT ${TELEMETRY_INSTALLER_CHOICE_PROPERTY} AND OCX_TELEMETRY_PREVIOUS_CHOICE + NOT ${TELEMETRY_INSTALLER_STAMP_PROPERTY} AND OCX_TELEMETRY_PREVIOUS_STAMP + NOT ${TELEMETRY_INSTALLER_CHOICE_PROPERTY} + + ${TELEMETRY_INSTALLER_CHOICE_PROPERTY} <> "${TELEMETRY_INSTALLER_OPT_OUT}" - - + + + + @@ -120,6 +147,7 @@ ${controls.join("\n")} ${TELEMETRY_INSTALLER_CHECKBOX_PROPERTY} = "1" NOT (${TELEMETRY_INSTALLER_CHECKBOX_PROPERTY} = "1") + 1 1 diff --git a/desktop/src-tauri/src/installer_telemetry.rs b/desktop/src-tauri/src/installer_telemetry.rs index dee11095dcd..c9ffa152e1a 100644 --- a/desktop/src-tauri/src/installer_telemetry.rs +++ b/desktop/src-tauri/src/installer_telemetry.rs @@ -1,12 +1,20 @@ //! The Windows installer's "Send anonymous usage statistics" choice. //! //! SignPath Foundation's conditions require software that sends usage data to include an -//! installation option that disables it. The MSI dialog generated into -//! `desktop/src-tauri/wix/telemetry-choice.wxs` records the person's choice under -//! `HKCU\Software\OpenCodex\Telemetry\InstallerChoice`. Before the app resolves, attaches to or -//! starts a proxy, this module carries an opt-out into the user's opencodex config by running the -//! bundled `ocx telemetry disable`, which persists `telemetry.decision = "declined"`. The value is -//! then rewritten to `applied`, so a later choice made in the dashboard is never overridden. +//! installation option that disables it. The MSI page generated into +//! `desktop/src-tauri/wix/telemetry-choice.wxs` records the choice machine-wide under +//! `HKLM\Software\OpenCodex\Telemetry`: `InstallerChoice` (`send` or `optout`) and +//! `InstallerChoiceStamp` (when it was chosen). HKLM is what makes a silent install run as SYSTEM +//! (Intune, SCCM) reach the people who later use the app; an upgrade carries the previous choice and +//! stamp forward. +//! +//! Before the app resolves, attaches to or starts a proxy, this module carries an opt-out into the +//! current user's opencodex config by running the bundled `ocx telemetry disable`, which persists +//! `telemetry.decision = "declined"` (on a fresh install it is held without creating `config.json`). +//! The applied stamp is then recorded in that user's `HKCU\Software\OpenCodex\Telemetry`, so each +//! opt-out applies once per user and a later dashboard choice is never overridden. A new install that +//! carries an opt-out has a new stamp and applies again; that is a person or an administrator +//! choosing again. //! //! When the decline could not be persisted, a sidecar this app starts runs with //! `OCX_TELEMETRY=0`, and the save is retried in the background (`RETRY_DELAYS`) and again on the @@ -27,12 +35,17 @@ use tokio::time::{timeout_at, Instant}; pub const REGISTRY_KEY: &str = r"Software\OpenCodex\Telemetry"; #[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub const REGISTRY_VALUE: &str = "InstallerChoice"; -pub const OPT_OUT: &str = "optout"; #[cfg_attr(not(target_os = "windows"), allow(dead_code))] -pub const APPLIED: &str = "applied"; +pub const STAMP_VALUE: &str = "InstallerChoiceStamp"; +/// Written by this app under HKCU: the stamp of the last installer opt-out applied for this user. +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +pub const APPLIED_STAMP_VALUE: &str = "AppliedInstallerStamp"; +pub const OPT_OUT: &str = "optout"; static SIDECAR_ENV_OPT_OUT: AtomicBool = AtomicBool::new(false); +/// The opt-out step's own budget, so a slow first `ocx` start cannot use up the startup deadline. +const STEP_CAP: Duration = Duration::from_secs(10); /// Background retries after a failed save, so an attached proxy does not keep its old setting. pub const RETRY_DELAYS: [Duration; 4] = [ Duration::from_secs(10), @@ -42,21 +55,39 @@ pub const RETRY_DELAYS: [Duration; 4] = [ ]; const RETRY_TIMEOUT: Duration = Duration::from_secs(30); +/// The machine-wide choice the installer recorded. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InstallerChoice { + pub choice: String, + pub stamp: String, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Outcome { - /// No opt-out is pending: no value, `send`, or one that was already applied. + /// No opt-out is pending for this user: no choice, `send`, or one already applied. NothingPending, - /// The decline is in the user's config and the registry value now reads `applied`. + /// The decline is in the user's config and its stamp is recorded for this user. Applied, - /// The decline is in the config, but the registry value could not be rewritten. + /// The decline is in the config, but the stamp could not be recorded; the next launch re-applies it. AppliedUnmarked, - /// The decline could not be persisted; this session's sidecar runs with `OCX_TELEMETRY=0`. + /// The decline could not be persisted; a sidecar this app starts runs with `OCX_TELEMETRY=0`. NotPersisted, } -/// Whether a recorded installer choice still has to be carried into the config. -pub fn is_pending_opt_out(value: Option<&str>) -> bool { - value.is_some_and(|value| value.trim().eq_ignore_ascii_case(OPT_OUT)) +/// The stamp of an installer opt-out this user has not applied yet. +pub fn pending_stamp<'a>( + choice: Option<&'a InstallerChoice>, + applied: Option<&str>, +) -> Option<&'a str> { + let choice = choice?; + if !choice.choice.trim().eq_ignore_ascii_case(OPT_OUT) { + return None; + } + let stamp = choice.stamp.trim(); + if applied.map(str::trim) == Some(stamp) { + return None; + } + Some(stamp) } /// `ocx telemetry disable` exits 0 only once the decline is persisted. @@ -64,19 +95,19 @@ pub fn decline_persisted(exit_code: Option) -> bool { exit_code == Some(0) } -/// Decide what a pending choice leads to, given how persisting and marking went. +/// Decide what a pending opt-out leads to, given how persisting and recording its stamp went. pub fn settle( - value: Option<&str>, + pending: Option<&str>, persist: impl FnOnce() -> bool, - mark_applied: impl FnOnce() -> bool, + mark_applied: impl FnOnce(&str) -> bool, ) -> Outcome { - if !is_pending_opt_out(value) { + let Some(stamp) = pending else { return Outcome::NothingPending; - } + }; if !persist() { return Outcome::NotPersisted; } - if mark_applied() { + if mark_applied(stamp) { Outcome::Applied } else { Outcome::AppliedUnmarked @@ -88,15 +119,16 @@ pub fn sidecar_env_opt_out() -> bool { SIDECAR_ENV_OPT_OUT.load(Ordering::SeqCst) } -/// Carry a pending installer opt-out into the user's opencodex config. +/// Carry a pending installer opt-out into the current user's opencodex config. pub async fn apply(app: &AppHandle, deadline: Instant) -> Outcome { - let value = os::read(); - let persisted = if is_pending_opt_out(value.as_deref()) { - disable(app, deadline).await - } else { - false + let choice = os::read_choice(); + let applied = os::read_applied_stamp(); + let pending = pending_stamp(choice.as_ref(), applied.as_deref()).map(str::to_owned); + let persisted = match pending { + Some(_) => disable(app, deadline.min(Instant::now() + STEP_CAP)).await, + None => false, }; - let outcome = settle(value.as_deref(), || persisted, os::mark_applied); + let outcome = settle(pending.as_deref(), || persisted, os::mark_applied); match outcome { Outcome::NotPersisted => { SIDECAR_ENV_OPT_OUT.store(true, Ordering::SeqCst); @@ -104,11 +136,13 @@ pub async fn apply(app: &AppHandle, deadline: Instant) -> Outcome { "the installer's telemetry opt-out could not be saved", "a proxy this app starts runs with OCX_TELEMETRY=0; saving is retried in the background", ); - let app = app.clone(); - tauri::async_runtime::spawn(async move { retry(app).await }); + if let Some(stamp) = pending { + let app = app.clone(); + tauri::async_runtime::spawn(async move { retry(app, stamp).await }); + } } Outcome::AppliedUnmarked => crate::logging::log_once( - "the installer's telemetry opt-out was saved but not marked as applied", + "the installer's telemetry opt-out was saved but not recorded as applied", "the next launch saves the decline again", ), Outcome::NothingPending | Outcome::Applied => {} @@ -116,13 +150,13 @@ pub async fn apply(app: &AppHandle, deadline: Instant) -> Outcome { outcome } -async fn retry(app: AppHandle) { +async fn retry(app: AppHandle, stamp: String) { for delay in RETRY_DELAYS { tokio::time::sleep(delay).await; if disable(&app, Instant::now() + RETRY_TIMEOUT).await { - if !os::mark_applied() { + if !os::mark_applied(&stamp) { crate::logging::log_once( - "the installer's telemetry opt-out was saved but not marked as applied", + "the installer's telemetry opt-out was saved but not recorded as applied", "the next launch saves the decline again", ); } @@ -153,21 +187,41 @@ async fn disable(app: &AppHandle, deadline: Instant) -> bool { #[cfg(target_os = "windows")] mod os { - use winreg::enums::{HKEY_CURRENT_USER, KEY_READ, KEY_SET_VALUE}; + use winreg::enums::{ + HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, KEY_READ, KEY_WOW64_32KEY, KEY_WOW64_64KEY, + }; use winreg::RegKey; - pub fn read() -> Option { + /// The installer's machine-wide choice, from the 64-bit view first and the 32-bit view second. + pub fn read_choice() -> Option { + for view in [KEY_WOW64_64KEY, KEY_WOW64_32KEY] { + let Ok(key) = RegKey::predef(HKEY_LOCAL_MACHINE) + .open_subkey_with_flags(super::REGISTRY_KEY, KEY_READ | view) + else { + continue; + }; + if let Ok(choice) = key.get_value::(super::REGISTRY_VALUE) { + let stamp = key + .get_value::(super::STAMP_VALUE) + .unwrap_or_default(); + return Some(super::InstallerChoice { choice, stamp }); + } + } + None + } + + pub fn read_applied_stamp() -> Option { RegKey::predef(HKEY_CURRENT_USER) .open_subkey_with_flags(super::REGISTRY_KEY, KEY_READ) .ok()? - .get_value::(super::REGISTRY_VALUE) + .get_value::(super::APPLIED_STAMP_VALUE) .ok() } - pub fn mark_applied() -> bool { + pub fn mark_applied(stamp: &str) -> bool { RegKey::predef(HKEY_CURRENT_USER) - .open_subkey_with_flags(super::REGISTRY_KEY, KEY_SET_VALUE) - .and_then(|key| key.set_value(super::REGISTRY_VALUE, &super::APPLIED.to_owned())) + .create_subkey(super::REGISTRY_KEY) + .and_then(|(key, _)| key.set_value(super::APPLIED_STAMP_VALUE, &stamp.to_owned())) .is_ok() } } @@ -175,33 +229,54 @@ mod os { #[cfg(not(target_os = "windows"))] mod os { // Only the Windows installer records a choice. - pub fn read() -> Option { + pub fn read_choice() -> Option { None } - pub fn mark_applied() -> bool { + pub fn read_applied_stamp() -> Option { + None + } + + pub fn mark_applied(_stamp: &str) -> bool { true } } #[cfg(test)] mod tests { - use super::{decline_persisted, is_pending_opt_out, settle, Outcome}; + use super::{decline_persisted, pending_stamp, settle, InstallerChoice, Outcome}; use std::cell::Cell; + fn choice(choice: &str, stamp: &str) -> InstallerChoice { + InstallerChoice { + choice: choice.to_owned(), + stamp: stamp.to_owned(), + } + } + #[test] - fn only_a_recorded_opt_out_is_pending() { - assert!(is_pending_opt_out(Some("optout"))); - assert!(is_pending_opt_out(Some(" OptOut "))); - for value in [ - None, - Some(""), - Some("send"), - Some("applied"), - Some("opt-out-later"), + fn an_opt_out_is_pending_until_its_stamp_is_applied_for_this_user() { + let opt_out = choice("optout", "11/8/2026 10:12:33"); + assert_eq!( + pending_stamp(Some(&opt_out), None), + Some("11/8/2026 10:12:33") + ); + assert_eq!( + pending_stamp(Some(&choice(" OptOut ", "s1")), Some("s0")), + Some("s1") + ); + assert_eq!( + pending_stamp(Some(&opt_out), Some("11/8/2026 10:12:33")), + None + ); + for other in [ + choice("send", "s1"), + choice("", "s1"), + choice("opt-out-later", "s1"), ] { - assert!(!is_pending_opt_out(value), "{value:?}"); + assert_eq!(pending_stamp(Some(&other), None), None, "{other:?}"); } + assert_eq!(pending_stamp(None, None), None); } #[test] @@ -214,42 +289,51 @@ mod tests { #[test] fn nothing_runs_without_a_pending_opt_out() { - for value in [None, Some("send"), Some("applied")] { - let ran = Cell::new(false); - let outcome = settle( - value, - || { - ran.set(true); - true - }, - || { - ran.set(true); - true - }, - ); - assert_eq!(outcome, Outcome::NothingPending); - assert!(!ran.get(), "{value:?}"); - } + let ran = Cell::new(false); + let outcome = settle( + None, + || { + ran.set(true); + true + }, + |_| { + ran.set(true); + true + }, + ); + assert_eq!(outcome, Outcome::NothingPending); + assert!(!ran.get()); } #[test] - fn an_opt_out_is_marked_only_after_the_decline_persisted() { - let marked = Cell::new(false); + fn a_stamp_is_recorded_only_after_the_decline_persisted() { + let marked = Cell::new(None::); assert_eq!( settle( - Some("optout"), + Some("s1"), || false, - || { - marked.set(true); + |stamp| { + marked.set(Some(stamp.to_owned())); true } ), Outcome::NotPersisted ); - assert!(!marked.get()); - assert_eq!(settle(Some("optout"), || true, || true), Outcome::Applied); + assert_eq!(marked.take(), None); + assert_eq!( + settle( + Some("s1"), + || true, + |stamp| { + marked.set(Some(stamp.to_owned())); + true + } + ), + Outcome::Applied + ); + assert_eq!(marked.take().as_deref(), Some("s1")); assert_eq!( - settle(Some("optout"), || true, || false), + settle(Some("s1"), || true, |_| false), Outcome::AppliedUnmarked ); } diff --git a/desktop/src-tauri/wix/telemetry-choice.wxs b/desktop/src-tauri/wix/telemetry-choice.wxs index 1fcf485c118..cc13b3df22a 100644 --- a/desktop/src-tauri/wix/telemetry-choice.wxs +++ b/desktop/src-tauri/wix/telemetry-choice.wxs @@ -1,12 +1,35 @@ + + + + + - - + + + + + + + + + + + + + OCX_TELEMETRY_CHOICE + NOT OCX_TELEMETRY_CHOICE AND OCX_TELEMETRY_PREVIOUS_CHOICE + NOT OCX_TELEMETRY_STAMP AND OCX_TELEMETRY_PREVIOUS_STAMP + NOT OCX_TELEMETRY_CHOICE + + OCX_TELEMETRY_CHOICE <> "optout" - - + + + + @@ -25,13 +48,14 @@ Read the full privacy policy]]> - + 1 OCX_TELEMETRY_SEND = "1" NOT (OCX_TELEMETRY_SEND = "1") + 1 1 diff --git a/docs-site/src/content/docs/guides/desktop-app.md b/docs-site/src/content/docs/guides/desktop-app.md index fc1d30e984c..3310795b3a2 100644 --- a/docs-site/src/content/docs/guides/desktop-app.md +++ b/docs-site/src/content/docs/guides/desktop-app.md @@ -31,7 +31,9 @@ release carries the signature, Windows SmartScreen may warn about an unsigned in One installer page describes the anonymous daily usage summary OpenCodex sends, links the [privacy statement](/reference/code-signing/#privacy), and has a **Send anonymous usage statistics** checkbox, checked by default. Clear it and the app saves the opt-out to your -opencodex config the first time it starts, before any proxy runs. For a silent install, pass +opencodex config the first time it starts, before any proxy runs. The choice applies to every +account on the computer, once for each account, so it also works for a silent install run by an +administrator or a deployment tool (Intune, SCCM). For a silent install, pass `OCX_TELEMETRY_CHOICE=optout`: ```powershell @@ -39,10 +41,9 @@ msiexec /i OpenCodex--windows-x64.msi /qn OCX_TELEMETRY_CHOICE=optout ``` Leaving the box checked does not start sending by itself: the dashboard still shows its one-time -notice first. Updates install without this page and never change an earlier opt-out. -If an administrator account other than yours approves the install, the choice is recorded for that -account; your first launch then asks again in the dashboard notice, and nothing is sent before you -answer. +notice first. In-app updates install without this page and keep an earlier opt-out. Running a +newer installer by hand shows the page again, unchecked if you opted out before. If you have since +turned telemetry on in the dashboard and want to keep it, check the box before you continue. ### Linux diff --git a/structure/desktop-shell.md b/structure/desktop-shell.md index 32d09d6781b..00c95186052 100644 --- a/structure/desktop-shell.md +++ b/structure/desktop-shell.md @@ -371,28 +371,33 @@ policy during installation and to include an installation option that disables i CLI prompt prints, and `tests/clients/desktop-telemetry-installer.test.ts` fails when the committed file is stale. It inserts `OcxTelemetryDlg` between Tauri's `WelcomeDlg` and `InstallDirDlg` (`Order="3"` overrides the template's `Order="2"` route) with the disclosure, a policy link and a -"Send anonymous usage statistics" checkbox, checked by default, and writes the choice to -`HKCU\Software\OpenCodex\Telemetry\InstallerChoice` (`send` or `optout`; silent installs pass -`OCX_TELEMETRY_CHOICE=optout`; updates install without the page and write `send`, which is never -acted on). +"Send anonymous usage statistics" checkbox. + +The choice is machine-wide: `HKLM\Software\OpenCodex\Telemetry` holds `InstallerChoice` (`send` +or `optout`) and `InstallerChoiceStamp` (`[Date] [Time]` of the decision). HKLM is what lets a +silent install run as SYSTEM (Intune, SCCM) or approved by another administrator reach the people +who use the app. `OCX_TELEMETRY_CHOICE` has no default: a value passed on the command line gets a +new stamp (`OcxTelemetryStampExplicit`, before `AppSearch`); otherwise `AppSearch` reads the +previous choice and stamp and carries them (`OcxTelemetryKeepChoice`, `OcxTelemetryKeepStamp`), +which keeps an opt-out through in-app updates, which run without the page, and through the early +removal of the old product in a major upgrade; a first install defaults to `send`. The box starts +checked unless the install already carries an opt-out, and pressing Next records the box with a +new stamp. A page note says leaving the box checked keeps the current choice, because `send` is +never acted on. `desktop/src-tauri/src/installer_telemetry.rs` runs in the startup sequence before `resolve::run`, -so before the app resolves, attaches to or starts any proxy. A pending `optout` runs the bundled -`ocx telemetry disable --json` (on a fresh install it holds the decline as `pendingDecision` in -`telemetry-state.json` without creating `config.json`, so `ocx init` still runs setup) and, after a -clean exit, rewrites the value to `applied` so a later dashboard choice is never overridden. If the -decline did not persist, `sidecar::start` adds `OCX_TELEMETRY=0` to a sidecar this app starts, and -the save is retried in the background (`RETRY_DELAYS`, up to about 13 minutes) and on the next -launch; a proxy the app only attaches to follows the decline once a retry succeeds. An MSI install -puts no `ocx.exe` on PATH; the terminal command comes from this app, so the app always runs before -any proxy from that install. - -The package is per-machine (`InstallScope="perMachine"` in Tauri's template), and the choice is an -HKCU value written by the install. When a standard user elevates with a different administrator -account, the value lands in that administrator's hive and the user's first launch finds no choice; -the install stays undecided and nothing is sent until the user answers the dashboard notice. The -page also says that leaving the box checked keeps the current choice, because `send` is never acted -on and a re-install cannot read the config to pre-set the box. +so before the app resolves, attaches to or starts any proxy, with its own 10-second cap. An +`optout` whose stamp differs from this user's `HKCU\Software\OpenCodex\Telemetry\AppliedInstallerStamp` +runs the bundled `ocx telemetry disable --json` (on a fresh install it holds the decline as +`pendingDecision` in `telemetry-state.json` without creating `config.json`, so `ocx init` still +runs setup) and, after a clean exit, records the stamp for this user. Each opt-out therefore +applies once per user, and a later dashboard choice is never overridden until an install records a +new opt-out. If the decline did not persist, `sidecar::start` adds `OCX_TELEMETRY=0` to a sidecar +this app starts, and the save is retried in the background (`RETRY_DELAYS`, up to about 13 +minutes) and on the next launch; a proxy the app only attaches to follows the decline once a retry +succeeds. If the stamp could not be recorded, the next launch saves the decline again. An MSI +install puts no `ocx.exe` on PATH; the terminal command comes from this app, so the app always runs +before any proxy from that install. ### Linux packaged-shell acceptance diff --git a/tests/clients/desktop-telemetry-installer.test.ts b/tests/clients/desktop-telemetry-installer.test.ts index 30e10767d0d..0b28e8bb654 100644 --- a/tests/clients/desktop-telemetry-installer.test.ts +++ b/tests/clients/desktop-telemetry-installer.test.ts @@ -14,7 +14,8 @@ import { TELEMETRY_INSTALLER_OPT_OUT, TELEMETRY_INSTALLER_REGISTRY_KEY, TELEMETRY_INSTALLER_REGISTRY_VALUE, - TELEMETRY_INSTALLER_SEND, + TELEMETRY_INSTALLER_STAMP_PROPERTY, + TELEMETRY_INSTALLER_STAMP_VALUE, } from "../../desktop/scripts/telemetry-installer-dialog"; import { TELEMETRY_CLI_DISCLOSURE_LINES } from "../../src/cli/telemetry"; import { resolveTelemetryStatus } from "../../src/telemetry/consent"; @@ -69,18 +70,36 @@ describe("Windows installer telemetry choice", () => { test("the dialog links the policy and offers a disable option that is checked by default", () => { expect(wxs).toContain(`Read the full privacy policy`); - expect(wxs).toContain(``); expect(wxs).toMatch(new RegExp(`Type="CheckBox"[^>]*Property="${TELEMETRY_INSTALLER_CHECKBOX_PROPERTY}" CheckBoxValue="1" Text="${TELEMETRY_INSTALLER_CHECKBOX_TEXT}"`)); + // The box starts checked unless this install already carries an opt-out (command line or upgrade). + expect(wxs).toContain( + `${TELEMETRY_INSTALLER_CHOICE_PROPERTY} <> "${TELEMETRY_INSTALLER_OPT_OUT}"`, + ); // A checked box writes "send", which is never acted on, and the page says so. expect(wxs).toContain(`Text="${TELEMETRY_INSTALLER_KEEP_NOTE}"`); - // Unchecking records an opt-out; silent installs default to send and opt out with the property. - expect(wxs).toContain(``); - expect(wxs).toContain( + // Unchecking records an opt-out with a new stamp. + const next = //.exec(wxs)?.[0] ?? ""; + expect(next).toContain( `NOT (${TELEMETRY_INSTALLER_CHECKBOX_PROPERTY} = "1")`, ); - expect(wxs).toContain( - ``, - ); + expect(next).toContain(`1`); + }); + + test("the choice is machine-wide, survives upgrades, and a silent opt-out gets its own stamp", () => { + // HKLM, so a silent install run as SYSTEM (Intune, SCCM) reaches every user. + for (const name of [TELEMETRY_INSTALLER_REGISTRY_VALUE, TELEMETRY_INSTALLER_STAMP_VALUE]) { + expect(wxs).toMatch(new RegExp(``); + const order = ["OcxTelemetryStampExplicit", "OcxTelemetryKeepChoice", "OcxTelemetryKeepStamp", "OcxTelemetryDefaultChoice", "OcxTelemetryCheckBox"] + .map(action => wxs.indexOf(`Action="${action}"`)); + expect(order.every(index => index > 0)).toBe(true); + expect([...order].sort((a, b) => a - b)).toEqual(order); + expect(wxs).toContain(`Action="OcxTelemetryStampExplicit" Id="${TELEMETRY_INSTALLER_STAMP_PROPERTY}" Value="[Date] [Time]" Before="AppSearch"`); + expect(wxs).toContain(`Action="OcxTelemetryKeepChoice" Id="${TELEMETRY_INSTALLER_CHOICE_PROPERTY}" Value="[OCX_TELEMETRY_PREVIOUS_CHOICE]" After="AppSearch"`); }); test("the dialog sits between the welcome and install-directory pages of Tauri's sequence", () => { @@ -92,14 +111,19 @@ describe("Windows installer telemetry choice", () => { expect(dialog).toMatch(/ { + test("the Tauri config links the fragment and the app reads the same registry values", () => { const config = JSON.parse(readFileSync(repoPath("desktop", "src-tauri", "tauri.conf.json"), "utf8")); expect(config.bundle.windows.wix.fragmentPaths).toEqual([TELEMETRY_INSTALLER_FRAGMENT_PATH]); expect(config.bundle.windows.wix.componentGroupRefs).toEqual([TELEMETRY_INSTALLER_COMPONENT_GROUP]); expect(wxs).toContain(``); expect(rustConst("REGISTRY_KEY")).toBe(TELEMETRY_INSTALLER_REGISTRY_KEY); expect(rustConst("REGISTRY_VALUE")).toBe(TELEMETRY_INSTALLER_REGISTRY_VALUE); + expect(rustConst("STAMP_VALUE")).toBe(TELEMETRY_INSTALLER_STAMP_VALUE); expect(rustConst("OPT_OUT")).toBe(TELEMETRY_INSTALLER_OPT_OUT); + // The app reads HKLM and records the applied stamp per user under HKCU. + const module = rustCode(RUST); + expect(module).toContain("RegKey::predef(HKEY_LOCAL_MACHINE)"); + expect(module).toContain("create_subkey(super::REGISTRY_KEY)"); }); test("the app saves the opt-out before it resolves or starts a proxy, and falls back to the env opt-out", () => { @@ -114,7 +138,7 @@ describe("Windows installer telemetry choice", () => { const module = rustCode(RUST); expect(module).toContain(`.args(["telemetry", "disable", "--json"])`); // A failed save is retried in the background, so a proxy the app only attaches to follows it too. - expect(module).toMatch(/Outcome::NotPersisted => \{[\s\S]*?tauri::async_runtime::spawn\(async move \{ retry\(app\)\.await \}\)/); + expect(module).toMatch(/Outcome::NotPersisted => \{[\s\S]*?tauri::async_runtime::spawn\(async move \{ retry\(app, stamp\)\.await \}\)/); }); test("the command the app runs persists the opt-out, and nothing is sent afterwards", async () => { From 7aee5186250d2bd5572e67ebb8a8081d8282bd8b Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 20:09:10 +0900 Subject: [PATCH 21/37] fix(config,telemetry): carry validated on-disk consent through every whole-config save, and keep an unreadable state file's protection From the round-3 reviews of #8300 (all P3): - saveConfig now carries the on-disk telemetry block like the live save does, so a decline written by another process between a load and a save is never rolled back. - Both saves carry the block only when it validates; a malformed hand edit is dropped (as a load drops it) instead of being adopted into the live config, which made unrelated route validations fail. Shared helper: src/config/telemetry-carry.ts. - A writer that replaces an unreadable telemetry-state.json starts from 'nothing through today may be sent', and the status preview reports nothing eligible while every tick refuses to send. Failing-first tests in tests/config/telemetry-live-save.test.ts and tests/usage/usage-telemetry-client.test.ts. --- src/config.ts | 8 +++- src/config/live-reconcile.ts | 13 ++---- src/config/telemetry-carry.ts | 23 ++++++++++ src/telemetry/routes.ts | 9 +++- src/telemetry/state.ts | 16 +++++-- structure/telemetry.md | 5 +++ tests/config/telemetry-live-save.test.ts | 51 +++++++++++++++++++++- tests/usage/usage-telemetry-client.test.ts | 10 +++++ 8 files changed, 118 insertions(+), 17 deletions(-) create mode 100644 src/config/telemetry-carry.ts diff --git a/src/config.ts b/src/config.ts index 609326e2587..3742fd4e383 100644 --- a/src/config.ts +++ b/src/config.ts @@ -161,6 +161,8 @@ import { persistConfigUnlocked, readRawConfigJson } from "./config/persist-unloc import { withConfigMutationLockSync, bumpGenerationForCooperatingConfigWrite } from "./config/mutation-lock"; import { getDefaultConfig } from "./config/proxy-env"; import { configSchema } from "./config/schema/config-schema"; +import { adoptPersistedTelemetry } from "./config/live-reconcile"; +import { carryPersistedTelemetry } from "./config/telemetry-carry"; import { sanitizeOpenCodeOverridesForLoad } from "./config/opencode-wire-upgrade"; import { projectRemovedOpenCodeFreeSnapshot } from "./config/removed-opencode-free-backup"; import { @@ -384,12 +386,16 @@ export function saveConfig(config: OcxConfig): void { // Keep the real-home assertion ahead of even lock-directory preparation. assertNotRealHomeUnderTest(getConfigDir()); withConfigMutationLockSync(() => { + const onDisk = readRawConfigJson(); const withProvenance = projectCustomModelCatalogMigration( - readRawConfigJson(), + onDisk, projectConfigRebaseProvenance(config), ); + // Telemetry consent is owned by the file, as in the live save (see carryPersistedTelemetry). + const telemetry = carryPersistedTelemetry(withProvenance, onDisk); if (persistConfigUnlocked(withProvenance)) bumpGenerationForCooperatingConfigWrite(); adoptCustomModelCatalogMigration(config, withProvenance); + if (telemetry.carried) adoptPersistedTelemetry(config, telemetry.value); if (withProvenance.configRebaseProvenance === undefined) delete config.configRebaseProvenance; else config.configRebaseProvenance = structuredClone(withProvenance.configRebaseProvenance); clearPendingConfigDeletions(config); diff --git a/src/config/live-reconcile.ts b/src/config/live-reconcile.ts index 2b5f34a73eb..77bc917fc09 100644 --- a/src/config/live-reconcile.ts +++ b/src/config/live-reconcile.ts @@ -1,6 +1,7 @@ import { observeAccountAutoPauseRevisions, snapshotAutoPauseFingerprints } from "./account-auto-pause-revisions"; import type { OcxConfig, OcxProviderConfig, ProviderCostOverlay } from "../types"; import { configReasoningPinsConfigError } from "./provider-validation"; +import { carryPersistedTelemetry } from "./telemetry-carry"; import { adoptCustomModelCatalogMigration, projectCustomModelCatalogMigration } from "../codex/custom-model-catalog-migration"; import { refreshPreservedProviderOwner } from "../usage/user-cost-overlays"; import { refreshConfigDerivedRegistries } from "./derived-registries"; @@ -636,15 +637,9 @@ export function saveConfigPreservingClaudeCode(config: OcxConfig): void { ); if (provenanceProjection.configRebaseProvenance === undefined) delete projectedConfig.configRebaseProvenance; else projectedConfig.configRebaseProvenance = provenanceProjection.configRebaseProvenance; - // Telemetry consent is owned by the file: the CLI and the consent route write it there while a - // proxy may hold an older snapshot. A settings save must carry the decision on disk, never drop - // or roll it back. - if (onDisk !== undefined) { - const diskTelemetry = (onDisk as Partial).telemetry; - if (diskTelemetry === undefined) delete projectedConfig.telemetry; - else projectedConfig.telemetry = structuredClone(diskTelemetry); - adoptPersistedTelemetry(config, diskTelemetry); - } + // Telemetry consent is owned by the file (see carryPersistedTelemetry). + const telemetry = carryPersistedTelemetry(projectedConfig, onDisk as Record | undefined); + if (telemetry.carried) adoptPersistedTelemetry(config, telemetry.value); const persistedBinding = (bindingBaseline || diskDeletedKeys.has("hostname")) && onDisk ? readPersistedServerBinding(onDisk, bindingBaseline ?? { port: config.port, hostname: config.hostname }) : bindingBaseline; diff --git a/src/config/telemetry-carry.ts b/src/config/telemetry-carry.ts new file mode 100644 index 00000000000..ba613037c97 --- /dev/null +++ b/src/config/telemetry-carry.ts @@ -0,0 +1,23 @@ +import type { OcxConfig } from "../types"; +import { telemetryConfigSchema } from "./schema/leaf-validators"; + +/** + * Telemetry consent is owned by `config.json` on disk: `ocx telemetry`, the start prompt and the + * consent route write it there, often while another process holds an older snapshot. A whole-config + * save therefore writes the on-disk block (validated; a malformed hand edit is dropped, as a load + * would drop it) instead of the snapshot's, so a decision is never dropped or rolled back. Without a + * file on disk the snapshot's block is kept. + * + * Returns whether the block was taken from disk and the value now in `target`. + */ +export function carryPersistedTelemetry( + target: OcxConfig, + onDisk: Record | undefined, +): { carried: boolean; value: OcxConfig["telemetry"] } { + if (onDisk === undefined) return { carried: false, value: target.telemetry }; + const parsed = onDisk.telemetry === undefined ? undefined : telemetryConfigSchema.safeParse(onDisk.telemetry); + const value = parsed?.success ? (structuredClone(parsed.data) as OcxConfig["telemetry"]) : undefined; + if (value === undefined) delete target.telemetry; + else target.telemetry = value; + return { carried: true, value }; +} diff --git a/src/telemetry/routes.ts b/src/telemetry/routes.ts index 1ba32b455e0..2577af42387 100644 --- a/src/telemetry/routes.ts +++ b/src/telemetry/routes.ts @@ -21,6 +21,7 @@ import { latestCompletedUtcDay, nudgeTelemetryScheduler, readTelemetryState, + readTelemetryStateFile, recordPendingTelemetryDecision, resolveNextEligibleTelemetryDay, withPendingTelemetryDecision, @@ -62,8 +63,12 @@ export async function buildTelemetryStatusSnapshot( const configDir = options.configDir ?? getConfigDir(); const status = resolveTelemetryStatus(withPendingTelemetryDecision(config, configDir), env); const installChannel = resolveInstallChannel({ env }); - const state = readTelemetryState(configDir); - const { day: nextEligibleDay } = resolveNextEligibleTelemetryDay(status, state, now); + const stateFile = readTelemetryStateFile(configDir); + const state = stateFile.state; + // An unreadable state file blocks every send (state-unreadable), so nothing is eligible to preview. + const { day: nextEligibleDay } = stateFile.unreadable + ? { day: null } + : resolveNextEligibleTelemetryDay(status, state, now); const previewEligible = nextEligibleDay !== null; const day = nextEligibleDay ?? latestCompletedUtcDay(now); const aggregate = await previewAggregate(day, configDir, now, options.cachePreview === true); diff --git a/src/telemetry/state.ts b/src/telemetry/state.ts index e945bcc4a40..03dac3fb322 100644 --- a/src/telemetry/state.ts +++ b/src/telemetry/state.ts @@ -152,6 +152,16 @@ export function readTelemetryState(configDir = getConfigDir()): TelemetryState { return readTelemetryStateFile(configDir).state; } +/** + * Read the state for a rewrite. A file that exists but cannot be read may have held opt-out days + * or sent days, so the rewrite starts from "nothing through today may be sent". + */ +function readTelemetryStateForWrite(configDir: string, now: number): TelemetryState { + const { state, unreadable } = readTelemetryStateFile(configDir); + if (unreadable) extendIneligibleThroughToday(state, now); + return state; +} + export function writeTelemetryState(state: TelemetryState, configDir = getConfigDir(), now = Date.now()): void { mkdirSync(configDir, { recursive: true, mode: 0o700 }); const path = telemetryStatePath(configDir); @@ -182,7 +192,7 @@ export function recordPendingTelemetryDecision( configDir = getConfigDir(), now = Date.now(), ): void { - const state = readTelemetryState(configDir); + const state = readTelemetryStateForWrite(configDir, now); state.pendingDecision = structuredClone(decision); writeTelemetryState(state, configDir, now); } @@ -232,7 +242,7 @@ function extendIneligibleThroughToday(state: TelemetryState, now: number): void * passes its pid as `owner`; a one-off observation (a CLI command, a tick) passes none. */ export function recordTelemetryEnvOptOut(configDir = getConfigDir(), now = Date.now(), owner?: number): void { - const state = readTelemetryState(configDir); + const state = readTelemetryStateForWrite(configDir, now); extendIneligibleThroughToday(state, now); if (owner === undefined) { state.envOptOutActiveSince ??= new Date(now).toISOString(); @@ -251,7 +261,7 @@ export function recordTelemetryEnvOptOut(configDir = getConfigDir(), now = Date. * `ineligibleThroughDay` to today. Returns true while an opted-out proxy is still running. */ export function endTelemetryEnvOptOut(configDir = getConfigDir(), now = Date.now(), owner?: number): boolean { - const state = readTelemetryState(configDir); + const state = readTelemetryStateForWrite(configDir, now); const recorded = state.envOptOutOwners ?? []; const others = owner === undefined ? recorded : recorded.filter(entry => entry.pid !== owner); const running = others.filter(entry => isProcessAlive(entry.pid)); diff --git a/structure/telemetry.md b/structure/telemetry.md index e44ee3af428..e38ab86028d 100644 --- a/structure/telemetry.md +++ b/structure/telemetry.md @@ -49,6 +49,11 @@ Only a human can transition an undecided install to active sending: `telemetry-state.json` instead of creating the config, so `ocx init` still runs setup. `withPendingTelemetryDecision` applies it wherever consent is resolved (scheduler, status route, CLI prompt) until a decision is written into the config, which clears it. +- Telemetry consent in `config.json` is owned by the file: `carryPersistedTelemetry` + (`src/config/telemetry-carry.ts`) makes both whole-config saves (`saveConfig` and + `saveConfigPreservingClaudeCode`) write the validated on-disk block instead of the caller's + snapshot, so a decision written by another process is never dropped or rolled back, and a + malformed hand edit is dropped as a load would drop it. ## Identity, aggregation, and scheduling diff --git a/tests/config/telemetry-live-save.test.ts b/tests/config/telemetry-live-save.test.ts index c41aaccec16..330e01b7e6e 100644 --- a/tests/config/telemetry-live-save.test.ts +++ b/tests/config/telemetry-live-save.test.ts @@ -1,8 +1,8 @@ import { expect, test } from "bun:test"; -import { mkdtempSync, readFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { armClaudeCodeBaseline, getConfigPath, loadConfig, mutatePersistedConfig, saveConfig, saveConfigPreservingClaudeCode } from "../../src/config"; +import { armClaudeCodeBaseline, getConfigPath, loadConfig, mutatePersistedConfig, saveConfig, saveConfigPreservingClaudeCode, validateConfigCandidate } from "../../src/config"; import type { OcxConfig, OcxTelemetryConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -59,3 +59,50 @@ test("a live save never rolls a newer decline on disk back to the accept it load expect(saved.telemetry).toEqual(declined); expect(live.telemetry).toEqual(declined); }); + +test("a live save drops a malformed telemetry block instead of adopting it", () => { + const oldHome = process.env.OPENCODEX_HOME; + const home = mkdtempSync(join(tmpdir(), "ocx-telemetry-live-save-")); + process.env.OPENCODEX_HOME = home; + try { + saveConfig(seed()); + const live = loadConfig(); + armClaudeCodeBaseline(live); + // A hand edit leaves an invalid block on disk; a load would drop it, and so must a save. + const raw = JSON.parse(readFileSync(getConfigPath(), "utf8")) as Record; + raw.telemetry = { decision: "nope", extra: 1 }; + writeFileSync(getConfigPath(), JSON.stringify(raw, null, 2) + "\n", "utf8"); + live.logLevel = "debug"; + saveConfigPreservingClaudeCode(live); + expect(live.telemetry).toBeUndefined(); + expect((JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig).telemetry).toBeUndefined(); + expect(validateConfigCandidate(live).ok).toBe(true); + } finally { + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldHome; + removeTreeWithRetry(home); + } +}); + +test("a whole-config save from a stale snapshot keeps a newer decline on disk", () => { + const oldHome = process.env.OPENCODEX_HOME; + const home = mkdtempSync(join(tmpdir(), "ocx-telemetry-live-save-")); + process.env.OPENCODEX_HOME = home; + try { + saveConfig(seed(accepted)); + const stale = loadConfig(); + // Another process (`ocx telemetry disable`) declines between this load and save. + expect(mutatePersistedConfig(config => { + config.telemetry = structuredClone(declined); + return { changed: true, value: null }; + }).status).toBe("committed"); + stale.logLevel = "debug"; + saveConfig(stale); + expect((JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig).telemetry).toEqual(declined); + expect(stale.telemetry).toEqual(declined); + } finally { + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldHome; + removeTreeWithRetry(home); + } +}); diff --git a/tests/usage/usage-telemetry-client.test.ts b/tests/usage/usage-telemetry-client.test.ts index 1e3db899269..017648cbadf 100644 --- a/tests/usage/usage-telemetry-client.test.ts +++ b/tests/usage/usage-telemetry-client.test.ts @@ -36,12 +36,14 @@ import { telemetryIdPath, } from "../../src/telemetry/install-id"; import { buildTelemetryPayloadV1, normalizeTelemetryVersion, resolveInstallChannel } from "../../src/telemetry/payload"; +import { buildTelemetryStatusSnapshot } from "../../src/telemetry/routes"; import { computeTelemetryIntervalDelayMs, computeTelemetryStartupDelayMs, nudgeTelemetryScheduler, endTelemetryEnvOptOut, readTelemetryState, + recordPendingTelemetryDecision, recordTelemetryEnvOptOut, runTelemetryTick, startTelemetryScheduler, @@ -864,6 +866,14 @@ describe("install identity, aggregation, disjoint tokens, and scheduler", () => writeFileSync(telemetryStatePath(home), "{ not json", "utf8"); expect(await runTelemetryTick({ configDir: home, config, env: {}, now: NOW, fetch: mockFetch })).toEqual({ sent: false, reason: "state-unreadable" }); expect(fetchCalls).toBe(0); + // The status shows nothing eligible while every tick refuses to send. + expect((await buildTelemetryStatusSnapshot(config, { configDir: home, env: {}, now: NOW })).previewEligible).toBe(false); + // A writer that replaces the unreadable file keeps every day through today unsent. + recordTelemetryEnvOptOut(home, NOW); + expect(readTelemetryState(home).ineligibleThroughDay).toBe("2026-11-07"); + writeFileSync(telemetryStatePath(home), "{ not json", "utf8"); + recordPendingTelemetryDecision({ decision: "declined", decidedAt: "2026-11-07T10:00:00.000Z", noticeShownAt: "2026-11-07T10:00:00.000Z" }, home, NOW); + expect(readTelemetryState(home).ineligibleThroughDay).toBe("2026-11-07"); }); test("a telemetry-id.json deleted by another process is not served from memory", () => { From bc5cbe85a54334405fef4a75a9b5054d47a0cc03 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 20:18:17 +0900 Subject: [PATCH 22/37] fix(desktop): keep a carried installer choice's stamp, compare optout case-insensitively, and honour a per-user opt-out - WiX: OcxTelemetryStampExplicit only stamps while no stamp is set, so the stamp the UI phase hands to the install phase is never replaced and a reduced-UI (/qr) run keeps a carried opt-out's old stamp; the checkbox compares optout with ~<>; the WelcomeDlg/InstallDirDlg overrides apply only when NOT Installed, leaving the patch route alone. - Bridge: registry access goes through an InstallerRegistry trait. HKLM and HKCU opt-outs are both honoured, each with its own applied stamp under HKCU; send from any scope is never acted on. Unit tests drive it with an in-memory registry (precedence, applied stamps, send never flipping a decline, failed persist/mark). - Test: evaluates the fragment's conditions through both MSI phases for fresh, silent, reduced-UI and upgrade installs (5 cases fail on the previous fragment). --- desktop/scripts/telemetry-installer-dialog.ts | 14 +- desktop/src-tauri/src/installer_telemetry.rs | 488 ++++++++++++------ desktop/src-tauri/wix/telemetry-choice.wxs | 14 +- .../src/content/docs/guides/desktop-app.md | 11 +- structure/desktop-shell.md | 54 +- .../desktop-telemetry-installer.test.ts | 160 +++++- 6 files changed, 546 insertions(+), 195 deletions(-) diff --git a/desktop/scripts/telemetry-installer-dialog.ts b/desktop/scripts/telemetry-installer-dialog.ts index 8427b99a05f..d492ae9836d 100644 --- a/desktop/scripts/telemetry-installer-dialog.ts +++ b/desktop/scripts/telemetry-installer-dialog.ts @@ -114,13 +114,14 @@ export function renderTelemetryInstallerFragment(): string { - - ${TELEMETRY_INSTALLER_CHOICE_PROPERTY} + + ${TELEMETRY_INSTALLER_CHOICE_PROPERTY} AND NOT ${TELEMETRY_INSTALLER_STAMP_PROPERTY} NOT ${TELEMETRY_INSTALLER_CHOICE_PROPERTY} AND OCX_TELEMETRY_PREVIOUS_CHOICE NOT ${TELEMETRY_INSTALLER_STAMP_PROPERTY} AND OCX_TELEMETRY_PREVIOUS_STAMP NOT ${TELEMETRY_INSTALLER_CHOICE_PROPERTY} - ${TELEMETRY_INSTALLER_CHOICE_PROPERTY} <> "${TELEMETRY_INSTALLER_OPT_OUT}" + ${TELEMETRY_INSTALLER_CHOICE_PROPERTY} ~<> "${TELEMETRY_INSTALLER_OPT_OUT}" @@ -154,9 +155,10 @@ ${controls.join("\n")} 1 - - 1 - 1 + + NOT Installed + NOT Installed diff --git a/desktop/src-tauri/src/installer_telemetry.rs b/desktop/src-tauri/src/installer_telemetry.rs index c9ffa152e1a..965a8dead69 100644 --- a/desktop/src-tauri/src/installer_telemetry.rs +++ b/desktop/src-tauri/src/installer_telemetry.rs @@ -5,21 +5,24 @@ //! `desktop/src-tauri/wix/telemetry-choice.wxs` records the choice machine-wide under //! `HKLM\Software\OpenCodex\Telemetry`: `InstallerChoice` (`send` or `optout`) and //! `InstallerChoiceStamp` (when it was chosen). HKLM is what makes a silent install run as SYSTEM -//! (Intune, SCCM) reach the people who later use the app; an upgrade carries the previous choice and -//! stamp forward. +//! (Intune, SCCM) or approved by another administrator reach the people who later use the app; an +//! upgrade carries the previous choice and stamp forward. The same two values under the user's own +//! `HKCU\Software\OpenCodex\Telemetry` are honoured too, as a per-user choice. //! //! Before the app resolves, attaches to or starts a proxy, this module carries an opt-out into the //! current user's opencodex config by running the bundled `ocx telemetry disable`, which persists -//! `telemetry.decision = "declined"` (on a fresh install it is held without creating `config.json`). -//! The applied stamp is then recorded in that user's `HKCU\Software\OpenCodex\Telemetry`, so each -//! opt-out applies once per user and a later dashboard choice is never overridden. A new install that -//! carries an opt-out has a new stamp and applies again; that is a person or an administrator -//! choosing again. +//! `telemetry.decision = "declined"` (on a fresh install it is held as `pendingDecision` without +//! creating `config.json`). It then records the applied stamp in that user's HKCU, one value per +//! scope, so each opt-out applies once per user and a later dashboard choice is not overridden until +//! an install records a new opt-out. `send` is never acted on, and this module has no path that +//! enables telemetry, so no installer choice can turn a decline back on. //! //! When the decline could not be persisted, a sidecar this app starts runs with //! `OCX_TELEMETRY=0`, and the save is retried in the background (`RETRY_DELAYS`) and again on the //! next launch. A proxy this app only attaches to was started elsewhere with its own environment; -//! it follows the saved decline once a retry succeeds (it re-reads the config on every tick). +//! it follows the saved decline once a retry succeeds (it re-reads the config on every tick). If a +//! decline was saved but its stamp could not be recorded, the next launch saves it again, which can +//! override a dashboard enable made in between; that errs toward privacy. //! //! An MSI install does not put `ocx.exe` on PATH; the terminal command is installed by this app. //! The app therefore runs before any proxy from that install can. @@ -37,9 +40,10 @@ pub const REGISTRY_KEY: &str = r"Software\OpenCodex\Telemetry"; pub const REGISTRY_VALUE: &str = "InstallerChoice"; #[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub const STAMP_VALUE: &str = "InstallerChoiceStamp"; -/// Written by this app under HKCU: the stamp of the last installer opt-out applied for this user. -#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +/// Written by this app under HKCU: the stamp of the last machine-wide opt-out applied for this user. pub const APPLIED_STAMP_VALUE: &str = "AppliedInstallerStamp"; +/// Written by this app under HKCU: the stamp of the last per-user opt-out applied. +pub const APPLIED_USER_STAMP_VALUE: &str = "AppliedUserInstallerStamp"; pub const OPT_OUT: &str = "optout"; static SIDECAR_ENV_OPT_OUT: AtomicBool = AtomicBool::new(false); @@ -55,39 +59,75 @@ pub const RETRY_DELAYS: [Duration; 4] = [ ]; const RETRY_TIMEOUT: Duration = Duration::from_secs(30); -/// The machine-wide choice the installer recorded. +/// Where an installer choice was recorded. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Scope { + /// `HKLM`: every account on the computer. + Machine, + /// `HKCU`: this account only. + User, +} + +impl Scope { + /// The HKCU value this app writes once an opt-out from this scope is applied. + pub fn applied_value(self) -> &'static str { + match self { + Scope::Machine => APPLIED_STAMP_VALUE, + Scope::User => APPLIED_USER_STAMP_VALUE, + } + } +} + +/// A choice the installer recorded. #[derive(Clone, Debug, PartialEq, Eq)] pub struct InstallerChoice { + pub scope: Scope, pub choice: String, pub stamp: String, } +/// Registry access, injected so the decision logic is tested without Windows. +pub trait InstallerRegistry { + /// The choices the installer recorded, in any order. + fn choices(&self) -> Vec; + /// The stamp this app last applied for `scope` in this account. + fn applied_stamp(&self, scope: Scope) -> Option; + /// Record that the opt-out with `stamp` from `scope` is applied in this account. + fn mark_applied(&self, scope: Scope, stamp: &str) -> bool; +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Outcome { /// No opt-out is pending for this user: no choice, `send`, or one already applied. NothingPending, - /// The decline is in the user's config and its stamp is recorded for this user. + /// The decline is in the user's config and every pending stamp is recorded. Applied, - /// The decline is in the config, but the stamp could not be recorded; the next launch re-applies it. + /// The decline is in the config, but a stamp could not be recorded; the next launch re-applies it. AppliedUnmarked, /// The decline could not be persisted; a sidecar this app starts runs with `OCX_TELEMETRY=0`. NotPersisted, } -/// The stamp of an installer opt-out this user has not applied yet. -pub fn pending_stamp<'a>( - choice: Option<&'a InstallerChoice>, - applied: Option<&str>, -) -> Option<&'a str> { - let choice = choice?; - if !choice.choice.trim().eq_ignore_ascii_case(OPT_OUT) { - return None; - } - let stamp = choice.stamp.trim(); - if applied.map(str::trim) == Some(stamp) { - return None; - } - Some(stamp) +fn is_opt_out(choice: &str) -> bool { + choice.trim().eq_ignore_ascii_case(OPT_OUT) +} + +/// The opt-outs this account has not applied yet, machine-wide first. `send` is never pending. +pub fn pending_opt_outs(registry: &R) -> Vec { + let mut pending: Vec = registry + .choices() + .into_iter() + .filter(|choice| is_opt_out(&choice.choice)) + .filter(|choice| { + registry + .applied_stamp(choice.scope) + .as_deref() + .map(str::trim) + != Some(choice.stamp.trim()) + }) + .collect(); + pending.sort_by_key(|choice| choice.scope != Scope::Machine); + pending } /// `ocx telemetry disable` exits 0 only once the decline is persisted. @@ -95,19 +135,24 @@ pub fn decline_persisted(exit_code: Option) -> bool { exit_code == Some(0) } -/// Decide what a pending opt-out leads to, given how persisting and recording its stamp went. -pub fn settle( - pending: Option<&str>, +/// Apply the pending opt-outs: one persisted decline covers all of them, and only then is each +/// stamp recorded. +pub fn settle( + registry: &R, + pending: &[InstallerChoice], persist: impl FnOnce() -> bool, - mark_applied: impl FnOnce(&str) -> bool, ) -> Outcome { - let Some(stamp) = pending else { + if pending.is_empty() { return Outcome::NothingPending; - }; + } if !persist() { return Outcome::NotPersisted; } - if mark_applied(stamp) { + let mut all_marked = true; + for choice in pending { + all_marked &= registry.mark_applied(choice.scope, choice.stamp.trim()); + } + if all_marked { Outcome::Applied } else { Outcome::AppliedUnmarked @@ -121,14 +166,14 @@ pub fn sidecar_env_opt_out() -> bool { /// Carry a pending installer opt-out into the current user's opencodex config. pub async fn apply(app: &AppHandle, deadline: Instant) -> Outcome { - let choice = os::read_choice(); - let applied = os::read_applied_stamp(); - let pending = pending_stamp(choice.as_ref(), applied.as_deref()).map(str::to_owned); - let persisted = match pending { - Some(_) => disable(app, deadline.min(Instant::now() + STEP_CAP)).await, - None => false, + let registry = os::SystemRegistry; + let pending = pending_opt_outs(®istry); + let persisted = if pending.is_empty() { + false + } else { + disable(app, deadline.min(Instant::now() + STEP_CAP)).await }; - let outcome = settle(pending.as_deref(), || persisted, os::mark_applied); + let outcome = settle(®istry, &pending, || persisted); match outcome { Outcome::NotPersisted => { SIDECAR_ENV_OPT_OUT.store(true, Ordering::SeqCst); @@ -136,10 +181,8 @@ pub async fn apply(app: &AppHandle, deadline: Instant) -> Outcome { "the installer's telemetry opt-out could not be saved", "a proxy this app starts runs with OCX_TELEMETRY=0; saving is retried in the background", ); - if let Some(stamp) = pending { - let app = app.clone(); - tauri::async_runtime::spawn(async move { retry(app, stamp).await }); - } + let app = app.clone(); + tauri::async_runtime::spawn(async move { retry(app, pending).await }); } Outcome::AppliedUnmarked => crate::logging::log_once( "the installer's telemetry opt-out was saved but not recorded as applied", @@ -150,11 +193,11 @@ pub async fn apply(app: &AppHandle, deadline: Instant) -> Outcome { outcome } -async fn retry(app: AppHandle, stamp: String) { +async fn retry(app: AppHandle, pending: Vec) { for delay in RETRY_DELAYS { tokio::time::sleep(delay).await; if disable(&app, Instant::now() + RETRY_TIMEOUT).await { - if !os::mark_applied(&stamp) { + if settle(&os::SystemRegistry, &pending, || true) == Outcome::AppliedUnmarked { crate::logging::log_once( "the installer's telemetry opt-out was saved but not recorded as applied", "the next launch saves the decline again", @@ -187,154 +230,291 @@ async fn disable(app: &AppHandle, deadline: Instant) -> bool { #[cfg(target_os = "windows")] mod os { + use super::{InstallerChoice, InstallerRegistry, Scope}; use winreg::enums::{ HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, KEY_READ, KEY_WOW64_32KEY, KEY_WOW64_64KEY, }; use winreg::RegKey; - /// The installer's machine-wide choice, from the 64-bit view first and the 32-bit view second. - pub fn read_choice() -> Option { - for view in [KEY_WOW64_64KEY, KEY_WOW64_32KEY] { - let Ok(key) = RegKey::predef(HKEY_LOCAL_MACHINE) - .open_subkey_with_flags(super::REGISTRY_KEY, KEY_READ | view) - else { - continue; - }; - if let Ok(choice) = key.get_value::(super::REGISTRY_VALUE) { - let stamp = key - .get_value::(super::STAMP_VALUE) - .unwrap_or_default(); - return Some(super::InstallerChoice { choice, stamp }); + pub struct SystemRegistry; + + fn read(key: &RegKey, scope: Scope) -> Option { + let choice = key.get_value::(super::REGISTRY_VALUE).ok()?; + let stamp = key + .get_value::(super::STAMP_VALUE) + .unwrap_or_default(); + Some(InstallerChoice { + scope, + choice, + stamp, + }) + } + + impl InstallerRegistry for SystemRegistry { + fn choices(&self) -> Vec { + let mut choices = Vec::new(); + // The 64-bit view first, the 32-bit view second. + for view in [KEY_WOW64_64KEY, KEY_WOW64_32KEY] { + if let Some(choice) = RegKey::predef(HKEY_LOCAL_MACHINE) + .open_subkey_with_flags(super::REGISTRY_KEY, KEY_READ | view) + .ok() + .and_then(|key| read(&key, Scope::Machine)) + { + choices.push(choice); + break; + } } + if let Some(choice) = RegKey::predef(HKEY_CURRENT_USER) + .open_subkey_with_flags(super::REGISTRY_KEY, KEY_READ) + .ok() + .and_then(|key| read(&key, Scope::User)) + { + choices.push(choice); + } + choices } - None - } - pub fn read_applied_stamp() -> Option { - RegKey::predef(HKEY_CURRENT_USER) - .open_subkey_with_flags(super::REGISTRY_KEY, KEY_READ) - .ok()? - .get_value::(super::APPLIED_STAMP_VALUE) - .ok() - } + fn applied_stamp(&self, scope: Scope) -> Option { + RegKey::predef(HKEY_CURRENT_USER) + .open_subkey_with_flags(super::REGISTRY_KEY, KEY_READ) + .ok()? + .get_value::(scope.applied_value()) + .ok() + } - pub fn mark_applied(stamp: &str) -> bool { - RegKey::predef(HKEY_CURRENT_USER) - .create_subkey(super::REGISTRY_KEY) - .and_then(|(key, _)| key.set_value(super::APPLIED_STAMP_VALUE, &stamp.to_owned())) - .is_ok() + fn mark_applied(&self, scope: Scope, stamp: &str) -> bool { + RegKey::predef(HKEY_CURRENT_USER) + .create_subkey(super::REGISTRY_KEY) + .and_then(|(key, _)| key.set_value(scope.applied_value(), &stamp.to_owned())) + .is_ok() + } } } #[cfg(not(target_os = "windows"))] mod os { - // Only the Windows installer records a choice. - pub fn read_choice() -> Option { - None - } + use super::{InstallerChoice, InstallerRegistry, Scope}; - pub fn read_applied_stamp() -> Option { - None - } + /// Only the Windows installer records a choice. + pub struct SystemRegistry; - pub fn mark_applied(_stamp: &str) -> bool { - true + impl InstallerRegistry for SystemRegistry { + fn choices(&self) -> Vec { + Vec::new() + } + + fn applied_stamp(&self, _scope: Scope) -> Option { + None + } + + fn mark_applied(&self, _scope: Scope, _stamp: &str) -> bool { + true + } } } #[cfg(test)] mod tests { - use super::{decline_persisted, pending_stamp, settle, InstallerChoice, Outcome}; - use std::cell::Cell; + use super::{ + decline_persisted, pending_opt_outs, settle, InstallerChoice, InstallerRegistry, Outcome, + Scope, + }; + use std::cell::{Cell, RefCell}; + use std::collections::HashMap; + + /// An in-memory registry: the installer's choices plus this account's applied stamps. + #[derive(Default)] + struct FakeRegistry { + choices: Vec, + applied: RefCell>, + refuse_marks: bool, + } + + impl FakeRegistry { + fn with(choices: &[(Scope, &str, &str)]) -> Self { + Self { + choices: choices + .iter() + .map(|(scope, choice, stamp)| InstallerChoice { + scope: *scope, + choice: (*choice).to_owned(), + stamp: (*stamp).to_owned(), + }) + .collect(), + ..Self::default() + } + } + + fn applied(&self, scope: Scope) -> Option { + self.applied.borrow().get(scope.applied_value()).cloned() + } + + fn set_applied(&self, scope: Scope, stamp: &str) { + self.applied + .borrow_mut() + .insert(scope.applied_value(), stamp.to_owned()); + } + } + + impl InstallerRegistry for FakeRegistry { + fn choices(&self) -> Vec { + self.choices.clone() + } + + fn applied_stamp(&self, scope: Scope) -> Option { + self.applied(scope) + } - fn choice(choice: &str, stamp: &str) -> InstallerChoice { - InstallerChoice { - choice: choice.to_owned(), - stamp: stamp.to_owned(), + fn mark_applied(&self, scope: Scope, stamp: &str) -> bool { + if self.refuse_marks { + return false; + } + self.set_applied(scope, stamp); + true } } + /// Run one launch: returns the outcome and whether a decline was persisted. + fn launch(registry: &FakeRegistry, persist_ok: bool) -> (Outcome, bool) { + let persisted = Cell::new(false); + let pending = pending_opt_outs(registry); + let outcome = settle(registry, &pending, || { + persisted.set(true); + persist_ok + }); + (outcome, persisted.get()) + } + #[test] - fn an_opt_out_is_pending_until_its_stamp_is_applied_for_this_user() { - let opt_out = choice("optout", "11/8/2026 10:12:33"); + fn a_machine_wide_opt_out_applies_once_per_account() { + let registry = FakeRegistry::with(&[(Scope::Machine, "optout", "11/8/2026 10:12:33")]); + assert_eq!(launch(®istry, true), (Outcome::Applied, true)); assert_eq!( - pending_stamp(Some(&opt_out), None), + registry.applied(Scope::Machine).as_deref(), Some("11/8/2026 10:12:33") ); - assert_eq!( - pending_stamp(Some(&choice(" OptOut ", "s1")), Some("s0")), - Some("s1") - ); - assert_eq!( - pending_stamp(Some(&opt_out), Some("11/8/2026 10:12:33")), - None - ); - for other in [ - choice("send", "s1"), - choice("", "s1"), - choice("opt-out-later", "s1"), - ] { - assert_eq!(pending_stamp(Some(&other), None), None, "{other:?}"); - } - assert_eq!(pending_stamp(None, None), None); + // The next launch finds the stamp applied and does nothing, so a later dashboard enable stays. + assert_eq!(launch(®istry, true), (Outcome::NothingPending, false)); } #[test] - fn only_a_clean_exit_counts_as_persisted() { - assert!(decline_persisted(Some(0))); - for code in [None, Some(1), Some(2), Some(-1)] { - assert!(!decline_persisted(code), "{code:?}"); - } + fn a_new_stamp_from_a_later_install_applies_again() { + let registry = FakeRegistry::with(&[(Scope::Machine, "optout", "s2")]); + registry.set_applied(Scope::Machine, "s1"); + assert_eq!(launch(®istry, true), (Outcome::Applied, true)); + assert_eq!(registry.applied(Scope::Machine).as_deref(), Some("s2")); } #[test] - fn nothing_runs_without_a_pending_opt_out() { - let ran = Cell::new(false); - let outcome = settle( - None, - || { - ran.set(true); - true - }, - |_| { - ran.set(true); - true - }, - ); - assert_eq!(outcome, Outcome::NothingPending); - assert!(!ran.get()); + fn send_from_any_scope_never_acts_so_a_decline_is_never_flipped() { + for choices in [ + vec![(Scope::Machine, "send", "s1")], + vec![(Scope::User, "send", "s1")], + vec![(Scope::Machine, "send", "s1"), (Scope::User, "SEND", "s2")], + vec![(Scope::Machine, "", "s1"), (Scope::User, "off", "s2")], + ] { + let registry = FakeRegistry::with(&choices); + // Nothing runs: the bridge only ever runs `ocx telemetry disable`, and here not even that. + assert_eq!( + launch(®istry, true), + (Outcome::NothingPending, false), + "{choices:?}" + ); + assert_eq!(registry.applied(Scope::Machine), None); + assert_eq!(registry.applied(Scope::User), None); + } } #[test] - fn a_stamp_is_recorded_only_after_the_decline_persisted() { - let marked = Cell::new(None::); + fn machine_and_per_user_opt_outs_are_both_honoured_machine_first() { + let registry = FakeRegistry::with(&[ + (Scope::User, "optout", "u1"), + (Scope::Machine, " OptOut ", "m1"), + ]); + let pending = pending_opt_outs(®istry); assert_eq!( - settle( - Some("s1"), - || false, - |stamp| { - marked.set(Some(stamp.to_owned())); - true - } - ), - Outcome::NotPersisted - ); - assert_eq!(marked.take(), None); - assert_eq!( - settle( - Some("s1"), - || true, - |stamp| { - marked.set(Some(stamp.to_owned())); - true - } - ), - Outcome::Applied + pending + .iter() + .map(|choice| choice.scope) + .collect::>(), + vec![Scope::Machine, Scope::User] ); - assert_eq!(marked.take().as_deref(), Some("s1")); + // One persisted decline covers both, and both stamps are recorded. + assert_eq!(launch(®istry, true), (Outcome::Applied, true)); + assert_eq!(registry.applied(Scope::Machine).as_deref(), Some("m1")); + assert_eq!(registry.applied(Scope::User).as_deref(), Some("u1")); + } + + #[test] + fn a_later_install_recording_send_leaves_an_applied_decline_alone() { + // An opt-out from an earlier install was applied for this account ... + let earlier = FakeRegistry::with(&[(Scope::Machine, "optout", "s1")]); + assert_eq!(launch(&earlier, true), (Outcome::Applied, true)); + // ... then an upgrade where someone checked the box records send with a new stamp. + let later = FakeRegistry::with(&[(Scope::Machine, "send", "s2")]); + later.set_applied(Scope::Machine, "s1"); + // Nothing runs, so the saved decline stays, and the applied stamp is not touched. + assert_eq!(launch(&later, true), (Outcome::NothingPending, false)); + assert_eq!(later.applied(Scope::Machine).as_deref(), Some("s1")); + } + + #[test] + fn a_per_user_opt_out_applies_when_the_machine_choice_is_send() { + let registry = FakeRegistry::with(&[ + (Scope::Machine, "send", "m1"), + (Scope::User, "optout", "u1"), + ]); + assert_eq!(launch(®istry, true), (Outcome::Applied, true)); + assert_eq!(registry.applied(Scope::Machine), None); + assert_eq!(registry.applied(Scope::User).as_deref(), Some("u1")); + } + + #[test] + fn an_applied_machine_stamp_does_not_hide_a_new_per_user_opt_out() { + let registry = FakeRegistry::with(&[ + (Scope::Machine, "optout", "m1"), + (Scope::User, "optout", "u1"), + ]); + registry.set_applied(Scope::Machine, "m1"); + let pending = pending_opt_outs(®istry); assert_eq!( - settle(Some("s1"), || true, |_| false), - Outcome::AppliedUnmarked + pending + .iter() + .map(|choice| choice.scope) + .collect::>(), + vec![Scope::User] ); } + + #[test] + fn a_stamp_is_recorded_only_after_the_decline_persisted() { + let registry = FakeRegistry::with(&[(Scope::Machine, "optout", "m1")]); + assert_eq!(launch(®istry, false), (Outcome::NotPersisted, true)); + assert_eq!(registry.applied(Scope::Machine), None); + // The next launch tries again. + assert_eq!(launch(®istry, true), (Outcome::Applied, true)); + } + + #[test] + fn an_unrecorded_stamp_is_reported_and_retried_next_launch() { + let mut registry = FakeRegistry::with(&[(Scope::Machine, "optout", "m1")]); + registry.refuse_marks = true; + assert_eq!(launch(®istry, true), (Outcome::AppliedUnmarked, true)); + registry.refuse_marks = false; + assert_eq!(launch(®istry, true), (Outcome::Applied, true)); + } + + #[test] + fn no_choice_at_all_runs_nothing() { + let registry = FakeRegistry::default(); + assert_eq!(launch(®istry, true), (Outcome::NothingPending, false)); + } + + #[test] + fn only_a_clean_exit_counts_as_persisted() { + assert!(decline_persisted(Some(0))); + for code in [None, Some(1), Some(2), Some(-1)] { + assert!(!decline_persisted(code), "{code:?}"); + } + } } diff --git a/desktop/src-tauri/wix/telemetry-choice.wxs b/desktop/src-tauri/wix/telemetry-choice.wxs index cc13b3df22a..0ddcff03bbd 100644 --- a/desktop/src-tauri/wix/telemetry-choice.wxs +++ b/desktop/src-tauri/wix/telemetry-choice.wxs @@ -18,13 +18,14 @@ - - OCX_TELEMETRY_CHOICE + + OCX_TELEMETRY_CHOICE AND NOT OCX_TELEMETRY_STAMP NOT OCX_TELEMETRY_CHOICE AND OCX_TELEMETRY_PREVIOUS_CHOICE NOT OCX_TELEMETRY_STAMP AND OCX_TELEMETRY_PREVIOUS_STAMP NOT OCX_TELEMETRY_CHOICE - OCX_TELEMETRY_CHOICE <> "optout" + OCX_TELEMETRY_CHOICE ~<> "optout" @@ -62,9 +63,10 @@ 1
- - 1 - 1 + + NOT Installed + NOT Installed diff --git a/docs-site/src/content/docs/guides/desktop-app.md b/docs-site/src/content/docs/guides/desktop-app.md index 3310795b3a2..2e172dd9143 100644 --- a/docs-site/src/content/docs/guides/desktop-app.md +++ b/docs-site/src/content/docs/guides/desktop-app.md @@ -40,10 +40,15 @@ administrator or a deployment tool (Intune, SCCM). For a silent install, pass msiexec /i OpenCodex--windows-x64.msi /qn OCX_TELEMETRY_CHOICE=optout ``` +The installer needs administrator rights and records the choice for the whole computer. +`optout` is matched in any letter case; any other value counts as `send`. + Leaving the box checked does not start sending by itself: the dashboard still shows its one-time -notice first. In-app updates install without this page and keep an earlier opt-out. Running a -newer installer by hand shows the page again, unchecked if you opted out before. If you have since -turned telemetry on in the dashboard and want to keep it, check the box before you continue. +notice first, and a checked box never turns telemetry back on for an account that turned it off. +In-app updates, reduced-UI installs (`/qr`) and maintenance runs of the installed version skip this +page and keep the recorded choice. Running a newer installer by hand shows the page again, unchecked if you opted out +before. If you have since turned telemetry on in the dashboard and want to keep it, check the box +before you continue. ### Linux diff --git a/structure/desktop-shell.md b/structure/desktop-shell.md index 00c95186052..78944a7d555 100644 --- a/structure/desktop-shell.md +++ b/structure/desktop-shell.md @@ -371,33 +371,45 @@ policy during installation and to include an installation option that disables i CLI prompt prints, and `tests/clients/desktop-telemetry-installer.test.ts` fails when the committed file is stale. It inserts `OcxTelemetryDlg` between Tauri's `WelcomeDlg` and `InstallDirDlg` (`Order="3"` overrides the template's `Order="2"` route) with the disclosure, a policy link and a -"Send anonymous usage statistics" checkbox. +"Send anonymous usage statistics" checkbox. Both route overrides are conditioned on `NOT Installed`, +so an installed product's patch route (`WelcomeDlg` to `VerifyReadyDlg`) stays as Tauri wrote it. The choice is machine-wide: `HKLM\Software\OpenCodex\Telemetry` holds `InstallerChoice` (`send` or `optout`) and `InstallerChoiceStamp` (`[Date] [Time]` of the decision). HKLM is what lets a silent install run as SYSTEM (Intune, SCCM) or approved by another administrator reach the people -who use the app. `OCX_TELEMETRY_CHOICE` has no default: a value passed on the command line gets a -new stamp (`OcxTelemetryStampExplicit`, before `AppSearch`); otherwise `AppSearch` reads the -previous choice and stamp and carries them (`OcxTelemetryKeepChoice`, `OcxTelemetryKeepStamp`), -which keeps an opt-out through in-app updates, which run without the page, and through the early -removal of the old product in a major upgrade; a first install defaults to `send`. The box starts -checked unless the install already carries an opt-out, and pressing Next records the box with a -new stamp. A page note says leaving the box checked keeps the current choice, because `send` is -never acted on. +who use the app; Tauri's MSI is per-machine and needs elevation, so there is no per-user install. +`OCX_TELEMETRY_CHOICE` has no default. A value passed on the command line gets a new stamp +(`OcxTelemetryStampExplicit`, before `AppSearch`), but only while no stamp is set, so the stamp the +UI phase hands to the install phase is never replaced. Otherwise `AppSearch` reads the previous +choice and stamp and carries them (`OcxTelemetryKeepChoice`, `OcxTelemetryKeepStamp`). That keeps +an opt-out, with its old stamp, through in-app updates (`/passive`, no UI sequence), reduced-UI +(`/qr`) and maintenance runs, and the early removal of the old product in a major upgrade; a first +install defaults to `send`. The box starts checked unless the install already carries an opt-out +(compared case-insensitively with `~<>`), and pressing Next records the box with a new stamp. A +page note says leaving the box checked keeps the current choice, because `send` is never acted on. +The installer test evaluates the fragment's conditions through both MSI phases for fresh, silent, +reduced-UI and upgrade installs. `desktop/src-tauri/src/installer_telemetry.rs` runs in the startup sequence before `resolve::run`, -so before the app resolves, attaches to or starts any proxy, with its own 10-second cap. An -`optout` whose stamp differs from this user's `HKCU\Software\OpenCodex\Telemetry\AppliedInstallerStamp` -runs the bundled `ocx telemetry disable --json` (on a fresh install it holds the decline as -`pendingDecision` in `telemetry-state.json` without creating `config.json`, so `ocx init` still -runs setup) and, after a clean exit, records the stamp for this user. Each opt-out therefore -applies once per user, and a later dashboard choice is never overridden until an install records a -new opt-out. If the decline did not persist, `sidecar::start` adds `OCX_TELEMETRY=0` to a sidecar -this app starts, and the save is retried in the background (`RETRY_DELAYS`, up to about 13 -minutes) and on the next launch; a proxy the app only attaches to follows the decline once a retry -succeeds. If the stamp could not be recorded, the next launch saves the decline again. An MSI -install puts no `ocx.exe` on PATH; the terminal command comes from this app, so the app always runs -before any proxy from that install. +so before the app resolves, attaches to or starts any proxy, with its own 10-second cap. It reads +the HKLM choice (64-bit view, then 32-bit) and the same two values under the user's own +`HKCU\Software\OpenCodex\Telemetry`, which an administrator can set for one account. An `optout` +in any letter case is pending when its stamp differs from the one this user last applied for that +scope: `AppliedInstallerStamp` for HKLM and `AppliedUserInstallerStamp` for HKCU, both written under +the user's HKCU key. Pending opt-outs run the bundled `ocx telemetry disable --json` once (on a +fresh install it holds the decline as `pendingDecision` in `telemetry-state.json` without creating +`config.json`, so `ocx init` still runs setup) and, after a clean exit, each pending stamp is +recorded for this user. Each opt-out therefore applies once per user, and a later dashboard choice +is not overridden until an install records a new opt-out. `send` from either scope is never acted +on, and the module has no path that enables telemetry, so no installer value turns a saved decline +back on. Registry access goes through the `InstallerRegistry` trait, which the module's unit tests +replace with an in-memory registry. If the decline did not persist, `sidecar::start` adds +`OCX_TELEMETRY=0` to a sidecar this app starts, and the save is retried in the background +(`RETRY_DELAYS`, up to about 13 minutes) and on the next launch; a proxy the app only attaches to +follows the decline once a retry succeeds. If the stamp could not be recorded, the next launch saves +the decline again, which can override a dashboard enable made in between; that errs toward +privacy. An MSI install puts no `ocx.exe` on PATH; the terminal command comes from this app, so the +app always runs before any proxy from that install. ### Linux packaged-shell acceptance diff --git a/tests/clients/desktop-telemetry-installer.test.ts b/tests/clients/desktop-telemetry-installer.test.ts index 0b28e8bb654..08cea4effb7 100644 --- a/tests/clients/desktop-telemetry-installer.test.ts +++ b/tests/clients/desktop-telemetry-installer.test.ts @@ -53,6 +53,108 @@ function xmlUnescape(value: string): string { return value.replaceAll(""", '"').replaceAll("<", "<").replaceAll(">", ">").replaceAll("&", "&"); } +type Props = Record; + +/** Evaluates the subset of Windows Installer condition syntax the fragment uses. */ +function msiCondition(condition: string, props: Props): boolean { + const tokens = xmlUnescape(condition).match(/"[^"]*"|~<>|~=|<>|=|\(|\)|[A-Za-z_][A-Za-z0-9_.]*|\d+/g) ?? []; + let at = 0; + const peek = () => tokens[at]; + const value = (token: string): string => (token.startsWith('"') ? token.slice(1, -1) : /^\d+$/.test(token) ? token : props[token] ?? ""); + const primary = (): boolean => { + const token = tokens[at++]; + if (token === undefined) throw new Error(`incomplete condition: ${condition}`); + if (token === "NOT") return !primary(); + if (token === "(") { + const inner = or(); + if (tokens[at++] !== ")") throw new Error(`unbalanced condition: ${condition}`); + return inner; + } + const op = peek(); + if (op === "=" || op === "<>" || op === "~=" || op === "~<>") { + at += 1; + let left = value(token); + let right = value(tokens[at++]!); + if (op.startsWith("~")) [left, right] = [left.toLowerCase(), right.toLowerCase()]; + return op.endsWith("<>") ? left !== right : left === right; + } + const bare = value(token); + return bare !== "" && bare !== "0"; + }; + const and = (): boolean => { + let result = primary(); + while (peek() === "AND") { at += 1; result = primary() && result; } + return result; + }; + const or = (): boolean => { + let result = and(); + while (peek() === "OR") { at += 1; result = and() || result; } + return result; + }; + const result = or(); + if (at !== tokens.length) throw new Error(`unparsed condition: ${condition}`); + return result; +} + +function msiFormat(template: string, props: Props, clock: string): string { + return template.replace(/\[([A-Za-z_]+)\]/g, (_, name: string) => (name === "Date" ? clock.split(" ")[0]! : name === "Time" ? clock.split(" ")[1]! : props[name] ?? "")); +} + +interface InstallRun { + /** HKLM values an earlier install left, read by AppSearch. */ + previous?: { choice: string; stamp: string }; + /** Public properties on the msiexec command line. */ + commandLine?: Props; + /** `full` shows the page; `reduced` (/qr) runs the UI sequence without dialogs; `none` (/qn, /qb, /passive) skips it. */ + ui: "full" | "reduced" | "none"; + /** In a full-UI run: the box state the person leaves before clicking Next (default: as preselected). */ + box?: boolean; +} + +/** + * Runs the fragment's SetProperty actions and the page's Next publishes through both MSI phases and + * returns what the component writes to HKLM. The UI phase is stamped "11/8/2026 01:00:00", the + * install phase "11/8/2026 02:00:00", so a re-stamp in the install phase shows. + */ +function simulateInstall(wxs: string, run: InstallRun): { choice: string; stamp: string; boxPreselected?: boolean } { + const actions = [...wxs.matchAll(/([^<]*)<\/SetProperty>/g)] + .map(([, action, id, template, sequence, condition]) => ({ action: action!, id: id!, template: template!, sequence: sequence!, condition: condition! })); + if (actions.length !== 5) throw new Error(`expected 5 SetProperty actions, found ${actions.length}`); + const secure = [...wxs.matchAll(//g)].map(match => match[1]!); + const phase = (props: Props, sequence: "ui" | "execute", clock: string): Props => { + for (const step of actions) { + if (step.sequence !== "both" && step.sequence !== sequence) continue; + if (step.action === "OcxTelemetryKeepChoice" && run.previous) { + // AppSearch runs between OcxTelemetryStampExplicit and OcxTelemetryKeepChoice. + props.OCX_TELEMETRY_PREVIOUS_CHOICE = run.previous.choice; + props.OCX_TELEMETRY_PREVIOUS_STAMP = run.previous.stamp; + } + if (msiCondition(step.condition, props)) props[step.id] = msiFormat(step.template, props, clock); + } + return props; + }; + const commandLine = run.commandLine ?? {}; + let boxPreselected: boolean | undefined; + let carried: Props = {}; + if (run.ui !== "none") { + const ui = phase({ ...commandLine }, "ui", "11/8/2026 01:00:00"); + if (run.ui === "full") { + boxPreselected = ui.OCX_TELEMETRY_SEND === "1"; + const box = run.box ?? boxPreselected; + if (box) ui.OCX_TELEMETRY_SEND = "1"; + else delete ui.OCX_TELEMETRY_SEND; + const next = //.exec(wxs)?.[0] ?? ""; + for (const [, property, template, condition] of next.matchAll(/([^<]*)<\/Publish>/g)) { + if (msiCondition(condition!, ui)) ui[property!] = msiFormat(template!, ui, "11/8/2026 01:00:00"); + } + } + // Only Secure public properties reach the elevated install phase. + carried = Object.fromEntries(secure.filter(name => ui[name] !== undefined).map(name => [name, ui[name]!])); + } + const execute = phase({ ...commandLine, ...carried }, "execute", "11/8/2026 02:00:00"); + return { choice: execute.OCX_TELEMETRY_CHOICE ?? "", stamp: execute.OCX_TELEMETRY_STAMP ?? "", boxPreselected }; +} + describe("Windows installer telemetry choice", () => { const wxs = readFileSync(FRAGMENT, "utf8"); @@ -73,7 +175,7 @@ describe("Windows installer telemetry choice", () => { expect(wxs).toMatch(new RegExp(`Type="CheckBox"[^>]*Property="${TELEMETRY_INSTALLER_CHECKBOX_PROPERTY}" CheckBoxValue="1" Text="${TELEMETRY_INSTALLER_CHECKBOX_TEXT}"`)); // The box starts checked unless this install already carries an opt-out (command line or upgrade). expect(wxs).toContain( - `${TELEMETRY_INSTALLER_CHOICE_PROPERTY} <> "${TELEMETRY_INSTALLER_OPT_OUT}"`, + `${TELEMETRY_INSTALLER_CHOICE_PROPERTY} ~<> "${TELEMETRY_INSTALLER_OPT_OUT}"`, ); // A checked box writes "send", which is never acted on, and the page says so. expect(wxs).toContain(`Text="${TELEMETRY_INSTALLER_KEEP_NOTE}"`); @@ -102,10 +204,55 @@ describe("Windows installer telemetry choice", () => { expect(wxs).toContain(`Action="OcxTelemetryKeepChoice" Id="${TELEMETRY_INSTALLER_CHOICE_PROPERTY}" Value="[OCX_TELEMETRY_PREVIOUS_CHOICE]" After="AppSearch"`); }); + describe("what each kind of install records in HKLM", () => { + const optOut = TELEMETRY_INSTALLER_OPT_OUT; + const send = "send"; + const UI_STAMP = "11/8/2026 01:00:00"; + const INSTALL_STAMP = "11/8/2026 02:00:00"; + + test("a fresh install with the page records the box, stamped when Next was clicked", () => { + expect(simulateInstall(wxs, { ui: "full" })).toEqual({ choice: send, stamp: UI_STAMP, boxPreselected: true }); + expect(simulateInstall(wxs, { ui: "full", box: false })).toEqual({ choice: optOut, stamp: UI_STAMP, boxPreselected: true }); + }); + + test("a silent opt-out on the command line gets its own stamp, in any letter case", () => { + expect(simulateInstall(wxs, { ui: "none", commandLine: { OCX_TELEMETRY_CHOICE: optOut } })).toEqual({ choice: optOut, stamp: INSTALL_STAMP, boxPreselected: undefined }); + expect(simulateInstall(wxs, { ui: "reduced", commandLine: { OCX_TELEMETRY_CHOICE: optOut } })).toEqual({ choice: optOut, stamp: UI_STAMP, boxPreselected: undefined }); + // A full-UI install given OptOut starts with the box unchecked, so Next keeps the opt-out. + expect(simulateInstall(wxs, { ui: "full", commandLine: { OCX_TELEMETRY_CHOICE: "OptOut" } })).toEqual({ choice: optOut, stamp: UI_STAMP, boxPreselected: false }); + }); + + test("an upgrade without the page keeps the previous choice and stamp, so nothing is re-applied", () => { + for (const ui of ["none", "reduced"] as const) { + for (const previous of [{ choice: optOut, stamp: "s1" }, { choice: send, stamp: "s1" }]) { + expect(simulateInstall(wxs, { ui, previous }), `${ui} ${previous.choice}`).toEqual({ ...previous, boxPreselected: undefined }); + } + } + }); + + test("an upgrade never turns a previous opt-out into send unless the person checks the box", () => { + for (const ui of ["none", "reduced", "full"] as const) { + expect(simulateInstall(wxs, { ui, previous: { choice: optOut, stamp: "s1" } }).choice, ui).toBe(optOut); + } + // The page preselects the previous choice. + expect(simulateInstall(wxs, { ui: "full", previous: { choice: optOut, stamp: "s1" } }).boxPreselected).toBe(false); + expect(simulateInstall(wxs, { ui: "full", previous: { choice: send, stamp: "s1" } }).boxPreselected).toBe(true); + // Checking it records send, which the app never acts on: a saved decline stays declined. + expect(simulateInstall(wxs, { ui: "full", box: true, previous: { choice: optOut, stamp: "s1" } }).choice).toBe(send); + }); + + test("a silent install without a choice defaults to send and records no stamp", () => { + expect(simulateInstall(wxs, { ui: "none" })).toEqual({ choice: send, stamp: "", boxPreselected: undefined }); + }); + }); + test("the dialog sits between the welcome and install-directory pages of Tauri's sequence", () => { // Tauri's main.wxs routes WelcomeDlg -> InstallDirDlg at Order 2; the later NewDialog wins. - expect(wxs).toContain(`1`); - expect(wxs).toContain(`1`); + // Only on a first install, so an installed product's patch route (WelcomeDlg -> VerifyReadyDlg) is untouched. + expect(wxs).toContain(`NOT Installed`); + expect(wxs).toContain(`NOT Installed`); + expect(msiCondition("NOT Installed", {})).toBe(true); + expect(msiCondition("NOT Installed", { Installed: "00:00:00" })).toBe(false); const dialog = //.exec(wxs)?.[0] ?? ""; expect(dialog).toMatch(/ { expect(rustConst("REGISTRY_VALUE")).toBe(TELEMETRY_INSTALLER_REGISTRY_VALUE); expect(rustConst("STAMP_VALUE")).toBe(TELEMETRY_INSTALLER_STAMP_VALUE); expect(rustConst("OPT_OUT")).toBe(TELEMETRY_INSTALLER_OPT_OUT); - // The app reads HKLM and records the applied stamp per user under HKCU. + // The app reads the machine-wide choice and a per-user one, and records each applied stamp under HKCU. const module = rustCode(RUST); expect(module).toContain("RegKey::predef(HKEY_LOCAL_MACHINE)"); + expect(module).toMatch(/RegKey::predef\(HKEY_CURRENT_USER\)[\s\S]*?read\(&key, Scope::User\)/); expect(module).toContain("create_subkey(super::REGISTRY_KEY)"); + expect(rustConst("APPLIED_STAMP_VALUE")).toBe("AppliedInstallerStamp"); + expect(rustConst("APPLIED_USER_STAMP_VALUE")).toBe("AppliedUserInstallerStamp"); }); test("the app saves the opt-out before it resolves or starts a proxy, and falls back to the env opt-out", () => { @@ -138,7 +288,7 @@ describe("Windows installer telemetry choice", () => { const module = rustCode(RUST); expect(module).toContain(`.args(["telemetry", "disable", "--json"])`); // A failed save is retried in the background, so a proxy the app only attaches to follows it too. - expect(module).toMatch(/Outcome::NotPersisted => \{[\s\S]*?tauri::async_runtime::spawn\(async move \{ retry\(app, stamp\)\.await \}\)/); + expect(module).toMatch(/Outcome::NotPersisted => \{[\s\S]*?tauri::async_runtime::spawn\(async move \{ retry\(app, pending\)\.await \}\)/); }); test("the command the app runs persists the opt-out, and nothing is sent afterwards", async () => { From c9443b109a3f67a25205d627dc43d89acae3b306 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 20:21:41 +0900 Subject: [PATCH 23/37] fix(desktop): issue a new installer stamp only when the page changes the carried choice Next set OCX_TELEMETRY_STAMP unconditionally, so a full-UI upgrade that left a carried opt-out untouched got a fresh stamp and the app applied the opt-out again over a later dashboard enable. Next now stamps only when OCX_TELEMETRY_CHOICE ~<> OCX_TELEMETRY_PREVIOUS_CHOICE. The installer test covers unchanged and changed choices in both directions (it fails on the previous fragment). --- desktop/scripts/telemetry-installer-dialog.ts | 2 +- desktop/src-tauri/wix/telemetry-choice.wxs | 2 +- .../src/content/docs/guides/desktop-app.md | 7 ++++--- structure/desktop-shell.md | 4 +++- .../desktop-telemetry-installer.test.ts | 19 +++++++++++++++++-- 5 files changed, 26 insertions(+), 8 deletions(-) diff --git a/desktop/scripts/telemetry-installer-dialog.ts b/desktop/scripts/telemetry-installer-dialog.ts index d492ae9836d..a8976bf1f97 100644 --- a/desktop/scripts/telemetry-installer-dialog.ts +++ b/desktop/scripts/telemetry-installer-dialog.ts @@ -148,7 +148,7 @@ ${controls.join("\n")} ${TELEMETRY_INSTALLER_CHECKBOX_PROPERTY} = "1" NOT (${TELEMETRY_INSTALLER_CHECKBOX_PROPERTY} = "1") - 1 + ${TELEMETRY_INSTALLER_CHOICE_PROPERTY} ~<> OCX_TELEMETRY_PREVIOUS_CHOICE 1 diff --git a/desktop/src-tauri/wix/telemetry-choice.wxs b/desktop/src-tauri/wix/telemetry-choice.wxs index 0ddcff03bbd..c3ef550c5f6 100644 --- a/desktop/src-tauri/wix/telemetry-choice.wxs +++ b/desktop/src-tauri/wix/telemetry-choice.wxs @@ -56,7 +56,7 @@ OCX_TELEMETRY_SEND = "1" NOT (OCX_TELEMETRY_SEND = "1") - 1 + OCX_TELEMETRY_CHOICE ~<> OCX_TELEMETRY_PREVIOUS_CHOICE 1 diff --git a/docs-site/src/content/docs/guides/desktop-app.md b/docs-site/src/content/docs/guides/desktop-app.md index 2e172dd9143..2b5384977b9 100644 --- a/docs-site/src/content/docs/guides/desktop-app.md +++ b/docs-site/src/content/docs/guides/desktop-app.md @@ -46,9 +46,10 @@ The installer needs administrator rights and records the choice for the whole co Leaving the box checked does not start sending by itself: the dashboard still shows its one-time notice first, and a checked box never turns telemetry back on for an account that turned it off. In-app updates, reduced-UI installs (`/qr`) and maintenance runs of the installed version skip this -page and keep the recorded choice. Running a newer installer by hand shows the page again, unchecked if you opted out -before. If you have since turned telemetry on in the dashboard and want to keep it, check the box -before you continue. +page and keep the recorded choice. Running a newer installer by hand shows the page again, unchecked +if you opted out before. Continuing without changing the box keeps that earlier decision as it was, +so it is not applied again over a choice you made in the dashboard since; changing the box records a +new decision. ### Linux diff --git a/structure/desktop-shell.md b/structure/desktop-shell.md index 78944a7d555..8df3a5cb382 100644 --- a/structure/desktop-shell.md +++ b/structure/desktop-shell.md @@ -385,7 +385,9 @@ choice and stamp and carries them (`OcxTelemetryKeepChoice`, `OcxTelemetryKeepSt an opt-out, with its old stamp, through in-app updates (`/passive`, no UI sequence), reduced-UI (`/qr`) and maintenance runs, and the early removal of the old product in a major upgrade; a first install defaults to `send`. The box starts checked unless the install already carries an opt-out -(compared case-insensitively with `~<>`), and pressing Next records the box with a new stamp. A +(compared case-insensitively with `~<>`). Pressing Next records the box, and issues a new stamp only +when the box differs from the carried choice (`OCX_TELEMETRY_CHOICE ~<> OCX_TELEMETRY_PREVIOUS_CHOICE`), +so continuing past a carried opt-out does not apply it again over a later dashboard enable. A page note says leaving the box checked keeps the current choice, because `send` is never acted on. The installer test evaluates the fragment's conditions through both MSI phases for fresh, silent, reduced-UI and upgrade installs. diff --git a/tests/clients/desktop-telemetry-installer.test.ts b/tests/clients/desktop-telemetry-installer.test.ts index 08cea4effb7..5fdeca1dcaa 100644 --- a/tests/clients/desktop-telemetry-installer.test.ts +++ b/tests/clients/desktop-telemetry-installer.test.ts @@ -179,12 +179,16 @@ describe("Windows installer telemetry choice", () => { ); // A checked box writes "send", which is never acted on, and the page says so. expect(wxs).toContain(`Text="${TELEMETRY_INSTALLER_KEEP_NOTE}"`); - // Unchecking records an opt-out with a new stamp. + // Unchecking records an opt-out; a new stamp is issued only when the choice differs from the carried one. const next = //.exec(wxs)?.[0] ?? ""; expect(next).toContain( `NOT (${TELEMETRY_INSTALLER_CHECKBOX_PROPERTY} = "1")`, ); - expect(next).toContain(`1`); + expect(next).toContain( + `${TELEMETRY_INSTALLER_CHOICE_PROPERTY} ~<> OCX_TELEMETRY_PREVIOUS_CHOICE`, + ); + // The stamp is compared after the choice is set. + expect(next.indexOf(`Property="${TELEMETRY_INSTALLER_STAMP_PROPERTY}"`)).toBeGreaterThan(next.lastIndexOf(`Property="${TELEMETRY_INSTALLER_CHOICE_PROPERTY}"`)); }); test("the choice is machine-wide, survives upgrades, and a silent opt-out gets its own stamp", () => { @@ -241,6 +245,17 @@ describe("Windows installer telemetry choice", () => { expect(simulateInstall(wxs, { ui: "full", box: true, previous: { choice: optOut, stamp: "s1" } }).choice).toBe(send); }); + test("the page issues a new stamp only when the person changes the carried choice", () => { + // Continuing past a carried opt-out keeps its stamp, so the app does not apply it again over a + // dashboard enable made since. + expect(simulateInstall(wxs, { ui: "full", previous: { choice: optOut, stamp: "s1" } })).toEqual({ choice: optOut, stamp: "s1", boxPreselected: false }); + expect(simulateInstall(wxs, { ui: "full", previous: { choice: "OptOut", stamp: "s1" } })).toEqual({ choice: optOut, stamp: "s1", boxPreselected: false }); + expect(simulateInstall(wxs, { ui: "full", previous: { choice: send, stamp: "s1" } })).toEqual({ choice: send, stamp: "s1", boxPreselected: true }); + // A change is a new decision. + expect(simulateInstall(wxs, { ui: "full", box: false, previous: { choice: send, stamp: "s1" } })).toEqual({ choice: optOut, stamp: UI_STAMP, boxPreselected: true }); + expect(simulateInstall(wxs, { ui: "full", box: true, previous: { choice: optOut, stamp: "s1" } })).toEqual({ choice: send, stamp: UI_STAMP, boxPreselected: false }); + }); + test("a silent install without a choice defaults to send and records no stamp", () => { expect(simulateInstall(wxs, { ui: "none" })).toEqual({ choice: send, stamp: "", boxPreselected: undefined }); }); From 12d45a3f7ed503019459f5e40b52da20cf72c58a Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 20:48:47 +0900 Subject: [PATCH 24/37] fix(telemetry): never overwrite an unreadable state file after a send, and never let a stale save accept over an unread config From the round-4 reviews of #8300 (all P3; the first three reproduced): - The pre-send recheck reads strictly and refuses when telemetry-state.json has become unreadable; the bookkeeping writes after a send or a Retry-After leave such a file alone. Before, they rewrote it from an empty state, and a later tick sent days covered by an env opt-out. - A whole-config save over a config.json that exists but cannot be read keeps only a snapshot decline; any other block is dropped, so a stale accept can no longer replace an unread decline. - A proxy running under an env opt-out records itself again every 6 hours, so a state file rebuilt after corruption (which forgets envOptOutOwners) learns it again. - The status snapshot reports stateUnreadable, and ocx telemetry status says sending is paused. Failing-first tests in tests/usage/usage-telemetry-client.test.ts and tests/config/telemetry-live-save.test.ts. --- src/cli/telemetry.ts | 1 + src/config/telemetry-carry.ts | 22 +++++- src/server/background-lifecycle.ts | 22 ++++++ src/telemetry/routes.ts | 3 + src/telemetry/scheduler.ts | 44 ++++++++--- structure/telemetry.md | 10 ++- tests/config/telemetry-live-save.test.ts | 32 ++++++++ tests/usage/usage-telemetry-client.test.ts | 90 +++++++++++++++++++++- 8 files changed, 207 insertions(+), 17 deletions(-) diff --git a/src/cli/telemetry.ts b/src/cli/telemetry.ts index 78579f7abfd..65269dccbbb 100644 --- a/src/cli/telemetry.ts +++ b/src/cli/telemetry.ts @@ -165,6 +165,7 @@ function formatStatusLines(snapshot: TelemetryStatusSnapshot): string[] { ...(snapshot.envOverride ? [`Environment override: ${snapshot.envOverride}`] : []), `Install channel: ${snapshot.installChannel}`, `Last sent day: ${snapshot.lastSentDay ?? "never"}`, + ...(snapshot.stateUnreadable ? ["Sending paused: telemetry-state.json cannot be read, so nothing is sent."] : []), `Preview eligible: ${snapshot.previewEligible ? "yes" : "no"}`, snapshot.previewEligible ? `Preview (${snapshot.preview.day}, next eligible send):` diff --git a/src/config/telemetry-carry.ts b/src/config/telemetry-carry.ts index ba613037c97..44a749368cc 100644 --- a/src/config/telemetry-carry.ts +++ b/src/config/telemetry-carry.ts @@ -1,4 +1,6 @@ +import { existsSync } from "node:fs"; import type { OcxConfig } from "../types"; +import { getConfigPath } from "./paths"; import { telemetryConfigSchema } from "./schema/leaf-validators"; /** @@ -6,7 +8,9 @@ import { telemetryConfigSchema } from "./schema/leaf-validators"; * consent route write it there, often while another process holds an older snapshot. A whole-config * save therefore writes the on-disk block (validated; a malformed hand edit is dropped, as a load * would drop it) instead of the snapshot's, so a decision is never dropped or rolled back. Without a - * file on disk the snapshot's block is kept. + * file on disk the snapshot's block is kept. A file that exists but cannot be read may hold a + * decline the snapshot never saw, so only a decline survives from the snapshot; anything else is + * dropped and the install waits for the notice again. * * Returns whether the block was taken from disk and the value now in `target`. */ @@ -14,10 +18,24 @@ export function carryPersistedTelemetry( target: OcxConfig, onDisk: Record | undefined, ): { carried: boolean; value: OcxConfig["telemetry"] } { - if (onDisk === undefined) return { carried: false, value: target.telemetry }; + if (onDisk === undefined) { + if (!configFileExists()) return { carried: false, value: target.telemetry }; + if (target.telemetry?.decision === "declined") return { carried: true, value: target.telemetry }; + delete target.telemetry; + return { carried: true, value: undefined }; + } const parsed = onDisk.telemetry === undefined ? undefined : telemetryConfigSchema.safeParse(onDisk.telemetry); const value = parsed?.success ? (structuredClone(parsed.data) as OcxConfig["telemetry"]) : undefined; if (value === undefined) delete target.telemetry; else target.telemetry = value; return { carried: true, value }; } + +function configFileExists(): boolean { + try { + return existsSync(getConfigPath()); + } catch { + // Cannot tell: treat it as present, so an unread decision is not overwritten. + return true; + } +} diff --git a/src/server/background-lifecycle.ts b/src/server/background-lifecycle.ts index 549ad5e406f..f273fe957d8 100644 --- a/src/server/background-lifecycle.ts +++ b/src/server/background-lifecycle.ts @@ -67,6 +67,22 @@ let telemetryLoopGeneration = 0; let stopTelemetryLoop: (() => void) | null = null; // This process started under an env opt-out; its clean stop closes the opt-out period (D2). let telemetryEnvOptOutObserved = false; +/** + * How often a proxy running under an env opt-out records itself again, so a telemetry-state.json + * rebuilt after corruption (which forgets every owner) learns that this proxy is still opted out. + */ +const TELEMETRY_ENV_OPT_OUT_REFRESH_MS = 6 * 60 * 60 * 1000; +let telemetryEnvOptOutRefreshMsOverride: number | null = null; +let telemetryEnvOptOutRefresh: ReturnType | null = null; + +export function setTelemetryEnvOptOutRefreshMsForTests(ms: number | null): void { + telemetryEnvOptOutRefreshMsOverride = ms; +} + +function clearTelemetryEnvOptOutRefresh(): void { + if (telemetryEnvOptOutRefresh) clearInterval(telemetryEnvOptOutRefresh); + telemetryEnvOptOutRefresh = null; +} function setLivePolicyOwner(applyPolicy: PolicyApply | null): void { setStorageCleanupPolicyLiveSink(applyPolicy); @@ -145,12 +161,17 @@ function startProcessLoops(applyPolicy: PolicyApply): ProcessLoops { telemetryEnvOptOutObserved = true; // Recorded under this pid, so a proxy sharing the home cannot end the period while this one runs. try { recordTelemetryEnvOptOut(undefined, undefined, process.pid); } catch { /* best-effort */ } + telemetryEnvOptOutRefresh = setInterval(() => { + try { recordTelemetryEnvOptOut(undefined, undefined, process.pid); } catch { /* best-effort */ } + }, telemetryEnvOptOutRefreshMsOverride ?? TELEMETRY_ENV_OPT_OUT_REFRESH_MS); + telemetryEnvOptOutRefresh.unref?.(); } return { memoryWatchdog, stateStoreSweeper }; } catch (error) { telemetryLoopGeneration += 1; stopTelemetryLoop?.(); stopTelemetryLoop = null; + clearTelemetryEnvOptOutRefresh(); memoryWatchdog?.stop(); stateStoreSweeper?.stop(); stopStorageCleanupScheduler(); @@ -167,6 +188,7 @@ function stopProcessLoops(): void { const stopTelemetry = stopTelemetryLoop; stopTelemetryLoop = null; stopTelemetry?.(); + clearTelemetryEnvOptOutRefresh(); if (telemetryEnvOptOutObserved) { telemetryEnvOptOutObserved = false; try { endTelemetryEnvOptOut(undefined, undefined, process.pid); } catch { /* best-effort; the next tick ends it once this pid has exited */ } diff --git a/src/telemetry/routes.ts b/src/telemetry/routes.ts index 2577af42387..0007cfb3ff3 100644 --- a/src/telemetry/routes.ts +++ b/src/telemetry/routes.ts @@ -33,6 +33,8 @@ export interface TelemetryStatusSnapshot extends TelemetryConsentStatus { lastSentAt: string | null; previewEligible: boolean; preview: TelemetryPayloadV1; + /** Present when telemetry-state.json exists but cannot be read: every send is refused until it can. */ + stateUnreadable?: true; } /** How long the dashboard's preview may reuse a completed day's aggregate (the panel polls). */ @@ -91,6 +93,7 @@ export async function buildTelemetryStatusSnapshot( lastSentAt: state.lastSentAt, previewEligible, preview, + ...(stateFile.unreadable ? { stateUnreadable: true as const } : {}), }; } diff --git a/src/telemetry/scheduler.ts b/src/telemetry/scheduler.ts index c56029ffed9..30582aaa548 100644 --- a/src/telemetry/scheduler.ts +++ b/src/telemetry/scheduler.ts @@ -177,7 +177,9 @@ export async function runTelemetryTick(options: TelemetryTickOptions = {}): Prom // Ends the period only once no opted-out proxy is still running; otherwise extends it to today. endTelemetryEnvOptOut(configDir, now); } - const state = readTelemetryState(configDir); + const current = readTelemetryStateFile(configDir); + if (current.unreadable) return { sent: false, reason: "state-unreadable" }; + const state = current.state; if (state.retryAfterUntil) { const retryUntilMs = Date.parse(state.retryAfterUntil); if (!Number.isNaN(retryUntilMs) && now < retryUntilMs) { @@ -226,7 +228,12 @@ export async function runTelemetryTick(options: TelemetryTickOptions = {}): Prom if (!preSendStatus.effective) { return { sent: false, day: targetDay, reason: preSendStatus.reason }; } - const preSendState = readTelemetryState(configDir); + // The file may have become unreadable while the day was aggregated; never send over it. + const preSendFile = readTelemetryStateFile(configDir); + if (preSendFile.unreadable) { + return { sent: false, day: targetDay, reason: "state-unreadable" }; + } + const preSendState = preSendFile.state; const recheckedEligibility = resolveNextEligibleTelemetryDay(preSendStatus, preSendState, now); if (recheckedEligibility.day !== targetDay) { return { sent: false, day: targetDay, reason: recheckedEligibility.reason }; @@ -253,22 +260,23 @@ export async function runTelemetryTick(options: TelemetryTickOptions = {}): Prom return { sent: false, day: targetDay, reason: "aborted", sendResult }; } if (typeof sendResult.retryAfterMs === "number" && sendResult.retryAfterMs > 0) { - const freshState = readTelemetryState(configDir); - freshState.retryAfterUntil = new Date(now + sendResult.retryAfterMs).toISOString(); - writeTelemetryState(freshState, configDir, now); + const retryAfterUntil = new Date(now + sendResult.retryAfterMs).toISOString(); + updateReadableTelemetryState(configDir, now, state => { + state.retryAfterUntil = retryAfterUntil; + }); return { sent: false, day: targetDay, reason: "retry-after-backoff", sendResult }; } return { sent: false, day: targetDay, reason: "send-failed", sendResult }; } markTelemetryFirstReportSent(configDir); - const freshState = readTelemetryState(configDir); const nowIso = new Date(now).toISOString(); - freshState.sentDays[targetDay] = nowIso; - freshState.everReported = true; - freshState.lastSentDay = targetDay; - freshState.lastSentAt = nowIso; - freshState.retryAfterUntil = null; - writeTelemetryState(freshState, configDir, now); + updateReadableTelemetryState(configDir, now, state => { + state.sentDays[targetDay] = nowIso; + state.everReported = true; + state.lastSentDay = targetDay; + state.lastSentAt = nowIso; + state.retryAfterUntil = null; + }); return { sent: true, day: targetDay, reason: "sent", sendResult }; } finally { parentSignal?.removeEventListener("abort", onParentAbort); @@ -276,6 +284,18 @@ export async function runTelemetryTick(options: TelemetryTickOptions = {}): Prom } } +/** + * Bookkeeping after a send. A file that became unreadable during the send is left as it is: rewriting + * it from an empty state would drop the opt-out days it held, and every later tick refuses to send + * until it can be read again. + */ +function updateReadableTelemetryState(configDir: string, now: number, update: (state: TelemetryState) => void): void { + const file = readTelemetryStateFile(configDir); + if (file.unreadable) return; + update(file.state); + writeTelemetryState(file.state, configDir, now); +} + let startupTimer: ReturnType | null = null; let intervalTimer: ReturnType | null = null; let nudgeTimer: ReturnType | null = null; diff --git a/structure/telemetry.md b/structure/telemetry.md index e38ab86028d..1e5d5066a6c 100644 --- a/structure/telemetry.md +++ b/structure/telemetry.md @@ -53,7 +53,9 @@ Only a human can transition an undecided install to active sending: (`src/config/telemetry-carry.ts`) makes both whole-config saves (`saveConfig` and `saveConfigPreservingClaudeCode`) write the validated on-disk block instead of the caller's snapshot, so a decision written by another process is never dropped or rolled back, and a - malformed hand edit is dropped as a load would drop it. + malformed hand edit is dropped as a load would drop it. A `config.json` that exists but cannot be + read may hold a decline the snapshot never saw, so only a snapshot decline survives a save over + it; any other block is dropped and the install waits for the notice again. ## Identity, aggregation, and scheduling @@ -83,7 +85,11 @@ day is eligible. `endTelemetryEnvOptOut` closes the period (ineligible through t that proxy's clean stop, which always covers its stop day, or on the first tick without the opt-out, which ends it only once no recorded owner pid is still alive and otherwise extends it to today. A `telemetry-state.json` that exists but cannot be read (symlink, oversized, malformed) stops sending -(`state-unreadable`) rather than being read as empty. The state code lives in +(`state-unreadable`) rather than being read as empty: the tick checks it before choosing a day and +again just before sending, the bookkeeping writes after a send or a `Retry-After` leave such a file +alone, other writers rebuild it as ineligible through today, and the status snapshot reports +`stateUnreadable`. A rebuild forgets `envOptOutOwners`, so an opted-out proxy records itself again +every 6 hours. The state code lives in `src/telemetry/state.ts`, which `background-lifecycle.ts` imports statically; the scheduler, its aggregation and sender stay behind the dynamic import. Each tick sends at most one completed UTC day strictly after the UTC day of `decidedAt ?? noticeShownAt` and strictly after diff --git a/tests/config/telemetry-live-save.test.ts b/tests/config/telemetry-live-save.test.ts index 330e01b7e6e..33d812dd3c2 100644 --- a/tests/config/telemetry-live-save.test.ts +++ b/tests/config/telemetry-live-save.test.ts @@ -106,3 +106,35 @@ test("a whole-config save from a stale snapshot keeps a newer decline on disk", removeTreeWithRetry(home); } }); + +test("a save over an unreadable config.json never turns an unread decline back into an accept", () => { + const oldHome = process.env.OPENCODEX_HOME; + const home = mkdtempSync(join(tmpdir(), "ocx-telemetry-live-save-")); + process.env.OPENCODEX_HOME = home; + try { + saveConfig(seed(accepted)); + const stale = loadConfig(); + // Another process declines, then a hand edit breaks the file: the decline can no longer be read. + expect(mutatePersistedConfig(config => { + config.telemetry = structuredClone(declined); + return { changed: true, value: null }; + }).status).toBe("committed"); + writeFileSync(getConfigPath(), "{ broken", "utf8"); + stale.logLevel = "debug"; + saveConfig(stale); + // The stale accept is not written over a decision that could not be read; the install waits for the notice. + expect((JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig).telemetry).toBeUndefined(); + expect(stale.telemetry).toBeUndefined(); + + // A snapshot that holds a decline keeps it. + const declinedSnapshot = loadConfig(); + declinedSnapshot.telemetry = structuredClone(declined); + writeFileSync(getConfigPath(), "{ broken", "utf8"); + saveConfig(declinedSnapshot); + expect((JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig).telemetry).toEqual(declined); + } finally { + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldHome; + removeTreeWithRetry(home); + } +}); diff --git a/tests/usage/usage-telemetry-client.test.ts b/tests/usage/usage-telemetry-client.test.ts index 017648cbadf..b7afb062ba0 100644 --- a/tests/usage/usage-telemetry-client.test.ts +++ b/tests/usage/usage-telemetry-client.test.ts @@ -43,6 +43,7 @@ import { nudgeTelemetryScheduler, endTelemetryEnvOptOut, readTelemetryState, + readTelemetryStateFile, recordPendingTelemetryDecision, recordTelemetryEnvOptOut, runTelemetryTick, @@ -56,7 +57,7 @@ import { sendTelemetryPayload, setTelemetrySenderOverridesForTests, } from "../../src/telemetry/sender"; -import { acquireServerBackgroundLifecycle } from "../../src/server/background-lifecycle"; +import { acquireServerBackgroundLifecycle, setTelemetryEnvOptOutRefreshMsForTests } from "../../src/server/background-lifecycle"; import type { OcxConfig } from "../../src/types"; import type { PersistedUsageEntry } from "../../src/usage/log"; import { REQUEST_OUTCOME_CLASSES } from "../../src/usage/request-outcome"; @@ -876,6 +877,93 @@ describe("install identity, aggregation, disjoint tokens, and scheduler", () => expect(readTelemetryState(home).ineligibleThroughDay).toBe("2026-11-07"); }); + test("a state file that becomes unreadable during a send keeps its protection, so opt-out days are never sent", async () => { + const statePath = telemetryStatePath(home); + const sentDays: string[] = []; + let corruptOnSend = false; + let reply = () => new Response("{}", { status: 202 }); + const mockFetch = (async (_url: string | URL | Request, init?: RequestInit) => { + sentDays.push((JSON.parse(String(init?.body)) as TelemetryPayloadV1).day); + if (corruptOnSend) writeFileSync(statePath, "{ not json", "utf8"); + return reply(); + }) as unknown as typeof fetch; + const config = { + telemetry: { decision: "accepted" as const, decidedAt: "2026-10-30T10:00:00.000Z", noticeShownAt: "2026-10-30T10:00:00.000Z" }, + }; + const tickAt = (iso: string) => runTelemetryTick({ configDir: home, config, env: {}, now: Date.parse(iso), fetch: mockFetch }); + + // An env opt-out covers every day through 2026-11-05. + recordTelemetryEnvOptOut(home, Date.parse("2026-11-03T09:00:00.000Z")); + expect((await tickAt("2026-11-05T12:00:00.000Z")).sent).toBe(false); + expect(readTelemetryState(home).ineligibleThroughDay).toBe("2026-11-05"); + + // A 429 arrives while the file is being corrupted: the backoff write must not replace it. + corruptOnSend = true; + reply = () => new Response("{}", { status: 429, headers: { "retry-after": "60" } }); + expect(await tickAt("2026-11-07T12:00:00.000Z")).toMatchObject({ sent: false, reason: "retry-after-backoff" }); + expect(readTelemetryStateFile(home).unreadable).toBe(true); + + // A send that succeeds while the file is corrupted must not replace it either. + writeFileSync(statePath, JSON.stringify({ version: 1, sentDays: {}, ineligibleThroughDay: "2026-11-05" }), "utf8"); + reply = () => new Response("{}", { status: 202 }); + expect(await tickAt("2026-11-07T13:00:00.000Z")).toMatchObject({ sent: true, day: "2026-11-06" }); + expect(readTelemetryStateFile(home).unreadable).toBe(true); + // Every later tick refuses, so no day through 2026-11-05 is ever sent. + corruptOnSend = false; + expect(await tickAt("2026-11-09T12:00:00.000Z")).toEqual({ sent: false, reason: "state-unreadable" }); + expect(sentDays.every(day => day > "2026-11-05")).toBe(true); + // The status says why nothing is sent. + const status = await buildTelemetryStatusSnapshot(config, { configDir: home, env: {}, now: Date.parse("2026-11-09T12:00:00.000Z") }); + expect(status.stateUnreadable).toBe(true); + expect(status.previewEligible).toBe(false); + }); + + test("a state file that becomes unreadable before the pre-send check blocks the send", async () => { + let fetchCalls = 0; + const config = { + telemetry: { decision: "accepted" as const, decidedAt: "2026-10-30T10:00:00.000Z", noticeShownAt: "2026-10-30T10:00:00.000Z" }, + }; + const result = await runTelemetryTick({ + configDir: home, + config, + env: {}, + now: Date.parse("2026-11-07T12:00:00.000Z"), + aggregateDay: async (day, opts) => { + writeFileSync(telemetryStatePath(home), "{ not json", "utf8"); + return aggregateTelemetryForDay(day, opts); + }, + fetch: (async () => { + fetchCalls += 1; + return new Response("{}", { status: 202 }); + }) as unknown as typeof fetch, + }); + expect(result).toMatchObject({ sent: false, reason: "state-unreadable" }); + expect(fetchCalls).toBe(0); + }); + + test("D2: an opted-out proxy records itself again, so a state file rebuilt after corruption keeps it", async () => { + const proxyConfig = { port: 0, defaultProvider: "openai", providers: {} } as unknown as OcxConfig; + const prevTelemetry = process.env.OCX_TELEMETRY; + setTelemetryEnvOptOutRefreshMsForTests(25); + process.env.OCX_TELEMETRY = "0"; + let lease: Awaited> | undefined; + try { + lease = acquireServerBackgroundLifecycle(() => {}, proxyConfig); + expect(readTelemetryState(home).envOptOutOwners?.map(owner => owner.pid)).toEqual([process.pid]); + // The file is corrupted and rebuilt by a one-off observation that does not know the owner. + writeFileSync(telemetryStatePath(home), "{ not json", "utf8"); + recordTelemetryEnvOptOut(home); + expect(readTelemetryState(home).envOptOutOwners ?? []).toEqual([]); + await new Promise(resolve => setTimeout(resolve, 120)); + expect(readTelemetryState(home).envOptOutOwners?.map(owner => owner.pid)).toEqual([process.pid]); + } finally { + await lease?.release(); + setTelemetryEnvOptOutRefreshMsForTests(null); + if (prevTelemetry === undefined) delete process.env.OCX_TELEMETRY; + else process.env.OCX_TELEMETRY = prevTelemetry; + } + }); + test("a telemetry-id.json deleted by another process is not served from memory", () => { const { identity } = ensureTelemetryInstallId(home); expect(readTelemetryInstallId(home)?.installId).toBe(identity.installId); From 0a0ea7446218444ce1e4b87351a42a351ff2965a Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 20:50:24 +0900 Subject: [PATCH 25/37] docs(privacy): name the per-day install hash in the privacy statement The payload carries installDayHash (sha256 of the install id and the day), which the dashboard popup lists; the README privacy line (and its seven copies) and the code-signing privacy section now name it too. readme/i18n-manifest.json records the resynced README hash. --- README.md | 2 +- .../src/content/docs/reference/code-signing.md | 18 +++++++++--------- .../content/docs/tr/reference/code-signing.md | 2 +- readme/README.fr.md | 2 +- readme/README.ja.md | 2 +- readme/README.ko.md | 2 +- readme/README.ru.md | 2 +- readme/README.tr.md | 2 +- readme/README.zh-CN.md | 2 +- readme/README.zh-TW.md | 2 +- readme/i18n-manifest.json | 14 +++++++------- 11 files changed, 25 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 0dbab6f40f5..145b51eb1d5 100644 --- a/README.md +++ b/README.md @@ -498,7 +498,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## Disclaimer diff --git a/docs-site/src/content/docs/reference/code-signing.md b/docs-site/src/content/docs/reference/code-signing.md index 61a75e9e001..6bb4feb7224 100644 --- a/docs-site/src/content/docs/reference/code-signing.md +++ b/docs-site/src/content/docs/reference/code-signing.md @@ -30,15 +30,15 @@ SignPath Foundation policy forbids signing them with the project certificate. ### Privacy -OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, -outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person -installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and -has not declined; it can be turned off in that notice and anytime with -`ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide -aggregates are public; otherwise data goes only where the user configures. In practice, -OpenCodex forwards requests to the providers you configure. To look for new versions it also -reads release metadata from GitHub and the npm registry, and those update checks carry none of -your prompts, credentials or usage data. +OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, +version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to +`telemetry.opencodex.me` only after the person installing or operating it has seen the notice +(dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that +notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy +details; project-wide aggregates are public; otherwise data goes only where the user configures. +In practice, OpenCodex forwards requests to the providers you configure. To look for new +versions it also reads release metadata from GitHub and the npm registry, and those update +checks carry none of your prompts, credentials or usage data. ## Verifying a download diff --git a/docs-site/src/content/docs/tr/reference/code-signing.md b/docs-site/src/content/docs/tr/reference/code-signing.md index 1c1f53667f0..97b10cb7e91 100644 --- a/docs-site/src/content/docs/tr/reference/code-signing.md +++ b/docs-site/src/content/docs/tr/reference/code-signing.md @@ -33,7 +33,7 @@ imzalanmasını yasaklar. OpenCodex, yalnızca programı kuran veya çalıştıran kişi bildirimi (kontrol paneli açılır penceresi veya etkileşimli CLI istemi) gördükten ve reddetmedikten sonra -`telemetry.opencodex.me` adresine günde bir anonim kullanım özeti (sürüm, platform, +`telemetry.opencodex.me` adresine günde bir anonim kullanım özeti (her gün değişen bir kurulum karması, sürüm, platform, sayımlar; istemler, çıktılar, anahtarlar, hesaplar, ana bilgisayar adları veya dosya yolları asla dahil edilmez) gönderir; bu bildirimde ve istediğiniz zaman `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` veya Proxy diff --git a/readme/README.fr.md b/readme/README.fr.md index ab4d40ccf78..6a3e6238560 100644 --- a/readme/README.fr.md +++ b/readme/README.fr.md @@ -496,7 +496,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## Avis de non-responsabilité diff --git a/readme/README.ja.md b/readme/README.ja.md index bce4e0b3d0b..0b418532863 100644 --- a/readme/README.ja.md +++ b/readme/README.ja.md @@ -490,7 +490,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## 免責事項 diff --git a/readme/README.ko.md b/readme/README.ko.md index ff86a86a490..34be23a82e7 100644 --- a/readme/README.ko.md +++ b/readme/README.ko.md @@ -473,7 +473,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## 면책 조항 diff --git a/readme/README.ru.md b/readme/README.ru.md index 5c06c7b974e..04e28cad530 100644 --- a/readme/README.ru.md +++ b/readme/README.ru.md @@ -502,7 +502,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## Отказ от ответственности diff --git a/readme/README.tr.md b/readme/README.tr.md index b9c34551830..c7714db0ad1 100644 --- a/readme/README.tr.md +++ b/readme/README.tr.md @@ -498,7 +498,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## Sorumluluk reddi diff --git a/readme/README.zh-CN.md b/readme/README.zh-CN.md index 8647bf6f080..457a0795645 100644 --- a/readme/README.zh-CN.md +++ b/readme/README.zh-CN.md @@ -471,7 +471,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## 免责声明 diff --git a/readme/README.zh-TW.md b/readme/README.zh-TW.md index 4a837326dfc..791299f09d4 100644 --- a/readme/README.zh-TW.md +++ b/readme/README.zh-TW.md @@ -470,7 +470,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## 免責聲明 diff --git a/readme/i18n-manifest.json b/readme/i18n-manifest.json index 3ace0af263d..c99ef3b9b27 100644 --- a/readme/i18n-manifest.json +++ b/readme/i18n-manifest.json @@ -6,43 +6,43 @@ "file": "readme/README.fr.md", "label": "Français", "docsPath": "fr", - "sourceSha256": "093e29d37473affad3f323896ca58d3659acf7e956c02b4bad2dbb26c82ab616" + "sourceSha256": "69d65e9a991c3a77cfb8f5336789f6ea315fa821daca88947124d5f333b502b5" }, "ko": { "file": "readme/README.ko.md", "label": "한국어", "docsPath": "ko", - "sourceSha256": "093e29d37473affad3f323896ca58d3659acf7e956c02b4bad2dbb26c82ab616" + "sourceSha256": "69d65e9a991c3a77cfb8f5336789f6ea315fa821daca88947124d5f333b502b5" }, "zh-CN": { "file": "readme/README.zh-CN.md", "label": "简体中文", "docsPath": "zh-cn", - "sourceSha256": "093e29d37473affad3f323896ca58d3659acf7e956c02b4bad2dbb26c82ab616" + "sourceSha256": "69d65e9a991c3a77cfb8f5336789f6ea315fa821daca88947124d5f333b502b5" }, "zh-TW": { "file": "readme/README.zh-TW.md", "label": "繁體中文", "docsPath": "zh-tw", - "sourceSha256": "093e29d37473affad3f323896ca58d3659acf7e956c02b4bad2dbb26c82ab616" + "sourceSha256": "69d65e9a991c3a77cfb8f5336789f6ea315fa821daca88947124d5f333b502b5" }, "ru": { "file": "readme/README.ru.md", "label": "Русский", "docsPath": "ru", - "sourceSha256": "093e29d37473affad3f323896ca58d3659acf7e956c02b4bad2dbb26c82ab616" + "sourceSha256": "69d65e9a991c3a77cfb8f5336789f6ea315fa821daca88947124d5f333b502b5" }, "ja": { "file": "readme/README.ja.md", "label": "日本語", "docsPath": "ja", - "sourceSha256": "093e29d37473affad3f323896ca58d3659acf7e956c02b4bad2dbb26c82ab616" + "sourceSha256": "69d65e9a991c3a77cfb8f5336789f6ea315fa821daca88947124d5f333b502b5" }, "tr": { "file": "readme/README.tr.md", "label": "Türkçe", "docsPath": "tr", - "sourceSha256": "093e29d37473affad3f323896ca58d3659acf7e956c02b4bad2dbb26c82ab616" + "sourceSha256": "69d65e9a991c3a77cfb8f5336789f6ea315fa821daca88947124d5f333b502b5" } } } From 94e15a66cac9b866bafa6ee3eafbb5700d19f1b9 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 20:54:20 +0900 Subject: [PATCH 26/37] fix(desktop): keep the carried stamp after Back and Next, fail closed on unrecognized installer values, and name the install hash in the disclosure From the round-4 reviews of #8348 (all P3): - Next restores the stamp the page started from when the final box equals the carried choice, so checking, going Back and unchecking again no longer issues a new stamp that re-applies the opt-out over a later dashboard enable. The simulator now runs repeated Next clicks. - Any OCX_TELEMETRY_CHOICE other than send (off, 0, opt-out, padding) becomes optout before it is stamped; compared with "" because a bare property holding the integer 0 evaluates false. The app treats any recorded value other than send as an opt-out too. - The page note says unchecking turns it off for all; the patch-route comment is corrected; a test asserts no license file, since the page's Next skips LicenseAgreementDlg. - src/telemetry/disclosure.ts (CLI prompt and installer page) names the per-day install hash, release channel and first-report flag, as the dashboard popup does. --- desktop/scripts/telemetry-installer-dialog.ts | 16 +++++--- desktop/src-tauri/src/installer_telemetry.rs | 22 +++++++++- desktop/src-tauri/wix/telemetry-choice.wxs | 16 +++++--- .../src/content/docs/guides/desktop-app.md | 2 +- src/telemetry/disclosure.ts | 2 +- structure/desktop-shell.md | 20 +++++---- .../desktop-telemetry-installer.test.ts | 41 ++++++++++++++++--- 7 files changed, 92 insertions(+), 27 deletions(-) diff --git a/desktop/scripts/telemetry-installer-dialog.ts b/desktop/scripts/telemetry-installer-dialog.ts index a8976bf1f97..d1ab5848f76 100644 --- a/desktop/scripts/telemetry-installer-dialog.ts +++ b/desktop/scripts/telemetry-installer-dialog.ts @@ -35,9 +35,9 @@ export const TELEMETRY_INSTALLER_STAMP_PROPERTY = "OCX_TELEMETRY_STAMP"; export const TELEMETRY_INSTALLER_CHOICE_PROPERTY = "OCX_TELEMETRY_CHOICE"; export const TELEMETRY_INSTALLER_CHECKBOX_PROPERTY = "OCX_TELEMETRY_SEND"; export const TELEMETRY_INSTALLER_CHECKBOX_TEXT = "Send anonymous usage statistics"; -// Shown under the checkbox: "send" is never acted on, and an opt-out applies to every account. +// Shown under the checkbox: "send" is never acted on, and a new opt-out applies to every account. export const TELEMETRY_INSTALLER_KEEP_NOTE = - "Checked keeps each account's current choice; unchecked turns it off for all."; + "Checked keeps each account's choice; unchecking turns it off for all."; export const TELEMETRY_INSTALLER_SEND = "send"; export const TELEMETRY_INSTALLER_OPT_OUT = "optout"; // Fixed so upgrades keep one component identity for the HKLM values (new when the key path moved to HKLM). @@ -114,12 +114,17 @@ export function renderTelemetryInstallerFragment(): string { + + ${TELEMETRY_INSTALLER_CHOICE_PROPERTY} <> "" AND ${TELEMETRY_INSTALLER_CHOICE_PROPERTY} ~<> "${TELEMETRY_INSTALLER_SEND}" ${TELEMETRY_INSTALLER_CHOICE_PROPERTY} AND NOT ${TELEMETRY_INSTALLER_STAMP_PROPERTY} NOT ${TELEMETRY_INSTALLER_CHOICE_PROPERTY} AND OCX_TELEMETRY_PREVIOUS_CHOICE NOT ${TELEMETRY_INSTALLER_STAMP_PROPERTY} AND OCX_TELEMETRY_PREVIOUS_STAMP - NOT ${TELEMETRY_INSTALLER_CHOICE_PROPERTY} + + ${TELEMETRY_INSTALLER_STAMP_PROPERTY} + NOT ${TELEMETRY_INSTALLER_CHOICE_PROPERTY} ${TELEMETRY_INSTALLER_CHOICE_PROPERTY} ~<> "${TELEMETRY_INSTALLER_OPT_OUT}" @@ -149,14 +154,15 @@ ${controls.join("\n")} ${TELEMETRY_INSTALLER_CHECKBOX_PROPERTY} = "1" NOT (${TELEMETRY_INSTALLER_CHECKBOX_PROPERTY} = "1") ${TELEMETRY_INSTALLER_CHOICE_PROPERTY} ~<> OCX_TELEMETRY_PREVIOUS_CHOICE + ${TELEMETRY_INSTALLER_CHOICE_PROPERTY} ~= OCX_TELEMETRY_PREVIOUS_CHOICE AND OCX_TELEMETRY_CARRIED_STAMP 1 1 - + NOT Installed NOT Installed diff --git a/desktop/src-tauri/src/installer_telemetry.rs b/desktop/src-tauri/src/installer_telemetry.rs index 965a8dead69..85a09dbec0f 100644 --- a/desktop/src-tauri/src/installer_telemetry.rs +++ b/desktop/src-tauri/src/installer_telemetry.rs @@ -44,7 +44,11 @@ pub const STAMP_VALUE: &str = "InstallerChoiceStamp"; pub const APPLIED_STAMP_VALUE: &str = "AppliedInstallerStamp"; /// Written by this app under HKCU: the stamp of the last per-user opt-out applied. pub const APPLIED_USER_STAMP_VALUE: &str = "AppliedUserInstallerStamp"; +// The installer's value for an opt-out, pinned against the WiX fragment by a test; the app treats any +// value other than `SEND` as an opt-out. +#[allow(dead_code)] pub const OPT_OUT: &str = "optout"; +pub const SEND: &str = "send"; static SIDECAR_ENV_OPT_OUT: AtomicBool = AtomicBool::new(false); @@ -108,8 +112,10 @@ pub enum Outcome { NotPersisted, } +/// Fail closed like the installer: any recorded value other than `send` (or nothing) is an opt-out. fn is_opt_out(choice: &str) -> bool { - choice.trim().eq_ignore_ascii_case(OPT_OUT) + let choice = choice.trim(); + !choice.is_empty() && !choice.eq_ignore_ascii_case(SEND) } /// The opt-outs this account has not applied yet, machine-wide first. `send` is never pending. @@ -411,7 +417,7 @@ mod tests { vec![(Scope::Machine, "send", "s1")], vec![(Scope::User, "send", "s1")], vec![(Scope::Machine, "send", "s1"), (Scope::User, "SEND", "s2")], - vec![(Scope::Machine, "", "s1"), (Scope::User, "off", "s2")], + vec![(Scope::Machine, "", "s1"), (Scope::User, " Send ", "s2")], ] { let registry = FakeRegistry::with(&choices); // Nothing runs: the bridge only ever runs `ocx telemetry disable`, and here not even that. @@ -425,6 +431,18 @@ mod tests { } } + #[test] + fn an_unrecognized_value_counts_as_an_opt_out() { + for value in ["off", "0", "opt-out", "OptOut", " optout "] { + let registry = FakeRegistry::with(&[(Scope::User, value, "u1")]); + assert_eq!( + launch(®istry, true), + (Outcome::Applied, true), + "{value:?}" + ); + } + } + #[test] fn machine_and_per_user_opt_outs_are_both_honoured_machine_first() { let registry = FakeRegistry::with(&[ diff --git a/desktop/src-tauri/wix/telemetry-choice.wxs b/desktop/src-tauri/wix/telemetry-choice.wxs index c3ef550c5f6..a3e82d4d834 100644 --- a/desktop/src-tauri/wix/telemetry-choice.wxs +++ b/desktop/src-tauri/wix/telemetry-choice.wxs @@ -18,12 +18,17 @@ + + OCX_TELEMETRY_CHOICE <> "" AND OCX_TELEMETRY_CHOICE ~<> "send" OCX_TELEMETRY_CHOICE AND NOT OCX_TELEMETRY_STAMP NOT OCX_TELEMETRY_CHOICE AND OCX_TELEMETRY_PREVIOUS_CHOICE NOT OCX_TELEMETRY_STAMP AND OCX_TELEMETRY_PREVIOUS_STAMP - NOT OCX_TELEMETRY_CHOICE + + OCX_TELEMETRY_STAMP + NOT OCX_TELEMETRY_CHOICE OCX_TELEMETRY_CHOICE ~<> "optout" @@ -40,7 +45,7 @@ - + @@ -49,7 +54,7 @@ Read the full privacy policy]]> - + 1 @@ -57,14 +62,15 @@ OCX_TELEMETRY_SEND = "1" NOT (OCX_TELEMETRY_SEND = "1") OCX_TELEMETRY_CHOICE ~<> OCX_TELEMETRY_PREVIOUS_CHOICE + OCX_TELEMETRY_CHOICE ~= OCX_TELEMETRY_PREVIOUS_CHOICE AND OCX_TELEMETRY_CARRIED_STAMP 1 1 - + NOT Installed NOT Installed diff --git a/docs-site/src/content/docs/guides/desktop-app.md b/docs-site/src/content/docs/guides/desktop-app.md index 2b5384977b9..08421ad769a 100644 --- a/docs-site/src/content/docs/guides/desktop-app.md +++ b/docs-site/src/content/docs/guides/desktop-app.md @@ -41,7 +41,7 @@ msiexec /i OpenCodex--windows-x64.msi /qn OCX_TELEMETRY_CHOICE=optout ``` The installer needs administrator rights and records the choice for the whole computer. -`optout` is matched in any letter case; any other value counts as `send`. +Only `send` keeps telemetry on; `optout`, in any letter case, and any other value (`off`, `0`) turn it off. Leaving the box checked does not start sending by itself: the dashboard still shows its one-time notice first, and a checked box never turns telemetry back on for an account that turned it off. diff --git a/src/telemetry/disclosure.ts b/src/telemetry/disclosure.ts index 96c1149e8c2..469702d5603 100644 --- a/src/telemetry/disclosure.ts +++ b/src/telemetry/disclosure.ts @@ -11,7 +11,7 @@ export const TELEMETRY_POLICY_URL = "https://opencodex.me/reference/code-signing export const TELEMETRY_DISCLOSURE_HEADING = "Anonymous daily usage telemetry:"; export const TELEMETRY_DISCLOSURE_POINTS: readonly string[] = Object.freeze([ - "Sent once per day to telemetry.opencodex.me: version, OS/arch, install channel, provider kind, coarse model family, active minutes, and request/token/error counts.", + "Sent daily to telemetry.opencodex.me: a daily-changing install hash, version, release channel, OS/arch, install channel, first-report flag, provider kind, model family, active minutes, request/token/error counts.", "Never sent: prompts, model outputs, request/response bodies, API keys, OAuth tokens, account IDs or emails, custom base URLs, hostnames, file paths, or error text.", "Public aggregates: project-wide totals are published at opencodex.me/dashboard.", "See the exact payload: ocx telemetry status --json", diff --git a/structure/desktop-shell.md b/structure/desktop-shell.md index 8df3a5cb382..bcc7eed7fbd 100644 --- a/structure/desktop-shell.md +++ b/structure/desktop-shell.md @@ -378,7 +378,9 @@ The choice is machine-wide: `HKLM\Software\OpenCodex\Telemetry` holds `Installer or `optout`) and `InstallerChoiceStamp` (`[Date] [Time]` of the decision). HKLM is what lets a silent install run as SYSTEM (Intune, SCCM) or approved by another administrator reach the people who use the app; Tauri's MSI is per-machine and needs elevation, so there is no per-user install. -`OCX_TELEMETRY_CHOICE` has no default. A value passed on the command line gets a new stamp +`OCX_TELEMETRY_CHOICE` has no default. A command-line value other than `send` (`off`, `0`, a typo) +becomes `optout` first (`OcxTelemetryNormalizeChoice`, compared with `""` because a bare property +whose value is the integer 0 evaluates false). A value passed on the command line gets a new stamp (`OcxTelemetryStampExplicit`, before `AppSearch`), but only while no stamp is set, so the stamp the UI phase hands to the install phase is never replaced. Otherwise `AppSearch` reads the previous choice and stamp and carries them (`OcxTelemetryKeepChoice`, `OcxTelemetryKeepStamp`). That keeps @@ -387,16 +389,20 @@ an opt-out, with its old stamp, through in-app updates (`/passive`, no UI sequen install defaults to `send`. The box starts checked unless the install already carries an opt-out (compared case-insensitively with `~<>`). Pressing Next records the box, and issues a new stamp only when the box differs from the carried choice (`OCX_TELEMETRY_CHOICE ~<> OCX_TELEMETRY_PREVIOUS_CHOICE`), -so continuing past a carried opt-out does not apply it again over a later dashboard enable. A -page note says leaving the box checked keeps the current choice, because `send` is never acted on. -The installer test evaluates the fragment's conditions through both MSI phases for fresh, silent, -reduced-UI and upgrade installs. +so continuing past a carried opt-out does not apply it again over a later dashboard enable; when +the box ends equal to the carried choice after Back and another Next, the stamp the page started +from (`OCX_TELEMETRY_CARRIED_STAMP`, saved in the UI sequence) is restored. A page note says +leaving the box checked keeps the current choice, because `send` is never acted on. The installer +test evaluates the fragment's conditions through both MSI phases for fresh, silent, reduced-UI and +upgrade installs and repeated Next clicks. The page's Next goes straight to `InstallDirDlg`, so the +test also asserts no license file is configured (Tauri routes through `LicenseAgreementDlg` only then). `desktop/src-tauri/src/installer_telemetry.rs` runs in the startup sequence before `resolve::run`, so before the app resolves, attaches to or starts any proxy, with its own 10-second cap. It reads the HKLM choice (64-bit view, then 32-bit) and the same two values under the user's own -`HKCU\Software\OpenCodex\Telemetry`, which an administrator can set for one account. An `optout` -in any letter case is pending when its stamp differs from the one this user last applied for that +`HKCU\Software\OpenCodex\Telemetry`, which an administrator can set for one account. Any value +other than `send` counts as an opt-out, as in the installer; it is pending when its stamp differs +from the one this user last applied for that scope: `AppliedInstallerStamp` for HKLM and `AppliedUserInstallerStamp` for HKCU, both written under the user's HKCU key. Pending opt-outs run the bundled `ocx telemetry disable --json` once (on a fresh install it holds the decline as `pendingDecision` in `telemetry-state.json` without creating diff --git a/tests/clients/desktop-telemetry-installer.test.ts b/tests/clients/desktop-telemetry-installer.test.ts index 5fdeca1dcaa..efea33ee3fe 100644 --- a/tests/clients/desktop-telemetry-installer.test.ts +++ b/tests/clients/desktop-telemetry-installer.test.ts @@ -109,6 +109,8 @@ interface InstallRun { ui: "full" | "reduced" | "none"; /** In a full-UI run: the box state the person leaves before clicking Next (default: as preselected). */ box?: boolean; + /** In a full-UI run: the box state at each Next click, with Back in between (overrides `box`). */ + clicks?: boolean[]; } /** @@ -119,7 +121,7 @@ interface InstallRun { function simulateInstall(wxs: string, run: InstallRun): { choice: string; stamp: string; boxPreselected?: boolean } { const actions = [...wxs.matchAll(/([^<]*)<\/SetProperty>/g)] .map(([, action, id, template, sequence, condition]) => ({ action: action!, id: id!, template: template!, sequence: sequence!, condition: condition! })); - if (actions.length !== 5) throw new Error(`expected 5 SetProperty actions, found ${actions.length}`); + if (actions.length !== 7) throw new Error(`expected 7 SetProperty actions, found ${actions.length}`); const secure = [...wxs.matchAll(//g)].map(match => match[1]!); const phase = (props: Props, sequence: "ui" | "execute", clock: string): Props => { for (const step of actions) { @@ -140,12 +142,16 @@ function simulateInstall(wxs: string, run: InstallRun): { choice: string; stamp: const ui = phase({ ...commandLine }, "ui", "11/8/2026 01:00:00"); if (run.ui === "full") { boxPreselected = ui.OCX_TELEMETRY_SEND === "1"; - const box = run.box ?? boxPreselected; - if (box) ui.OCX_TELEMETRY_SEND = "1"; - else delete ui.OCX_TELEMETRY_SEND; const next = //.exec(wxs)?.[0] ?? ""; - for (const [, property, template, condition] of next.matchAll(/([^<]*)<\/Publish>/g)) { - if (msiCondition(condition!, ui)) ui[property!] = msiFormat(template!, ui, "11/8/2026 01:00:00"); + // Each Next click (Back from the next page returns here) has its own time. + const clicks = run.clicks ?? [run.box ?? boxPreselected]; + for (const [index, box] of clicks.entries()) { + if (box) ui.OCX_TELEMETRY_SEND = "1"; + else delete ui.OCX_TELEMETRY_SEND; + const clock = index === 0 ? "11/8/2026 01:00:00" : `11/8/2026 01:0${index}:00`; + for (const [, property, template, condition] of next.matchAll(/([^<]*)<\/Publish>/g)) { + if (msiCondition(condition!, ui)) ui[property!] = msiFormat(template!, ui, clock); + } } } // Only Secure public properties reach the elevated install phase. @@ -259,6 +265,24 @@ describe("Windows installer telemetry choice", () => { test("a silent install without a choice defaults to send and records no stamp", () => { expect(simulateInstall(wxs, { ui: "none" })).toEqual({ choice: send, stamp: "", boxPreselected: undefined }); }); + + test("going Back and putting the box back keeps the carried stamp", () => { + // Checked, Next, Back, unchecked again, Next: the final choice equals the carried one. + expect(simulateInstall(wxs, { ui: "full", clicks: [true, false], previous: { choice: optOut, stamp: "s1" } })).toEqual({ choice: optOut, stamp: "s1", boxPreselected: false }); + expect(simulateInstall(wxs, { ui: "full", clicks: [false, true], previous: { choice: send, stamp: "s1" } })).toEqual({ choice: send, stamp: "s1", boxPreselected: true }); + // A real change is still a new decision, stamped at the Next that made it. + expect(simulateInstall(wxs, { ui: "full", clicks: [true, false, true], previous: { choice: optOut, stamp: "s1" } })).toEqual({ choice: send, stamp: "11/8/2026 01:02:00", boxPreselected: false }); + // A command-line opt-out keeps its own new stamp when the person leaves it as it is. + expect(simulateInstall(wxs, { ui: "full", clicks: [true, false], commandLine: { OCX_TELEMETRY_CHOICE: optOut }, previous: { choice: optOut, stamp: "s1" } })).toEqual({ choice: optOut, stamp: UI_STAMP, boxPreselected: false }); + }); + + test("any command-line value other than send counts as an opt-out", () => { + for (const value of ["off", "0", "opt-out", "disabled", " optout", "OPTOUT", " send"]) { + expect(simulateInstall(wxs, { ui: "none", commandLine: { OCX_TELEMETRY_CHOICE: value } }), value).toEqual({ choice: optOut, stamp: INSTALL_STAMP, boxPreselected: undefined }); + expect(simulateInstall(wxs, { ui: "full", commandLine: { OCX_TELEMETRY_CHOICE: value } }).boxPreselected, value).toBe(false); + } + expect(simulateInstall(wxs, { ui: "none", commandLine: { OCX_TELEMETRY_CHOICE: "Send" } }).choice).toBe("Send"); + }); }); test("the dialog sits between the welcome and install-directory pages of Tauri's sequence", () => { @@ -271,6 +295,11 @@ describe("Windows installer telemetry choice", () => { const dialog = //.exec(wxs)?.[0] ?? ""; expect(dialog).toMatch(/ { From e8f8662d27ea3dca7fbca28e0b5ddd1569bc1e40 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 21:12:08 +0900 Subject: [PATCH 27/37] fix(desktop): schedule the installer's default choice after an action present in both sequences From the final privacy review of #8348: - [P1] OcxTelemetryDefaultChoice (both sequences) was scheduled after OcxTelemetryCarriedStamp (UI sequence only). WiX 3.14.1's linker rejects a parent missing from a sequence ("Found an ActionRow with a non-existent After action"), so the MSI would not build. It follows OcxTelemetryKeepStamp again; a new test checks that every Before/After parent is a standard action or scheduled in each sequence its action runs in (it fails on 94e15a66ca). - [P3] The box starts checked only when the choice is send, so a carried value other than send (a policy pre-seed such as "off") starts unchecked instead of turning into send on Next. - [P3] The app only runs before every proxy on a first install: a service from ocx service install can start a proxy at logon, and an opt-out from a later manual upgrade reaches it when the app next starts. Documented in the module, structure/desktop-shell.md and the desktop guide. --- desktop/scripts/telemetry-installer-dialog.ts | 7 +++--- desktop/src-tauri/src/installer_telemetry.rs | 5 +++- desktop/src-tauri/wix/telemetry-choice.wxs | 7 +++--- .../src/content/docs/guides/desktop-app.md | 5 ++++ structure/desktop-shell.md | 6 +++-- .../desktop-telemetry-installer.test.ts | 25 +++++++++++++++++-- 6 files changed, 44 insertions(+), 11 deletions(-) diff --git a/desktop/scripts/telemetry-installer-dialog.ts b/desktop/scripts/telemetry-installer-dialog.ts index d1ab5848f76..b626ed03ca2 100644 --- a/desktop/scripts/telemetry-installer-dialog.ts +++ b/desktop/scripts/telemetry-installer-dialog.ts @@ -124,9 +124,10 @@ export function renderTelemetryInstallerFragment(): string { NOT ${TELEMETRY_INSTALLER_STAMP_PROPERTY} AND OCX_TELEMETRY_PREVIOUS_STAMP ${TELEMETRY_INSTALLER_STAMP_PROPERTY} - NOT ${TELEMETRY_INSTALLER_CHOICE_PROPERTY} - - ${TELEMETRY_INSTALLER_CHOICE_PROPERTY} ~<> "${TELEMETRY_INSTALLER_OPT_OUT}" + + NOT ${TELEMETRY_INSTALLER_CHOICE_PROPERTY} + + ${TELEMETRY_INSTALLER_CHOICE_PROPERTY} ~= "${TELEMETRY_INSTALLER_SEND}" diff --git a/desktop/src-tauri/src/installer_telemetry.rs b/desktop/src-tauri/src/installer_telemetry.rs index 85a09dbec0f..83551c0fdc7 100644 --- a/desktop/src-tauri/src/installer_telemetry.rs +++ b/desktop/src-tauri/src/installer_telemetry.rs @@ -25,7 +25,10 @@ //! override a dashboard enable made in between; that errs toward privacy. //! //! An MSI install does not put `ocx.exe` on PATH; the terminal command is installed by this app. -//! The app therefore runs before any proxy from that install can. +//! On a first install the app therefore runs before any proxy from that install can. After the app +//! has installed the terminal command, `ocx service install` can register a logon task that starts a +//! proxy without the app; an opt-out recorded by a later manual upgrade reaches that proxy's config +//! when the app next starts, and until then the service may still make its daily send. use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; diff --git a/desktop/src-tauri/wix/telemetry-choice.wxs b/desktop/src-tauri/wix/telemetry-choice.wxs index a3e82d4d834..1b59751d049 100644 --- a/desktop/src-tauri/wix/telemetry-choice.wxs +++ b/desktop/src-tauri/wix/telemetry-choice.wxs @@ -28,9 +28,10 @@ NOT OCX_TELEMETRY_STAMP AND OCX_TELEMETRY_PREVIOUS_STAMP OCX_TELEMETRY_STAMP - NOT OCX_TELEMETRY_CHOICE - - OCX_TELEMETRY_CHOICE ~<> "optout" + + NOT OCX_TELEMETRY_CHOICE + + OCX_TELEMETRY_CHOICE ~= "send" diff --git a/docs-site/src/content/docs/guides/desktop-app.md b/docs-site/src/content/docs/guides/desktop-app.md index 08421ad769a..3c714002387 100644 --- a/docs-site/src/content/docs/guides/desktop-app.md +++ b/docs-site/src/content/docs/guides/desktop-app.md @@ -51,6 +51,11 @@ if you opted out before. Continuing without changing the box keeps that earlier so it is not applied again over a choice you made in the dashboard since; changing the box records a new decision. +If you installed the background service with `ocx service install`, it can start a proxy at logon +without the app. An opt-out recorded by a later installer reaches that service's config the next +time the app starts; until then the service may still send its daily summary. To stop it at once, +run `ocx telemetry disable`. + ### Linux Download `OpenCodex--linux-x86_64.AppImage` or diff --git a/structure/desktop-shell.md b/structure/desktop-shell.md index bcc7eed7fbd..0f21a7ccabe 100644 --- a/structure/desktop-shell.md +++ b/structure/desktop-shell.md @@ -416,8 +416,10 @@ replace with an in-memory registry. If the decline did not persist, `sidecar::st (`RETRY_DELAYS`, up to about 13 minutes) and on the next launch; a proxy the app only attaches to follows the decline once a retry succeeds. If the stamp could not be recorded, the next launch saves the decline again, which can override a dashboard enable made in between; that errs toward -privacy. An MSI install puts no `ocx.exe` on PATH; the terminal command comes from this app, so the -app always runs before any proxy from that install. +privacy. An MSI install puts no `ocx.exe` on PATH; the terminal command comes from this app, so on a +first install the app runs before any proxy from that install. Once the app has installed the +terminal command, `ocx service install` can register a logon task that starts a proxy without the +app; an opt-out recorded by a later manual upgrade reaches that proxy when the app next starts. ### Linux packaged-shell acceptance diff --git a/tests/clients/desktop-telemetry-installer.test.ts b/tests/clients/desktop-telemetry-installer.test.ts index efea33ee3fe..ccfe3dd926a 100644 --- a/tests/clients/desktop-telemetry-installer.test.ts +++ b/tests/clients/desktop-telemetry-installer.test.ts @@ -179,9 +179,9 @@ describe("Windows installer telemetry choice", () => { test("the dialog links the policy and offers a disable option that is checked by default", () => { expect(wxs).toContain(`Read the full privacy policy`); expect(wxs).toMatch(new RegExp(`Type="CheckBox"[^>]*Property="${TELEMETRY_INSTALLER_CHECKBOX_PROPERTY}" CheckBoxValue="1" Text="${TELEMETRY_INSTALLER_CHECKBOX_TEXT}"`)); - // The box starts checked unless this install already carries an opt-out (command line or upgrade). + // The box starts checked only when the choice is send, so any carried opt-out (command line or upgrade) starts unchecked. expect(wxs).toContain( - `${TELEMETRY_INSTALLER_CHOICE_PROPERTY} ~<> "${TELEMETRY_INSTALLER_OPT_OUT}"`, + `${TELEMETRY_INSTALLER_CHOICE_PROPERTY} ~= "send"`, ); // A checked box writes "send", which is never acted on, and the page says so. expect(wxs).toContain(`Text="${TELEMETRY_INSTALLER_KEEP_NOTE}"`); @@ -214,6 +214,20 @@ describe("Windows installer telemetry choice", () => { expect(wxs).toContain(`Action="OcxTelemetryKeepChoice" Id="${TELEMETRY_INSTALLER_CHOICE_PROPERTY}" Value="[OCX_TELEMETRY_PREVIOUS_CHOICE]" After="AppSearch"`); }); + test("every scheduling parent exists in each sequence its action runs in, as WiX's linker requires", () => { + // Standard actions present in both InstallUISequence and InstallExecuteSequence. + const standard = new Set(["AppSearch"]); + const actions = [...wxs.matchAll(//g)] + .map(([, action, , parent, sequence]) => ({ action: action!, parent: parent!, sequences: sequence === "both" ? ["ui", "execute"] : [sequence!] })); + expect(actions.length).toBeGreaterThan(0); + for (const { action, parent, sequences } of actions) { + for (const sequence of sequences) { + const parentScheduled = standard.has(parent) || actions.some(other => other.action === parent && other.sequences.includes(sequence)); + expect(parentScheduled, `${action} (${sequence}) is scheduled after ${parent}, which is not in that sequence`).toBe(true); + } + } + }); + describe("what each kind of install records in HKLM", () => { const optOut = TELEMETRY_INSTALLER_OPT_OUT; const send = "send"; @@ -283,6 +297,13 @@ describe("Windows installer telemetry choice", () => { } expect(simulateInstall(wxs, { ui: "none", commandLine: { OCX_TELEMETRY_CHOICE: "Send" } }).choice).toBe("Send"); }); + + test("a carried value other than send also preselects an unchecked box", () => { + // Written by something other than this MSI (a policy pre-seed): the app already reads it as an opt-out. + expect(simulateInstall(wxs, { ui: "full", previous: { choice: "off", stamp: "s1" } })).toEqual({ choice: optOut, stamp: UI_STAMP, boxPreselected: false }); + expect(simulateInstall(wxs, { ui: "none", previous: { choice: "off", stamp: "s1" } })).toEqual({ choice: "off", stamp: "s1", boxPreselected: undefined }); + expect(simulateInstall(wxs, { ui: "full", previous: { choice: "SEND", stamp: "s1" } }).boxPreselected).toBe(true); + }); }); test("the dialog sits between the welcome and install-directory pages of Tauri's sequence", () => { From 37d09e9090730642467c0f6f9c3bc9d3d1f06523 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 21:23:35 +0900 Subject: [PATCH 28/37] fix(config,telemetry): a save that creates config.json never introduces a telemetry decision From the final privacy review of #8300: - [P2] Without config.json, a whole-config save wrote the caller's in-memory telemetry block. A dashboard accept adopted into a config-less proxy, then "ocx telemetry disable" (held as the pending decision), then any settings save created config.json with the stale accept, and the config's decision outranks the pending one, so sending resumed. Without a readable config.json only a snapshot decline survives now; the held decision or the notice applies. - [P3] A recorded decision rebuilds an unreadable telemetry-state.json, which otherwise paused sending for good; the status line says how. Any rebuild keeps every day through the UTC day six hours from now unsent (TELEMETRY_ENV_OPT_OUT_REFRESH_MS), the window in which a forgotten opted-out proxy records itself again. - [P3] The CLI notice names the per-day install hash, release channel and first-report flag, and the privacy statement (README and its seven copies, code-signing en/tr) lists every field sent. Failing-first tests in tests/config/telemetry-live-save.test.ts and tests/usage/usage-telemetry-client.test.ts; the live-save tests now create a config holding a decision the way a person's decision reaches it. --- README.md | 2 +- .../content/docs/reference/code-signing.md | 15 +++--- .../content/docs/tr/reference/code-signing.md | 23 +++++---- readme/README.fr.md | 2 +- readme/README.ja.md | 2 +- readme/README.ko.md | 2 +- readme/README.ru.md | 2 +- readme/README.tr.md | 2 +- readme/README.zh-CN.md | 2 +- readme/README.zh-TW.md | 2 +- readme/i18n-manifest.json | 14 +++--- src/cli/telemetry.ts | 6 ++- src/config/telemetry-carry.ts | 19 ++----- src/server/background-lifecycle.ts | 7 +-- src/telemetry/state.ts | 20 ++++++-- structure/telemetry.md | 13 +++-- tests/config/telemetry-live-save.test.ts | 50 +++++++++++++++++-- tests/usage/usage-telemetry-client.test.ts | 28 +++++++++++ 18 files changed, 142 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index 145b51eb1d5..89fe809b41d 100644 --- a/README.md +++ b/README.md @@ -498,7 +498,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, version, release channel, platform, install channel, provider kind, coarse model family, active minutes and request, token and error counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## Disclaimer diff --git a/docs-site/src/content/docs/reference/code-signing.md b/docs-site/src/content/docs/reference/code-signing.md index 6bb4feb7224..5de0a2d2fb9 100644 --- a/docs-site/src/content/docs/reference/code-signing.md +++ b/docs-site/src/content/docs/reference/code-signing.md @@ -31,13 +31,14 @@ SignPath Foundation policy forbids signing them with the project certificate. ### Privacy OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, -version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to -`telemetry.opencodex.me` only after the person installing or operating it has seen the notice -(dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that -notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy -details; project-wide aggregates are public; otherwise data goes only where the user configures. -In practice, OpenCodex forwards requests to the providers you configure. To look for new -versions it also reads release metadata from GitHub and the npm registry, and those update +version, release channel, platform, install channel, provider kind, coarse model family, active +minutes and request, token and error counts; never prompts, outputs, keys, accounts, hosts or +paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the +notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in +that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or +Proxy details; project-wide aggregates are public; otherwise data goes only where the user +configures. In practice, OpenCodex forwards requests to the providers you configure. To look for +new versions it also reads release metadata from GitHub and the npm registry, and those update checks carry none of your prompts, credentials or usage data. ## Verifying a download diff --git a/docs-site/src/content/docs/tr/reference/code-signing.md b/docs-site/src/content/docs/tr/reference/code-signing.md index 97b10cb7e91..304305ab1d5 100644 --- a/docs-site/src/content/docs/tr/reference/code-signing.md +++ b/docs-site/src/content/docs/tr/reference/code-signing.md @@ -31,17 +31,18 @@ imzalanmasını yasaklar. ### Gizlilik -OpenCodex, yalnızca programı kuran veya çalıştıran kişi bildirimi (kontrol paneli -açılır penceresi veya etkileşimli CLI istemi) gördükten ve reddetmedikten sonra -`telemetry.opencodex.me` adresine günde bir anonim kullanım özeti (her gün değişen bir kurulum karması, sürüm, platform, -sayımlar; istemler, çıktılar, anahtarlar, hesaplar, ana bilgisayar adları veya dosya -yolları asla dahil edilmez) gönderir; bu bildirimde ve istediğiniz -zaman `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` veya Proxy -details üzerinden kapatılabilir; proje genelindeki toplamlar herkese açıktır; bunun -dışında veriler yalnızca kullanıcının yapılandırdığı yerlere gider. Uygulamada -OpenCodex, istekleri yapılandırdığınız sağlayıcılara iletir. Yeni sürümler için -GitHub ve npm kayıt deposundan yayın meta verilerini de okur; bu güncelleme -kontrolleri istemlerinizi, kimlik bilgilerinizi veya kullanım verilerinizi taşımaz. +OpenCodex, yalnızca programı kuran veya çalıştıran kişi bildirimi (kontrol paneli açılır +penceresi veya etkileşimli CLI istemi) gördükten ve reddetmedikten sonra +`telemetry.opencodex.me` adresine günde bir anonim kullanım özeti (her gün değişen bir kurulum +karması, sürüm, sürüm kanalı, platform, kurulum kanalı, sağlayıcı türü, kaba model ailesi, etkin +dakikalar ve istek, jeton ve hata sayımları; istemler, çıktılar, anahtarlar, hesaplar, ana +bilgisayar adları veya dosya yolları asla dahil edilmez) gönderir; bu bildirimde ve istediğiniz +zaman `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` veya Proxy details üzerinden +kapatılabilir; proje genelindeki toplamlar herkese açıktır; bunun dışında veriler yalnızca +kullanıcının yapılandırdığı yerlere gider. Uygulamada OpenCodex, istekleri yapılandırdığınız +sağlayıcılara iletir. Yeni sürümler için GitHub ve npm kayıt deposundan yayın meta verilerini de +okur; bu güncelleme kontrolleri istemlerinizi, kimlik bilgilerinizi veya kullanım verilerinizi +taşımaz. ## İndirmeyi doğrulama diff --git a/readme/README.fr.md b/readme/README.fr.md index 6a3e6238560..50d81f0e0b9 100644 --- a/readme/README.fr.md +++ b/readme/README.fr.md @@ -496,7 +496,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, version, release channel, platform, install channel, provider kind, coarse model family, active minutes and request, token and error counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## Avis de non-responsabilité diff --git a/readme/README.ja.md b/readme/README.ja.md index 0b418532863..c007b9bf8d7 100644 --- a/readme/README.ja.md +++ b/readme/README.ja.md @@ -490,7 +490,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, version, release channel, platform, install channel, provider kind, coarse model family, active minutes and request, token and error counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## 免責事項 diff --git a/readme/README.ko.md b/readme/README.ko.md index 34be23a82e7..b9042de68e6 100644 --- a/readme/README.ko.md +++ b/readme/README.ko.md @@ -473,7 +473,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, version, release channel, platform, install channel, provider kind, coarse model family, active minutes and request, token and error counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## 면책 조항 diff --git a/readme/README.ru.md b/readme/README.ru.md index 04e28cad530..63768e4a02f 100644 --- a/readme/README.ru.md +++ b/readme/README.ru.md @@ -502,7 +502,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, version, release channel, platform, install channel, provider kind, coarse model family, active minutes and request, token and error counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## Отказ от ответственности diff --git a/readme/README.tr.md b/readme/README.tr.md index c7714db0ad1..1d7567f80c0 100644 --- a/readme/README.tr.md +++ b/readme/README.tr.md @@ -498,7 +498,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, version, release channel, platform, install channel, provider kind, coarse model family, active minutes and request, token and error counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## Sorumluluk reddi diff --git a/readme/README.zh-CN.md b/readme/README.zh-CN.md index 457a0795645..787dfecdf9e 100644 --- a/readme/README.zh-CN.md +++ b/readme/README.zh-CN.md @@ -471,7 +471,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, version, release channel, platform, install channel, provider kind, coarse model family, active minutes and request, token and error counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## 免责声明 diff --git a/readme/README.zh-TW.md b/readme/README.zh-TW.md index 791299f09d4..e2d194a64fb 100644 --- a/readme/README.zh-TW.md +++ b/readme/README.zh-TW.md @@ -470,7 +470,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. +- Privacy: OpenCodex sends one anonymous daily usage summary (a per-day install hash that changes daily, version, release channel, platform, install channel, provider kind, coarse model family, active minutes and request, token and error counts; never prompts, outputs, keys, accounts, hosts or paths) to `telemetry.opencodex.me` only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off in that notice and anytime with `ocx telemetry disable`, `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1` or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. ## 免責聲明 diff --git a/readme/i18n-manifest.json b/readme/i18n-manifest.json index c99ef3b9b27..d8a22a78e1f 100644 --- a/readme/i18n-manifest.json +++ b/readme/i18n-manifest.json @@ -6,43 +6,43 @@ "file": "readme/README.fr.md", "label": "Français", "docsPath": "fr", - "sourceSha256": "69d65e9a991c3a77cfb8f5336789f6ea315fa821daca88947124d5f333b502b5" + "sourceSha256": "a5a6e863d29242ed82a9708c5359e585d6a16d88bfc5f92a8c151f632ed7a2c0" }, "ko": { "file": "readme/README.ko.md", "label": "한국어", "docsPath": "ko", - "sourceSha256": "69d65e9a991c3a77cfb8f5336789f6ea315fa821daca88947124d5f333b502b5" + "sourceSha256": "a5a6e863d29242ed82a9708c5359e585d6a16d88bfc5f92a8c151f632ed7a2c0" }, "zh-CN": { "file": "readme/README.zh-CN.md", "label": "简体中文", "docsPath": "zh-cn", - "sourceSha256": "69d65e9a991c3a77cfb8f5336789f6ea315fa821daca88947124d5f333b502b5" + "sourceSha256": "a5a6e863d29242ed82a9708c5359e585d6a16d88bfc5f92a8c151f632ed7a2c0" }, "zh-TW": { "file": "readme/README.zh-TW.md", "label": "繁體中文", "docsPath": "zh-tw", - "sourceSha256": "69d65e9a991c3a77cfb8f5336789f6ea315fa821daca88947124d5f333b502b5" + "sourceSha256": "a5a6e863d29242ed82a9708c5359e585d6a16d88bfc5f92a8c151f632ed7a2c0" }, "ru": { "file": "readme/README.ru.md", "label": "Русский", "docsPath": "ru", - "sourceSha256": "69d65e9a991c3a77cfb8f5336789f6ea315fa821daca88947124d5f333b502b5" + "sourceSha256": "a5a6e863d29242ed82a9708c5359e585d6a16d88bfc5f92a8c151f632ed7a2c0" }, "ja": { "file": "readme/README.ja.md", "label": "日本語", "docsPath": "ja", - "sourceSha256": "69d65e9a991c3a77cfb8f5336789f6ea315fa821daca88947124d5f333b502b5" + "sourceSha256": "a5a6e863d29242ed82a9708c5359e585d6a16d88bfc5f92a8c151f632ed7a2c0" }, "tr": { "file": "readme/README.tr.md", "label": "Türkçe", "docsPath": "tr", - "sourceSha256": "69d65e9a991c3a77cfb8f5336789f6ea315fa821daca88947124d5f333b502b5" + "sourceSha256": "a5a6e863d29242ed82a9708c5359e585d6a16d88bfc5f92a8c151f632ed7a2c0" } } } diff --git a/src/cli/telemetry.ts b/src/cli/telemetry.ts index 65269dccbbb..8cbc0be3d65 100644 --- a/src/cli/telemetry.ts +++ b/src/cli/telemetry.ts @@ -37,7 +37,7 @@ const DEFERRAL_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; export const TELEMETRY_CLI_DISCLOSURE_LINES = Object.freeze([ " Anonymous daily usage telemetry:", - " • Sent once per day to telemetry.opencodex.me: version, OS/arch, install channel, provider kind, coarse model family, active minutes, and request/token/error counts.", + " • Sent daily to telemetry.opencodex.me: a daily-changing install hash, version, release channel, OS/arch, install channel, first-report flag, provider kind, model family, active minutes, request/token/error counts.", " • Never sent: prompts, model outputs, request/response bodies, API keys, OAuth tokens, account IDs or emails, custom base URLs, hostnames, file paths, or error text.", " • Public aggregates: project-wide totals are published at opencodex.me/dashboard.", " • See the exact payload: ocx telemetry status --json", @@ -165,7 +165,9 @@ function formatStatusLines(snapshot: TelemetryStatusSnapshot): string[] { ...(snapshot.envOverride ? [`Environment override: ${snapshot.envOverride}`] : []), `Install channel: ${snapshot.installChannel}`, `Last sent day: ${snapshot.lastSentDay ?? "never"}`, - ...(snapshot.stateUnreadable ? ["Sending paused: telemetry-state.json cannot be read, so nothing is sent."] : []), + ...(snapshot.stateUnreadable + ? ["Sending paused: telemetry-state.json cannot be read. Choosing again (ocx telemetry enable or disable) rebuilds it."] + : []), `Preview eligible: ${snapshot.previewEligible ? "yes" : "no"}`, snapshot.previewEligible ? `Preview (${snapshot.preview.day}, next eligible send):` diff --git a/src/config/telemetry-carry.ts b/src/config/telemetry-carry.ts index 44a749368cc..e70737c0fdd 100644 --- a/src/config/telemetry-carry.ts +++ b/src/config/telemetry-carry.ts @@ -1,6 +1,4 @@ -import { existsSync } from "node:fs"; import type { OcxConfig } from "../types"; -import { getConfigPath } from "./paths"; import { telemetryConfigSchema } from "./schema/leaf-validators"; /** @@ -8,9 +6,10 @@ import { telemetryConfigSchema } from "./schema/leaf-validators"; * consent route write it there, often while another process holds an older snapshot. A whole-config * save therefore writes the on-disk block (validated; a malformed hand edit is dropped, as a load * would drop it) instead of the snapshot's, so a decision is never dropped or rolled back. Without a - * file on disk the snapshot's block is kept. A file that exists but cannot be read may hold a - * decline the snapshot never saw, so only a decline survives from the snapshot; anything else is - * dropped and the install waits for the notice again. + * readable file, a whole-config save never introduces a decision: before `config.json` exists a + * decision is held in telemetry-state.json (and another process may have replaced it there), and a + * file that cannot be read may hold a decline the snapshot never saw. Only a snapshot decline + * survives; anything else is dropped, and the held decision or the notice applies. * * Returns whether the block was taken from disk and the value now in `target`. */ @@ -19,7 +18,6 @@ export function carryPersistedTelemetry( onDisk: Record | undefined, ): { carried: boolean; value: OcxConfig["telemetry"] } { if (onDisk === undefined) { - if (!configFileExists()) return { carried: false, value: target.telemetry }; if (target.telemetry?.decision === "declined") return { carried: true, value: target.telemetry }; delete target.telemetry; return { carried: true, value: undefined }; @@ -30,12 +28,3 @@ export function carryPersistedTelemetry( else target.telemetry = value; return { carried: true, value }; } - -function configFileExists(): boolean { - try { - return existsSync(getConfigPath()); - } catch { - // Cannot tell: treat it as present, so an unread decision is not overwritten. - return true; - } -} diff --git a/src/server/background-lifecycle.ts b/src/server/background-lifecycle.ts index f273fe957d8..72d267bb150 100644 --- a/src/server/background-lifecycle.ts +++ b/src/server/background-lifecycle.ts @@ -35,7 +35,7 @@ import { import { ensureQuotaPoolIdentity } from "../quota-pool/identity"; import { isTelemetryEnvOptOut } from "../telemetry/consent"; // The state module alone: the scheduler and its aggregation stay behind the dynamic import below. -import { endTelemetryEnvOptOut, recordTelemetryEnvOptOut } from "../telemetry/state"; +import { TELEMETRY_ENV_OPT_OUT_REFRESH_MS, endTelemetryEnvOptOut, recordTelemetryEnvOptOut } from "../telemetry/state"; type PolicyApply = (policy: StorageCleanupPolicy) => void; @@ -67,11 +67,6 @@ let telemetryLoopGeneration = 0; let stopTelemetryLoop: (() => void) | null = null; // This process started under an env opt-out; its clean stop closes the opt-out period (D2). let telemetryEnvOptOutObserved = false; -/** - * How often a proxy running under an env opt-out records itself again, so a telemetry-state.json - * rebuilt after corruption (which forgets every owner) learns that this proxy is still opted out. - */ -const TELEMETRY_ENV_OPT_OUT_REFRESH_MS = 6 * 60 * 60 * 1000; let telemetryEnvOptOutRefreshMsOverride: number | null = null; let telemetryEnvOptOutRefresh: ReturnType | null = null; diff --git a/src/telemetry/state.ts b/src/telemetry/state.ts index 03dac3fb322..8b971b25a82 100644 --- a/src/telemetry/state.ts +++ b/src/telemetry/state.ts @@ -16,6 +16,11 @@ import { deleteTelemetryInstallId } from "./install-id"; export const TELEMETRY_STATE_FILE = "telemetry-state.json"; const DAY_MS = 86_400_000; const STATE_RETENTION_DAYS = 30; +/** + * How often a proxy running under an env opt-out records itself again, so a state file rebuilt after + * corruption (which forgets every owner) learns that the proxy is still opted out. + */ +export const TELEMETRY_ENV_OPT_OUT_REFRESH_MS = 6 * 60 * 60 * 1000; /** A proxy that started under an env opt-out and has not stopped cleanly yet. */ export interface TelemetryEnvOptOutOwner { @@ -154,11 +159,12 @@ export function readTelemetryState(configDir = getConfigDir()): TelemetryState { /** * Read the state for a rewrite. A file that exists but cannot be read may have held opt-out days - * or sent days, so the rewrite starts from "nothing through today may be sent". + * or sent days and opted-out proxies that are still running, so the rewrite starts from "nothing + * may be sent through the UTC day those proxies have recorded themselves again by". */ function readTelemetryStateForWrite(configDir: string, now: number): TelemetryState { const { state, unreadable } = readTelemetryStateFile(configDir); - if (unreadable) extendIneligibleThroughToday(state, now); + if (unreadable) extendIneligibleThroughToday(state, now + TELEMETRY_ENV_OPT_OUT_REFRESH_MS); return state; } @@ -197,10 +203,14 @@ export function recordPendingTelemetryDecision( writeTelemetryState(state, configDir, now); } -/** Drop the pending decision once a decision is written into `config.json`. */ +/** + * Drop the pending decision once a decision is written into `config.json`. A person's decision also + * rebuilds an unreadable state file, which otherwise pauses sending for good. + */ export function clearPendingTelemetryDecision(configDir = getConfigDir(), now = Date.now()): void { - const state = readTelemetryState(configDir); - if (!state.pendingDecision) return; + const { state: current, unreadable } = readTelemetryStateFile(configDir); + if (!unreadable && !current.pendingDecision) return; + const state = unreadable ? readTelemetryStateForWrite(configDir, now) : current; state.pendingDecision = null; writeTelemetryState(state, configDir, now); } diff --git a/structure/telemetry.md b/structure/telemetry.md index 1e5d5066a6c..1ce71aa72e5 100644 --- a/structure/telemetry.md +++ b/structure/telemetry.md @@ -53,9 +53,11 @@ Only a human can transition an undecided install to active sending: (`src/config/telemetry-carry.ts`) makes both whole-config saves (`saveConfig` and `saveConfigPreservingClaudeCode`) write the validated on-disk block instead of the caller's snapshot, so a decision written by another process is never dropped or rolled back, and a - malformed hand edit is dropped as a load would drop it. A `config.json` that exists but cannot be - read may hold a decline the snapshot never saw, so only a snapshot decline survives a save over - it; any other block is dropped and the install waits for the notice again. + malformed hand edit is dropped as a load would drop it. Without a readable `config.json` a save + never introduces a decision: before the file exists the decision is held in + `telemetry-state.json`, where another process may have replaced it, and a file that cannot be read + may hold a decline the snapshot never saw. Only a snapshot decline survives; any other block is + dropped, and the held decision or the notice applies. ## Identity, aggregation, and scheduling @@ -87,9 +89,10 @@ which ends it only once no recorded owner pid is still alive and otherwise exten `telemetry-state.json` that exists but cannot be read (symlink, oversized, malformed) stops sending (`state-unreadable`) rather than being read as empty: the tick checks it before choosing a day and again just before sending, the bookkeeping writes after a send or a `Retry-After` leave such a file -alone, other writers rebuild it as ineligible through today, and the status snapshot reports +alone, other writers (including every recorded decision, so sending does not stay paused) rebuild +it as ineligible through the UTC day six hours from now, and the status snapshot reports `stateUnreadable`. A rebuild forgets `envOptOutOwners`, so an opted-out proxy records itself again -every 6 hours. The state code lives in +every 6 hours (`TELEMETRY_ENV_OPT_OUT_REFRESH_MS`), within that window. The state code lives in `src/telemetry/state.ts`, which `background-lifecycle.ts` imports statically; the scheduler, its aggregation and sender stay behind the dynamic import. Each tick sends at most one completed UTC day strictly after the UTC day of `decidedAt ?? noticeShownAt` and strictly after diff --git a/tests/config/telemetry-live-save.test.ts b/tests/config/telemetry-live-save.test.ts index 33d812dd3c2..903eafde817 100644 --- a/tests/config/telemetry-live-save.test.ts +++ b/tests/config/telemetry-live-save.test.ts @@ -3,6 +3,8 @@ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { armClaudeCodeBaseline, getConfigPath, loadConfig, mutatePersistedConfig, saveConfig, saveConfigPreservingClaudeCode, validateConfigCandidate } from "../../src/config"; +import { resolveTelemetryStatus } from "../../src/telemetry/consent"; +import { recordPendingTelemetryDecision, withPendingTelemetryDecision } from "../../src/telemetry/state"; import type { OcxConfig, OcxTelemetryConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -22,12 +24,26 @@ const seed = (telemetry?: OcxTelemetryConfig): OcxConfig => ({ const declined: OcxTelemetryConfig = { decision: "declined", decidedAt: "2026-11-08T10:00:00.000Z", noticeShownAt: "2026-11-08T10:00:00.000Z" }; const accepted: OcxTelemetryConfig = { decision: "accepted", decidedAt: "2026-11-01T10:00:00.000Z", noticeShownAt: "2026-11-01T10:00:00.000Z" }; +/** + * A whole-config save never introduces a telemetry decision, so a config holding one is created the + * way a person's decision reaches it: the file first, then the decision through mutatePersistedConfig. + */ +function createConfig(config: OcxConfig): void { + const { telemetry, ...rest } = config; + saveConfig(rest as OcxConfig); + if (!telemetry) return; + expect(mutatePersistedConfig(persisted => { + persisted.telemetry = structuredClone(telemetry); + return { changed: true, value: null }; + }).status).toBe("committed"); +} + function liveSaveAfterDiskDecision(initial: OcxConfig, onDisk: OcxTelemetryConfig): { live: OcxConfig; saved: OcxConfig } { const oldHome = process.env.OPENCODEX_HOME; const home = mkdtempSync(join(tmpdir(), "ocx-telemetry-live-save-")); process.env.OPENCODEX_HOME = home; try { - saveConfig(initial); + createConfig(initial); const live = loadConfig(); armClaudeCodeBaseline(live); // A CLI process records a decision while the proxy keeps its older in-memory config. @@ -89,7 +105,7 @@ test("a whole-config save from a stale snapshot keeps a newer decline on disk", const home = mkdtempSync(join(tmpdir(), "ocx-telemetry-live-save-")); process.env.OPENCODEX_HOME = home; try { - saveConfig(seed(accepted)); + createConfig(seed(accepted)); const stale = loadConfig(); // Another process (`ocx telemetry disable`) declines between this load and save. expect(mutatePersistedConfig(config => { @@ -112,7 +128,7 @@ test("a save over an unreadable config.json never turns an unread decline back i const home = mkdtempSync(join(tmpdir(), "ocx-telemetry-live-save-")); process.env.OPENCODEX_HOME = home; try { - saveConfig(seed(accepted)); + createConfig(seed(accepted)); const stale = loadConfig(); // Another process declines, then a hand edit breaks the file: the decline can no longer be read. expect(mutatePersistedConfig(config => { @@ -138,3 +154,31 @@ test("a save over an unreadable config.json never turns an unread decline back i removeTreeWithRetry(home); } }); + +test("a save that creates config.json never turns a pending decline into an accept", () => { + const oldHome = process.env.OPENCODEX_HOME; + const home = mkdtempSync(join(tmpdir(), "ocx-telemetry-live-save-")); + process.env.OPENCODEX_HOME = home; + try { + // A proxy runs without config.json and adopted a dashboard accept (held as the pending decision) ... + const live = { ...seed(), telemetry: structuredClone(accepted) } as OcxConfig; + recordPendingTelemetryDecision(accepted, home); + // ... then "ocx telemetry disable" replaces the pending decision with a decline. + recordPendingTelemetryDecision(declined, home); + // A settings save from that proxy creates config.json. + saveConfig(live); + expect((JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig).telemetry).toBeUndefined(); + expect(live.telemetry).toBeUndefined(); + expect(resolveTelemetryStatus(withPendingTelemetryDecision(loadConfig(), home), {})).toMatchObject({ decision: "declined", effective: false }); + + // A snapshot that holds a decline still creates the file with it. + removeTreeWithRetry(getConfigPath()); + const declinedSnapshot = { ...seed(), telemetry: structuredClone(declined) } as OcxConfig; + saveConfig(declinedSnapshot); + expect((JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig).telemetry).toEqual(declined); + } finally { + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldHome; + removeTreeWithRetry(home); + } +}); diff --git a/tests/usage/usage-telemetry-client.test.ts b/tests/usage/usage-telemetry-client.test.ts index b7afb062ba0..95e57598b4d 100644 --- a/tests/usage/usage-telemetry-client.test.ts +++ b/tests/usage/usage-telemetry-client.test.ts @@ -58,6 +58,7 @@ import { setTelemetrySenderOverridesForTests, } from "../../src/telemetry/sender"; import { acquireServerBackgroundLifecycle, setTelemetryEnvOptOutRefreshMsForTests } from "../../src/server/background-lifecycle"; +import { clearPendingTelemetryDecision } from "../../src/telemetry/state"; import type { OcxConfig } from "../../src/types"; import type { PersistedUsageEntry } from "../../src/usage/log"; import { REQUEST_OUTCOME_CLASSES } from "../../src/usage/request-outcome"; @@ -941,6 +942,33 @@ describe("install identity, aggregation, disjoint tokens, and scheduler", () => expect(fetchCalls).toBe(0); }); + test("a person's decision rebuilds an unreadable state file, keeping every day through the next owner refresh unsent", async () => { + const statePath = telemetryStatePath(home); + writeFileSync(statePath, "{ broken", "utf8"); + // Recorded late in the day: an opted-out proxy forgotten by the rebuild records itself again + // within 6 hours, so the rebuild covers the next UTC day too. + clearPendingTelemetryDecision(home, Date.parse("2026-11-07T21:00:00.000Z")); + expect(readTelemetryStateFile(home).unreadable).toBe(false); + expect(readTelemetryState(home).ineligibleThroughDay).toBe("2026-11-08"); + // Sending resumes for later days instead of staying paused forever. + const sentDays: string[] = []; + const config = { + telemetry: { decision: "accepted" as const, decidedAt: "2026-10-30T10:00:00.000Z", noticeShownAt: "2026-10-30T10:00:00.000Z" }, + }; + const result = await runTelemetryTick({ + configDir: home, + config, + env: {}, + now: Date.parse("2026-11-10T12:00:00.000Z"), + fetch: (async (_url: string | URL | Request, init?: RequestInit) => { + sentDays.push((JSON.parse(String(init?.body)) as TelemetryPayloadV1).day); + return new Response("{}", { status: 202 }); + }) as unknown as typeof fetch, + }); + expect(result).toMatchObject({ sent: true, day: "2026-11-09" }); + expect(sentDays).toEqual(["2026-11-09"]); + }); + test("D2: an opted-out proxy records itself again, so a state file rebuilt after corruption keeps it", async () => { const proxyConfig = { port: 0, defaultProvider: "openai", providers: {} } as unknown as OcxConfig; const prevTelemetry = process.env.OCX_TELEMETRY; From cb3398e3b18541a6cfd02fa6faa6820c292c868b Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 21:30:34 +0900 Subject: [PATCH 29/37] fix(desktop): compare every installer condition explicitly, so a carried 0 stays an opt-out From the final privacy review of #8348 (P3): a bare property test is false when the property holds the integer 0, so a carried InstallerChoice of 0 (written by something other than this MSI) fell back to send on upgrade. Every SetProperty and Next-publish condition now compares with "" explicitly; a test fails on any bare property test, and the simulator covers a carried 0. The desktop guide tells administrators that each install passing OCX_TELEMETRY_CHOICE records a new decision. --- desktop/scripts/telemetry-installer-dialog.ts | 14 +++++++------- desktop/src-tauri/wix/telemetry-choice.wxs | 14 +++++++------- docs-site/src/content/docs/guides/desktop-app.md | 3 +++ structure/desktop-shell.md | 3 ++- tests/clients/desktop-telemetry-installer.test.ts | 14 ++++++++++++++ 5 files changed, 33 insertions(+), 15 deletions(-) diff --git a/desktop/scripts/telemetry-installer-dialog.ts b/desktop/scripts/telemetry-installer-dialog.ts index b626ed03ca2..1e36eb0060d 100644 --- a/desktop/scripts/telemetry-installer-dialog.ts +++ b/desktop/scripts/telemetry-installer-dialog.ts @@ -115,17 +115,17 @@ export function renderTelemetryInstallerFragment(): string { + Every condition here compares with "" because a bare property holding the integer 0 is false. --> ${TELEMETRY_INSTALLER_CHOICE_PROPERTY} <> "" AND ${TELEMETRY_INSTALLER_CHOICE_PROPERTY} ~<> "${TELEMETRY_INSTALLER_SEND}" - ${TELEMETRY_INSTALLER_CHOICE_PROPERTY} AND NOT ${TELEMETRY_INSTALLER_STAMP_PROPERTY} - NOT ${TELEMETRY_INSTALLER_CHOICE_PROPERTY} AND OCX_TELEMETRY_PREVIOUS_CHOICE - NOT ${TELEMETRY_INSTALLER_STAMP_PROPERTY} AND OCX_TELEMETRY_PREVIOUS_STAMP + ${TELEMETRY_INSTALLER_CHOICE_PROPERTY} <> "" AND ${TELEMETRY_INSTALLER_STAMP_PROPERTY} = "" + ${TELEMETRY_INSTALLER_CHOICE_PROPERTY} = "" AND OCX_TELEMETRY_PREVIOUS_CHOICE <> "" + ${TELEMETRY_INSTALLER_STAMP_PROPERTY} = "" AND OCX_TELEMETRY_PREVIOUS_STAMP <> "" - ${TELEMETRY_INSTALLER_STAMP_PROPERTY} + ${TELEMETRY_INSTALLER_STAMP_PROPERTY} <> "" - NOT ${TELEMETRY_INSTALLER_CHOICE_PROPERTY} + ${TELEMETRY_INSTALLER_CHOICE_PROPERTY} = "" ${TELEMETRY_INSTALLER_CHOICE_PROPERTY} ~= "${TELEMETRY_INSTALLER_SEND}" @@ -155,7 +155,7 @@ ${controls.join("\n")} ${TELEMETRY_INSTALLER_CHECKBOX_PROPERTY} = "1" NOT (${TELEMETRY_INSTALLER_CHECKBOX_PROPERTY} = "1") ${TELEMETRY_INSTALLER_CHOICE_PROPERTY} ~<> OCX_TELEMETRY_PREVIOUS_CHOICE - ${TELEMETRY_INSTALLER_CHOICE_PROPERTY} ~= OCX_TELEMETRY_PREVIOUS_CHOICE AND OCX_TELEMETRY_CARRIED_STAMP + ${TELEMETRY_INSTALLER_CHOICE_PROPERTY} ~= OCX_TELEMETRY_PREVIOUS_CHOICE AND OCX_TELEMETRY_CARRIED_STAMP <> "" 1 diff --git a/desktop/src-tauri/wix/telemetry-choice.wxs b/desktop/src-tauri/wix/telemetry-choice.wxs index 1b59751d049..56b36c19f40 100644 --- a/desktop/src-tauri/wix/telemetry-choice.wxs +++ b/desktop/src-tauri/wix/telemetry-choice.wxs @@ -19,17 +19,17 @@ + Every condition here compares with "" because a bare property holding the integer 0 is false. --> OCX_TELEMETRY_CHOICE <> "" AND OCX_TELEMETRY_CHOICE ~<> "send" - OCX_TELEMETRY_CHOICE AND NOT OCX_TELEMETRY_STAMP - NOT OCX_TELEMETRY_CHOICE AND OCX_TELEMETRY_PREVIOUS_CHOICE - NOT OCX_TELEMETRY_STAMP AND OCX_TELEMETRY_PREVIOUS_STAMP + OCX_TELEMETRY_CHOICE <> "" AND OCX_TELEMETRY_STAMP = "" + OCX_TELEMETRY_CHOICE = "" AND OCX_TELEMETRY_PREVIOUS_CHOICE <> "" + OCX_TELEMETRY_STAMP = "" AND OCX_TELEMETRY_PREVIOUS_STAMP <> "" - OCX_TELEMETRY_STAMP + OCX_TELEMETRY_STAMP <> "" - NOT OCX_TELEMETRY_CHOICE + OCX_TELEMETRY_CHOICE = "" OCX_TELEMETRY_CHOICE ~= "send" @@ -63,7 +63,7 @@ OCX_TELEMETRY_SEND = "1" NOT (OCX_TELEMETRY_SEND = "1") OCX_TELEMETRY_CHOICE ~<> OCX_TELEMETRY_PREVIOUS_CHOICE - OCX_TELEMETRY_CHOICE ~= OCX_TELEMETRY_PREVIOUS_CHOICE AND OCX_TELEMETRY_CARRIED_STAMP + OCX_TELEMETRY_CHOICE ~= OCX_TELEMETRY_PREVIOUS_CHOICE AND OCX_TELEMETRY_CARRIED_STAMP <> "" 1 diff --git a/docs-site/src/content/docs/guides/desktop-app.md b/docs-site/src/content/docs/guides/desktop-app.md index 3c714002387..9d6ce126ebc 100644 --- a/docs-site/src/content/docs/guides/desktop-app.md +++ b/docs-site/src/content/docs/guides/desktop-app.md @@ -42,6 +42,9 @@ msiexec /i OpenCodex--windows-x64.msi /qn OCX_TELEMETRY_CHOICE=optout The installer needs administrator rights and records the choice for the whole computer. Only `send` keeps telemetry on; `optout`, in any letter case, and any other value (`off`, `0`) turn it off. +Each install that passes `OCX_TELEMETRY_CHOICE` records a new decision, so a deployment that reuses +the same command for every version applies the opt-out again after each upgrade, over any dashboard +choice made since. Leaving the box checked does not start sending by itself: the dashboard still shows its one-time notice first, and a checked box never turns telemetry back on for an account that turned it off. diff --git a/structure/desktop-shell.md b/structure/desktop-shell.md index 0f21a7ccabe..2a382835aeb 100644 --- a/structure/desktop-shell.md +++ b/structure/desktop-shell.md @@ -380,7 +380,8 @@ silent install run as SYSTEM (Intune, SCCM) or approved by another administrator who use the app; Tauri's MSI is per-machine and needs elevation, so there is no per-user install. `OCX_TELEMETRY_CHOICE` has no default. A command-line value other than `send` (`off`, `0`, a typo) becomes `optout` first (`OcxTelemetryNormalizeChoice`, compared with `""` because a bare property -whose value is the integer 0 evaluates false). A value passed on the command line gets a new stamp +whose value is the integer 0 evaluates false; every condition in the fragment compares +explicitly for the same reason, which a test enforces). A value passed on the command line gets a new stamp (`OcxTelemetryStampExplicit`, before `AppSearch`), but only while no stamp is set, so the stamp the UI phase hands to the install phase is never replaced. Otherwise `AppSearch` reads the previous choice and stamp and carries them (`OcxTelemetryKeepChoice`, `OcxTelemetryKeepStamp`). That keeps diff --git a/tests/clients/desktop-telemetry-installer.test.ts b/tests/clients/desktop-telemetry-installer.test.ts index ccfe3dd926a..3b2304f6ed1 100644 --- a/tests/clients/desktop-telemetry-installer.test.ts +++ b/tests/clients/desktop-telemetry-installer.test.ts @@ -228,6 +228,17 @@ describe("Windows installer telemetry choice", () => { } }); + test("no condition tests a property bare, since a property holding the integer 0 would read as false", () => { + const conditions = [ + ...wxs.matchAll(/]*>([^<]*)<\/SetProperty>/g), + ...wxs.matchAll(/([^<]*)<\/Publish>/g), + ].map(match => xmlUnescape(match[1]!)); + expect(conditions.length).toBeGreaterThan(0); + for (const condition of conditions) { + expect(condition, condition).not.toMatch(/(^|\bAND |\bOR |\bNOT |\()\s*OCX_TELEMETRY_\w+\s*($|\)| AND\b| OR\b)/); + } + }); + describe("what each kind of install records in HKLM", () => { const optOut = TELEMETRY_INSTALLER_OPT_OUT; const send = "send"; @@ -303,6 +314,9 @@ describe("Windows installer telemetry choice", () => { expect(simulateInstall(wxs, { ui: "full", previous: { choice: "off", stamp: "s1" } })).toEqual({ choice: optOut, stamp: UI_STAMP, boxPreselected: false }); expect(simulateInstall(wxs, { ui: "none", previous: { choice: "off", stamp: "s1" } })).toEqual({ choice: "off", stamp: "s1", boxPreselected: undefined }); expect(simulateInstall(wxs, { ui: "full", previous: { choice: "SEND", stamp: "s1" } }).boxPreselected).toBe(true); + // A carried integer 0 is a value: a bare property test would read it as false and fall back to send. + expect(simulateInstall(wxs, { ui: "full", previous: { choice: "0", stamp: "s1" } })).toEqual({ choice: optOut, stamp: UI_STAMP, boxPreselected: false }); + expect(simulateInstall(wxs, { ui: "none", previous: { choice: "0", stamp: "s1" } })).toEqual({ choice: "0", stamp: "s1", boxPreselected: undefined }); }); }); From d6fc2e268ce2c8a5ecb215690ef6b0b9fa78beee Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 21:35:33 +0900 Subject: [PATCH 30/37] fix(desktop): allow Windows-only installer telemetry items to be unused on other platforms From the final fresh review of #8348 (P1): APPLIED_STAMP_VALUE, APPLIED_USER_STAMP_VALUE, Scope::User and Scope::applied_value are used only by the Windows registry reader and the unit tests, so cargo clippy --all-targets -D warnings (the Linux desktop shell CI job) failed with four dead-code errors on non-Windows targets. They carry the same cfg_attr allow as the other Windows-only names. Real-crate host clippy: 4 errors before, clean after. --- desktop/src-tauri/src/installer_telemetry.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/desktop/src-tauri/src/installer_telemetry.rs b/desktop/src-tauri/src/installer_telemetry.rs index 83551c0fdc7..8631adc98b2 100644 --- a/desktop/src-tauri/src/installer_telemetry.rs +++ b/desktop/src-tauri/src/installer_telemetry.rs @@ -44,8 +44,10 @@ pub const REGISTRY_VALUE: &str = "InstallerChoice"; #[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub const STAMP_VALUE: &str = "InstallerChoiceStamp"; /// Written by this app under HKCU: the stamp of the last machine-wide opt-out applied for this user. +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub const APPLIED_STAMP_VALUE: &str = "AppliedInstallerStamp"; /// Written by this app under HKCU: the stamp of the last per-user opt-out applied. +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub const APPLIED_USER_STAMP_VALUE: &str = "AppliedUserInstallerStamp"; // The installer's value for an opt-out, pinned against the WiX fragment by a test; the app treats any // value other than `SEND` as an opt-out. @@ -71,12 +73,14 @@ const RETRY_TIMEOUT: Duration = Duration::from_secs(30); pub enum Scope { /// `HKLM`: every account on the computer. Machine, - /// `HKCU`: this account only. + /// `HKCU`: this account only. Only the Windows registry reader records it. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] User, } impl Scope { /// The HKCU value this app writes once an opt-out from this scope is applied. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub fn applied_value(self) -> &'static str { match self { Scope::Machine => APPLIED_STAMP_VALUE, From d10d52849b2e5fc26433c417793a8ae36b185358 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 23:28:40 +0900 Subject: [PATCH 31/37] ci: approve the skill-surface generator change in the release evidence policy Layer 2 adds the telemetry command root to scripts/generate-ocx-skill-surface.ts, a file dev pins in .github/scripts/release-evidence-policy.json since #8345. Following the procedure in release-evidence.cjs, a new ci v1 entry copies the latest one with only that file's blob updated (git hash-object --no-filters: 750ea21e -> 155e8666); older entries stay for historical revisions. node .github/scripts/release-evidence.cjs policy-check passes, and the gates step's release tests pass 68/68. --- .github/scripts/release-evidence-policy.json | 32 ++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/.github/scripts/release-evidence-policy.json b/.github/scripts/release-evidence-policy.json index 88c10c6ccbf..333ca4c278b 100644 --- a/.github/scripts/release-evidence-policy.json +++ b/.github/scripts/release-evidence-policy.json @@ -134,5 +134,37 @@ "desktop/scripts/appimage-patchelf.py": "63e78ef74160bc48fbcdd882065f1d416db6d13c", "desktop/scripts/linux-packaged-e2e.ts": "9ca92352668ff881c85caea44a9b4747bedc7e2d" } + }, + { + "kind": "ci", + "version": 1, + "files": { + ".github/workflows/ci.yml": "562e63d6bd140944d8c6ba808010913463c75826", + ".github/actions/setup-project-bun/action.yml": "cf28f6f9d4385d69784f4e3610914f6952ce789d", + ".github/scripts/release-evidence.cjs": "c4cd8c762abbfc1c0674937535c5a36b9cf705fd", + ".github/scripts/release-evidence.test.cjs": "de30ad37105cf0e7dd8c538efb5ee1cfafe02990", + "scripts/ci/select-test-lane.sh": "b25836cb55f962d1ffd9e68ed00f45f8b45becc8", + "scripts/ci/windows-sensitive-diff.sh": "03495f256c671aff57e750febffe19ca7face6a1", + "scripts/ci/run-bun-test-batches.sh": "f8aa18e80aff1c95c584cd2c879dfbb6806c11fb", + "scripts/ci/bun-crash-signatures.sh": "d34662ddd07864d97bed909135095db33fe42c9e", + "scripts/ci/bun-install-frozen.sh": "e6d7bc3aec044e12297c7c4c04015a33df0eb22b", + "scripts/ci/docker-smoke.ts": "a8424f93986a31625a0a236cdab5667db67b3405", + "scripts/ci/test-durations.tsv": "0c7b8eeac35a5f09414d1b77185733b5936c594f", + "scripts/ci/test-durations.windows.tsv": "5ee31b6bd6cada6ab7d806ea03dd0d8e46fe1d29", + "scripts/test.ts": "363bf072c4b689478fdbb76a61bf4eadbc054548", + "scripts/lib/test-runner-bun.ts": "8763806104a851dbdefeea8cffe8d7dd8d1c8da1", + "scripts/test-temp-lock.ts": "ee83fb254b728de119028f52b9894bf52d7aea15", + "scripts/ci/run-changed-tests.sh": "9a54d3a5db9849bcbe0dd766954a840185419fa5", + "scripts/keyring-smoke.ts": "cd3be07ad57796172696dfa053ea1fb129f29365", + "scripts/privacy-scan.ts": "86314e891b45e49739b94c76cddd9e4cc2d4340f", + "scripts/structure-ssot.ts": "40ba68401df9b77156afed66d6a11c61df8e9a41", + "scripts/generate-ocx-skill-surface.ts": "155e866656ba2d71e8fd48adda857451caf16322", + "desktop/scripts/prepare-sidecar.ts": "20f7eec61f73635ea681e25f13dd850b62bb41e2", + "desktop/scripts/build-widget.sh": "38e0eb9d713804b603aa75c32383aa18ef01d06c", + "desktop/scripts/verify-macos-runtime.sh": "43f1a31375c6f6fe7dcafb9d96c231b98d1f6ba4", + "desktop/scripts/verify-linux-sidecar.sh": "c502bb5c8ba8346c722a8f5274f2274bb325a934", + "desktop/scripts/appimage-patchelf.py": "63e78ef74160bc48fbcdd882065f1d416db6d13c", + "desktop/scripts/linux-packaged-e2e.ts": "9ca92352668ff881c85caea44a9b4747bedc7e2d" + } } ] From eec104730a17a8feb7f85bf2774a9ebd1b1626d7 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 23:33:05 +0900 Subject: [PATCH 32/37] fix(telemetry): read consent only from the telemetry block, and fail closed on untrustworthy state - [P2] resolveTelemetryStatus treated a whole config without a telemetry key as the telemetry block, so "ocx config set noticeShownAt " (allowed for agents; only telemetry.* is refused) turned sending on for an undecided install without anyone seeing the notice. Reproduced end to end with the CLI under CI=1: status went awaiting-notice -> default-on. Consent is now read only from config.telemetry, and the parameter type no longer accepts a bare block. - A telemetry-state.json lstat error other than ENOENT, or an opt-out field that is present but invalid (ineligibleThroughDay, envOptOutActiveSince, envOptOutOwners), marks the file unreadable instead of reading it as empty. - The rebuild of an unreadable state file during a decision is best effort, so a decision that is already saved never ends in an error (a state path that is a directory threw EISDIR). - The sender refuses redirects, so a report never goes to another host. Failing-first tests in tests/usage/usage-telemetry-client.test.ts (4 failed before the fix). --- src/telemetry/consent.ts | 19 ++--- src/telemetry/sender.ts | 2 + src/telemetry/state.ts | 56 ++++++++++---- structure/telemetry.md | 4 +- tests/usage/usage-telemetry-client.test.ts | 86 ++++++++++++++++++++-- 5 files changed, 136 insertions(+), 31 deletions(-) diff --git a/src/telemetry/consent.ts b/src/telemetry/consent.ts index 5461028ba51..bb5eaec8a59 100644 --- a/src/telemetry/consent.ts +++ b/src/telemetry/consent.ts @@ -22,17 +22,18 @@ export interface TelemetryConsentStatus { const OCX_TELEMETRY_OFF_VALUES = new Set(["0", "false", "off", "no"]); const DO_NOT_TRACK_ON_VALUES = new Set(["1", "true", "yes"]); +/** + * Consent lives only in the config's `telemetry` block. A whole config is never read as a block, so + * a top-level key such as `noticeShownAt` (which `ocx config set` can write) is not consent. + */ function extractTelemetryConfig( - input: OcxTelemetryConfig | { telemetry?: OcxTelemetryConfig } | null | undefined, + input: { telemetry?: OcxTelemetryConfig | null } | null | undefined, ): OcxTelemetryConfig | undefined { if (!input || typeof input !== "object") return undefined; - if ("telemetry" in input) { - const nested = (input as { telemetry?: unknown }).telemetry; - return nested && typeof nested === "object" && !Array.isArray(nested) - ? (nested as OcxTelemetryConfig) - : undefined; - } - return input as OcxTelemetryConfig; + const nested = (input as { telemetry?: unknown }).telemetry; + return nested && typeof nested === "object" && !Array.isArray(nested) + ? (nested as OcxTelemetryConfig) + : undefined; } function validIsoString(value: unknown): string | null { @@ -55,7 +56,7 @@ export function isTelemetryEnvOptOut(env: Record = p } export function resolveTelemetryStatus( - config: OcxTelemetryConfig | { telemetry?: OcxTelemetryConfig } | null | undefined, + config: { telemetry?: OcxTelemetryConfig | null } | null | undefined, env: Record = process.env, ): TelemetryConsentStatus { const telemetry = extractTelemetryConfig(config); diff --git a/src/telemetry/sender.ts b/src/telemetry/sender.ts index cc42909a08b..b7da1743182 100644 --- a/src/telemetry/sender.ts +++ b/src/telemetry/sender.ts @@ -67,6 +67,8 @@ export async function sendTelemetryPayload( "content-type": "application/json", }, body: JSON.stringify(validated.payload), + // The collector URL is a constant; a redirect would send the report to another host. + redirect: "error", signal: deadline.signal, }); if (!response.ok) { diff --git a/src/telemetry/state.ts b/src/telemetry/state.ts index 8b971b25a82..d78a573acc4 100644 --- a/src/telemetry/state.ts +++ b/src/telemetry/state.ts @@ -89,6 +89,14 @@ function parseOwners(raw: unknown): TelemetryEnvOptOutOwner[] { return owners; } +function ownersAreValid(raw: unknown): boolean { + return Array.isArray(raw) && raw.length <= 64 && raw.every(entry => { + if (!entry || typeof entry !== "object") return false; + const { pid, since } = entry as Record; + return typeof pid === "number" && Number.isSafeInteger(pid) && pid > 0 && typeof since === "string" && !Number.isNaN(Date.parse(since)); + }); +} + function parsePendingDecision(raw: unknown): OcxTelemetryConfig | null { if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; const value = raw as Record; @@ -102,15 +110,17 @@ function parsePendingDecision(raw: unknown): OcxTelemetryConfig | null { /** * Read the state, noting whether a file exists but cannot be trusted (a symlink, oversized or - * malformed). Such a file must not be read as empty: that would forget opt-out days. + * malformed, or with an opt-out field that is present but invalid). Such a file must not be read as + * empty: that would forget opt-out days. Only a file that does not exist reads as empty. */ export function readTelemetryStateFile(configDir = getConfigDir()): { state: TelemetryState; unreadable: boolean } { const path = telemetryStatePath(configDir); let stat; try { stat = lstatSync(path); - } catch { - return { state: emptyState(), unreadable: false }; + } catch (error) { + const missing = (error as NodeJS.ErrnoException)?.code === "ENOENT"; + return { state: emptyState(), unreadable: !missing }; } try { if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 64 * 1024) return { state: emptyState(), unreadable: true }; @@ -124,18 +134,25 @@ export function readTelemetryStateFile(configDir = getConfigDir()): { state: Tel sentDays[day] = at; } } - const ineligibleThroughDay = - typeof raw.ineligibleThroughDay === "string" && parseCalendarUtcDayMs(raw.ineligibleThroughDay) !== null - ? raw.ineligibleThroughDay - : null; + // Opt-out fields may be absent or null; present but invalid means the file cannot be trusted. + const present = (value: unknown) => value !== undefined && value !== null; + if (present(raw.ineligibleThroughDay) + && !(typeof raw.ineligibleThroughDay === "string" && parseCalendarUtcDayMs(raw.ineligibleThroughDay) !== null)) { + return { state: emptyState(), unreadable: true }; + } + if (present(raw.envOptOutActiveSince) + && !(typeof raw.envOptOutActiveSince === "string" && !Number.isNaN(Date.parse(raw.envOptOutActiveSince)))) { + return { state: emptyState(), unreadable: true }; + } + if (present(raw.envOptOutOwners) && !ownersAreValid(raw.envOptOutOwners)) { + return { state: emptyState(), unreadable: true }; + } + const ineligibleThroughDay = typeof raw.ineligibleThroughDay === "string" ? raw.ineligibleThroughDay : null; const retryAfterUntil = typeof raw.retryAfterUntil === "string" && !Number.isNaN(Date.parse(raw.retryAfterUntil)) ? raw.retryAfterUntil : null; - const envOptOutActiveSince = - typeof raw.envOptOutActiveSince === "string" && !Number.isNaN(Date.parse(raw.envOptOutActiveSince)) - ? raw.envOptOutActiveSince - : null; + const envOptOutActiveSince = typeof raw.envOptOutActiveSince === "string" ? raw.envOptOutActiveSince : null; return { unreadable: false, state: { version: 1, sentDays, @@ -209,10 +226,19 @@ export function recordPendingTelemetryDecision( */ export function clearPendingTelemetryDecision(configDir = getConfigDir(), now = Date.now()): void { const { state: current, unreadable } = readTelemetryStateFile(configDir); - if (!unreadable && !current.pendingDecision) return; - const state = unreadable ? readTelemetryStateForWrite(configDir, now) : current; - state.pendingDecision = null; - writeTelemetryState(state, configDir, now); + if (unreadable) { + // Best effort: the decision is already saved. If the rebuild fails, the file stays unreadable, + // which keeps sending blocked. + try { + const rebuilt = readTelemetryStateForWrite(configDir, now); + rebuilt.pendingDecision = null; + writeTelemetryState(rebuilt, configDir, now); + } catch { /* keep the unreadable file */ } + return; + } + if (!current.pendingDecision) return; + current.pendingDecision = null; + writeTelemetryState(current, configDir, now); } /** diff --git a/structure/telemetry.md b/structure/telemetry.md index 1ce71aa72e5..66a7e8f9632 100644 --- a/structure/telemetry.md +++ b/structure/telemetry.md @@ -24,7 +24,9 @@ via `adoptPersistedTelemetry` in `src/config/live-reconcile.ts`. `src/telemetry/consent.ts` (`resolveTelemetryStatus`) evaluates precedence in order: `OCX_TELEMETRY` (`0`, `false`, `off`, `no`), `DO_NOT_TRACK` (`1`, `true`, `yes`), `decision === "declined"`, `decision === "accepted"`, undecided with `noticeShownAt` -(`default-on`), and undecided without notice (`awaiting-notice`). +(`default-on`), and undecided without notice (`awaiting-notice`). It reads only the config's +`telemetry` block: a whole config is never treated as a block, so a top-level `noticeShownAt` or +`decision` (which `ocx config set` can write) is not consent. Only a human can transition an undecided install to active sending: diff --git a/tests/usage/usage-telemetry-client.test.ts b/tests/usage/usage-telemetry-client.test.ts index 95e57598b4d..128c4cc95a4 100644 --- a/tests/usage/usage-telemetry-client.test.ts +++ b/tests/usage/usage-telemetry-client.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, setSystemTime, spyOn, test } from "bun:test"; -import { existsSync, lstatSync, mkdtempSync, readdirSync, readFileSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { readConfigDiagnostics, telemetryConfigError } from "../../src/config/diagnostics"; @@ -180,13 +180,13 @@ describe("consent status matrix and config schema wiring", () => { envOverride: null, }); - expect(resolveTelemetryStatus({ noticeShownAt: "2026-11-06T00:00:00.000Z" }, {})).toMatchObject({ + expect(resolveTelemetryStatus({ telemetry: { noticeShownAt: "2026-11-06T00:00:00.000Z" } }, {})).toMatchObject({ effective: true, reason: "default-on", decision: null, }); - expect(resolveTelemetryStatus({ decision: "accepted", decidedAt: "2026-11-06T00:00:00.000Z" }, {})).toMatchObject({ + expect(resolveTelemetryStatus({ telemetry: { decision: "accepted", decidedAt: "2026-11-06T00:00:00.000Z" } }, {})).toMatchObject({ effective: true, reason: "accepted", decision: "accepted", @@ -194,7 +194,7 @@ describe("consent status matrix and config schema wiring", () => { expect( resolveTelemetryStatus( - { decision: "declined", decidedAt: "2026-11-06T00:00:00.000Z", noticeShownAt: "2026-11-05T00:00:00.000Z" }, + { telemetry: { decision: "declined", decidedAt: "2026-11-06T00:00:00.000Z", noticeShownAt: "2026-11-05T00:00:00.000Z" } }, {}, ), ).toMatchObject({ @@ -205,7 +205,7 @@ describe("consent status matrix and config schema wiring", () => { for (const val of ["0", "false", "off", "no", " FALSE "]) { expect( - resolveTelemetryStatus({ decision: "accepted" }, { OCX_TELEMETRY: val, DO_NOT_TRACK: "1" }), + resolveTelemetryStatus({ telemetry: { decision: "accepted" } }, { OCX_TELEMETRY: val, DO_NOT_TRACK: "1" }), ).toMatchObject({ effective: false, reason: "env-ocx-telemetry", @@ -214,7 +214,7 @@ describe("consent status matrix and config schema wiring", () => { } for (const val of ["1", "true", "yes", " TRUE "]) { - expect(resolveTelemetryStatus({ decision: "accepted" }, { DO_NOT_TRACK: val })).toMatchObject({ + expect(resolveTelemetryStatus({ telemetry: { decision: "accepted" } }, { DO_NOT_TRACK: val })).toMatchObject({ effective: false, reason: "env-do-not-track", envOverride: "DO_NOT_TRACK", @@ -222,6 +222,30 @@ describe("consent status matrix and config schema wiring", () => { } }); + test("consent is read only from the telemetry block, never from top-level config keys", async () => { + // `ocx config set noticeShownAt …` writes a top-level key; it must not count as the notice. + const topLevel = { noticeShownAt: "2026-01-01T00:00:00.000Z", decision: "accepted", decidedAt: "2026-01-01T00:00:00.000Z" }; + expect(resolveTelemetryStatus(topLevel as never, {})).toMatchObject({ effective: false, reason: "awaiting-notice", decision: null }); + const home = mkdtempSync(join(tmpdir(), "ocx-telemetry-toplevel-")); + try { + let fetchCalls = 0; + const result = await runTelemetryTick({ + configDir: home, + readConfig: () => topLevel as never, + env: {}, + now: Date.parse("2026-11-07T12:00:00.000Z"), + fetch: (async () => { + fetchCalls += 1; + return new Response("{}", { status: 202 }); + }) as unknown as typeof fetch, + }); + expect(result).toMatchObject({ sent: false, reason: "awaiting-notice" }); + expect(fetchCalls).toBe(0); + } finally { + removeTreeWithRetry(home); + } + }); + test("config diagnostics, load-degrade, and live-reconcile handle telemetry config", () => { expect(telemetryConfigError({ telemetry: { decision: "accepted" } })).toBeNull(); expect(telemetryConfigError({ telemetry: { endpoint: "https://evil.example" } })).toContain("schema_invalid: telemetry"); @@ -992,6 +1016,38 @@ describe("install identity, aggregation, disjoint tokens, and scheduler", () => } }); + test("a state file that cannot be inspected, or holds malformed opt-out fields, blocks sending", () => { + const statePath = telemetryStatePath(home); + const base = { version: 1, sentDays: {} }; + for (const bad of [ + { ...base, ineligibleThroughDay: "not-a-day" }, + { ...base, envOptOutActiveSince: "not-a-time" }, + { ...base, envOptOutOwners: "not-a-list" }, + { ...base, envOptOutOwners: [{ pid: "1", since: "2026-11-01T00:00:00.000Z" }] }, + ]) { + writeFileSync(statePath, JSON.stringify(bad), "utf8"); + expect(readTelemetryStateFile(home).unreadable, JSON.stringify(bad)).toBe(true); + } + // Absent or null fields are legitimate. + writeFileSync(statePath, JSON.stringify({ ...base, ineligibleThroughDay: null, envOptOutActiveSince: null, envOptOutOwners: [] }), "utf8"); + expect(readTelemetryStateFile(home).unreadable).toBe(false); + // An lstat failure other than "not found" is not read as a missing file. + if (process.platform !== "win32" && process.getuid?.() !== 0) { + chmodSync(home, 0o000); + try { + expect(readTelemetryStateFile(home).unreadable).toBe(true); + } finally { + chmodSync(home, 0o700); + } + } + }); + + test("a decision over a state path that cannot be rewritten still completes", () => { + const statePath = telemetryStatePath(home); + mkdirSync(statePath); + expect(() => clearPendingTelemetryDecision(home, NOW)).not.toThrow(); + }); + test("a telemetry-id.json deleted by another process is not served from memory", () => { const { identity } = ensureTelemetryInstallId(home); expect(readTelemetryInstallId(home)?.installId).toBe(identity.installId); @@ -1221,6 +1277,24 @@ describe("install identity, aggregation, disjoint tokens, and scheduler", () => } }); + test("sendTelemetryPayload refuses redirects, so a report never goes to another host", async () => { + const payload = buildTelemetryPayloadV1({ + aggregate: aggregateTelemetryEntriesForDay("2026-11-06", []), + installDayHash: "c".repeat(64), + newInstall: false, + now: NOW, + }); + let redirect: RequestRedirect | undefined; + await sendTelemetryPayload(payload, { + fetch: (async (_url: string | URL | Request, init?: RequestInit) => { + redirect = init?.redirect; + return new Response("{}", { status: 202 }); + }) as unknown as typeof fetch, + now: NOW, + }); + expect(redirect).toBe("error"); + }); + test("sendTelemetryPayload enforces its timeout and does not retry", async () => { const agg = aggregateTelemetryEntriesForDay("2026-11-06", []); const payload = buildTelemetryPayloadV1({ From 25633e9c7abeeea37edc082005ea365e99533c8c Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 23:45:50 +0900 Subject: [PATCH 33/37] fix(telemetry): keep a held decision out of a newly created config.json A dashboard accept on a fresh install is held in telemetry-state.json, but the route also copied it into the running config. After a later `ocx telemetry disable`, initializePersistedConfigIfMissing (reached by PUT /api/subagent-models and account selection) wrote that stale accept into the new config.json, which outranks the held decline. Creating config.json now runs carryPersistedTelemetry with no file, so only an in-memory decline survives, and the route no longer adopts a decision it only held as pending. Recording an opted-out owner also drops dead pids and stays within the 64 entries a reader accepts. --- src/config.ts | 6 ++++ src/telemetry/routes.ts | 4 ++- src/telemetry/state.ts | 6 +++- structure/telemetry.md | 4 ++- tests/config/telemetry-live-save.test.ts | 27 +++++++++++++++++- tests/server/server-telemetry-routes.test.ts | 30 +++++++++++++++++++- tests/usage/usage-telemetry-client.test.ts | 9 ++++++ 7 files changed, 81 insertions(+), 5 deletions(-) diff --git a/src/config.ts b/src/config.ts index 3742fd4e383..03d40608984 100644 --- a/src/config.ts +++ b/src/config.ts @@ -350,12 +350,17 @@ export function initializePersistedConfigIfMissing( const before = observeInitialConfigState(); if (before !== "missing") return before; let published = false; + let telemetry: ReturnType | undefined; try { const persisted = withConfigMutationLockSync((): OcxConfig | "exists" | "invalid" => { const current = observeInitialConfigState(); if (current !== "missing") return current; const projected = projectCustomModelCatalogMigration(undefined, projectConfigRebaseProvenance(config)); normalizeAccountAutoPauseRevisions(undefined, projected); + // Creating config.json never introduces a telemetry decision: one made before the file existed + // is held in telemetry-state.json, where another process may have replaced it (see + // carryPersistedTelemetry). Only an in-memory decline survives. + telemetry = carryPersistedTelemetry(projected, undefined); if (!validateConfigCandidate(projected).ok) throw new Error("Initial configuration is invalid."); if (!publishInitialConfigNoReplace(getConfigPath(), JSON.stringify(projected, null, 2) + "\n", io)) { return observeInitialConfigState() === "exists" ? "exists" : "invalid"; @@ -368,6 +373,7 @@ export function initializePersistedConfigIfMissing( }); if (typeof persisted === "string") return persisted; adoptCustomModelCatalogMigration(config, persisted); + if (telemetry?.carried) adoptPersistedTelemetry(config, telemetry.value); if (persisted.configRebaseProvenance === undefined) delete config.configRebaseProvenance; else config.configRebaseProvenance = structuredClone(persisted.configRebaseProvenance); clearPendingConfigDeletions(config); diff --git a/src/telemetry/routes.ts b/src/telemetry/routes.ts index 0007cfb3ff3..7f64747d33c 100644 --- a/src/telemetry/routes.ts +++ b/src/telemetry/routes.ts @@ -171,7 +171,9 @@ export async function handlePutTelemetryRoute(ctx: ManagementContext): Promise entry.pid !== owner); + // Drop owners that have exited (their days through today are covered above) and stay within the + // 64 entries a reader accepts. + const owners = (state.envOptOutOwners ?? []) + .filter(entry => entry.pid !== owner && isProcessAlive(entry.pid)) + .slice(-63); state.envOptOutOwners = [...owners, { pid: owner, since: new Date(now).toISOString() }]; } writeTelemetryState(state, configDir, now); diff --git a/structure/telemetry.md b/structure/telemetry.md index 66a7e8f9632..87c3b61cc1a 100644 --- a/structure/telemetry.md +++ b/structure/telemetry.md @@ -59,7 +59,9 @@ Only a human can transition an undecided install to active sending: never introduces a decision: before the file exists the decision is held in `telemetry-state.json`, where another process may have replaced it, and a file that cannot be read may hold a decline the snapshot never saw. Only a snapshot decline survives; any other block is - dropped, and the held decision or the notice applies. + dropped, and the held decision or the notice applies. The same rule covers + `initializePersistedConfigIfMissing`, which creates `config.json` from the running config, and the + consent route never copies a decision it only held as pending into the running config. ## Identity, aggregation, and scheduling diff --git a/tests/config/telemetry-live-save.test.ts b/tests/config/telemetry-live-save.test.ts index 903eafde817..4896d87f126 100644 --- a/tests/config/telemetry-live-save.test.ts +++ b/tests/config/telemetry-live-save.test.ts @@ -2,7 +2,7 @@ import { expect, test } from "bun:test"; import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { armClaudeCodeBaseline, getConfigPath, loadConfig, mutatePersistedConfig, saveConfig, saveConfigPreservingClaudeCode, validateConfigCandidate } from "../../src/config"; +import { armClaudeCodeBaseline, getConfigPath, initializePersistedConfigIfMissing, loadConfig, mutatePersistedConfig, saveConfig, saveConfigPreservingClaudeCode, validateConfigCandidate } from "../../src/config"; import { resolveTelemetryStatus } from "../../src/telemetry/consent"; import { recordPendingTelemetryDecision, withPendingTelemetryDecision } from "../../src/telemetry/state"; import type { OcxConfig, OcxTelemetryConfig } from "../../src/types"; @@ -182,3 +182,28 @@ test("a save that creates config.json never turns a pending decline into an acce removeTreeWithRetry(home); } }); + +test("creating config.json from an in-memory config never introduces a telemetry decision", () => { + const oldHome = process.env.OPENCODEX_HOME; + const home = mkdtempSync(join(tmpdir(), "ocx-telemetry-live-save-")); + process.env.OPENCODEX_HOME = home; + try { + // A running proxy holds an accept in memory while the held decision on disk is a decline. + recordPendingTelemetryDecision(declined, home); + const live = { ...seed(), telemetry: structuredClone(accepted) } as OcxConfig; + expect(initializePersistedConfigIfMissing(live)).toBe("created"); + expect((JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig).telemetry).toBeUndefined(); + expect(live.telemetry).toBeUndefined(); + expect(resolveTelemetryStatus(withPendingTelemetryDecision(loadConfig(), home), {})).toMatchObject({ decision: "declined", effective: false }); + + // An in-memory decline is kept. + removeTreeWithRetry(getConfigPath()); + const declinedLive = { ...seed(), telemetry: structuredClone(declined) } as OcxConfig; + expect(initializePersistedConfigIfMissing(declinedLive)).toBe("created"); + expect((JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig).telemetry).toEqual(declined); + } finally { + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldHome; + removeTreeWithRetry(home); + } +}); diff --git a/tests/server/server-telemetry-routes.test.ts b/tests/server/server-telemetry-routes.test.ts index 986d751bed0..3ba727f4866 100644 --- a/tests/server/server-telemetry-routes.test.ts +++ b/tests/server/server-telemetry-routes.test.ts @@ -7,7 +7,7 @@ import type { ManagementPrincipal } from "../../src/server/management-auth"; import type { ManagementRequestIngress } from "../../src/server/management/context"; import { TELEMETRY_PREVIEW_HASH_PLACEHOLDER, readTelemetryInstallId, resetTelemetryInstallIdMemoForTests, telemetryIdPath } from "../../src/telemetry/install-id"; import { buildTelemetryStatusSnapshot } from "../../src/telemetry/routes"; -import { latestCompletedUtcDay, readTelemetryState, runTelemetryTick } from "../../src/telemetry/scheduler"; +import { latestCompletedUtcDay, readTelemetryState, recordPendingTelemetryDecision, runTelemetryTick } from "../../src/telemetry/scheduler"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -257,6 +257,34 @@ describe("/api/telemetry management routes", () => { expect(status.body).toMatchObject({ effective: false, decision: "declined" }); }); + test("a decline held before config.json exists survives a management request that creates config.json", async () => { + // Fresh install: the dashboard popup accepts before any config.json exists ... + unlinkSync(join(home, "config.json")); + config = { + port: 10100, + logLevel: "info", + defaultProvider: "fixture", + providers: { fixture: { adapter: "openai-chat", baseUrl: "https://fixture.example.test" } }, + } as unknown as OcxConfig; + const accepted = await call("PUT", "/api/telemetry", { + body: { decision: "accepted" }, + principal: "gui-session", + ingress: { trustedLoopback: true, guiSessionIssuance: "loopback-bootstrap" }, + }); + expect(accepted.status).toBe(200); + // ... the held decision is not copied into the running proxy's config ... + expect(config.telemetry).toBeUndefined(); + // ... then "ocx telemetry disable" replaces the held decision with a decline. + recordPendingTelemetryDecision({ decision: "declined", decidedAt: "2026-11-08T12:00:00.000Z", noticeShownAt: "2026-11-08T12:00:00.000Z" }, home); + // An admin-token request that creates config.json from the in-memory config. + const created = await call("PUT", "/api/subagent-models", { body: { force: null } }); + expect(created.status).toBe(200); + expect(existsSync(join(home, "config.json"))).toBe(true); + expect((JSON.parse(readFileSync(join(home, "config.json"), "utf8")) as OcxConfig).telemetry).toBeUndefined(); + const status = await call("GET", "/api/telemetry"); + expect(status.body).toMatchObject({ effective: false, decision: "declined" }); + }); + test("GET /api/telemetry reports a decision written to disk while the server's config is stale", async () => { writeFileSync(join(home, "config.json"), JSON.stringify({ ...config, diff --git a/tests/usage/usage-telemetry-client.test.ts b/tests/usage/usage-telemetry-client.test.ts index 128c4cc95a4..5d11d8f2c3e 100644 --- a/tests/usage/usage-telemetry-client.test.ts +++ b/tests/usage/usage-telemetry-client.test.ts @@ -1042,6 +1042,15 @@ describe("install identity, aggregation, disjoint tokens, and scheduler", () => } }); + test("recording an opted-out owner drops dead pids and keeps the list within the readable limit", () => { + const since = "2026-11-01T00:00:00.000Z"; + const dead = Array.from({ length: 64 }, (_, i) => ({ pid: 2_000_000_000 + i, since })); + writeTelemetryState({ ...readTelemetryState(home), envOptOutOwners: dead }, home); + recordTelemetryEnvOptOut(home, NOW, process.pid); + expect(readTelemetryStateFile(home).unreadable).toBe(false); + expect(readTelemetryState(home).envOptOutOwners?.map(owner => owner.pid)).toEqual([process.pid]); + }); + test("a decision over a state path that cannot be rewritten still completes", () => { const statePath = telemetryStatePath(home); mkdirSync(statePath); From f5a8ae01da287ebb679a7f398a5745f5d746164b Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 23:48:39 +0900 Subject: [PATCH 34/37] fix(telemetry): fail closed on a malformed held decision A pendingDecision that is present but invalid was silently dropped, so a damaged held decline could let a notice-only config send under default-on consent. A held decline with a damaged timestamp now still applies (dated by noticeShownAt or the file time), and any other malformed held decision makes telemetry-state.json unreadable, which blocks sending until a decision rebuilds it. --- src/telemetry/state.ts | 19 ++++++++++++++----- structure/telemetry.md | 6 ++++-- tests/usage/usage-telemetry-client.test.ts | 8 +++++++- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/telemetry/state.ts b/src/telemetry/state.ts index 66ecb1f9120..9baee7140ba 100644 --- a/src/telemetry/state.ts +++ b/src/telemetry/state.ts @@ -97,14 +97,21 @@ function ownersAreValid(raw: unknown): boolean { }); } -function parsePendingDecision(raw: unknown): OcxTelemetryConfig | null { - if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; +/** A held decision that is present but cannot be trusted returns "invalid", which blocks sending. */ +function parsePendingDecision(raw: unknown, fileTime: Date): OcxTelemetryConfig | null | "invalid" { + if (raw === undefined || raw === null) return null; + if (typeof raw !== "object" || Array.isArray(raw)) return "invalid"; const value = raw as Record; - if (value.decision !== "accepted" && value.decision !== "declined") return null; + if (value.decision !== "accepted" && value.decision !== "declined") return "invalid"; const stamp = (key: string): string | null => typeof value[key] === "string" && !Number.isNaN(Date.parse(value[key] as string)) ? (value[key] as string) : null; const decidedAt = stamp("decidedAt"); - if (!decidedAt) return null; + if (!decidedAt) { + // A decline needs no date to apply; dropping it could let a notice-only config send. + if (value.decision !== "declined") return "invalid"; + const at = stamp("noticeShownAt") ?? fileTime.toISOString(); + return { decision: "declined", decidedAt: at, noticeShownAt: at }; + } return { decision: value.decision, decidedAt, noticeShownAt: stamp("noticeShownAt") ?? decidedAt }; } @@ -147,6 +154,8 @@ export function readTelemetryStateFile(configDir = getConfigDir()): { state: Tel if (present(raw.envOptOutOwners) && !ownersAreValid(raw.envOptOutOwners)) { return { state: emptyState(), unreadable: true }; } + const pendingDecision = parsePendingDecision(raw.pendingDecision, stat.mtime); + if (pendingDecision === "invalid") return { state: emptyState(), unreadable: true }; const ineligibleThroughDay = typeof raw.ineligibleThroughDay === "string" ? raw.ineligibleThroughDay : null; const retryAfterUntil = typeof raw.retryAfterUntil === "string" && !Number.isNaN(Date.parse(raw.retryAfterUntil)) @@ -162,7 +171,7 @@ export function readTelemetryStateFile(configDir = getConfigDir()): { state: Tel retryAfterUntil, envOptOutActiveSince, envOptOutOwners: parseOwners(raw.envOptOutOwners), - pendingDecision: parsePendingDecision(raw.pendingDecision), + pendingDecision, everReported: raw.everReported === true, } }; } catch { diff --git a/structure/telemetry.md b/structure/telemetry.md index 87c3b61cc1a..cb2762b1c27 100644 --- a/structure/telemetry.md +++ b/structure/telemetry.md @@ -90,13 +90,15 @@ once and runs no ticks, so the marker or owner stands for the whole period: whil day is eligible. `endTelemetryEnvOptOut` closes the period (ineligible through the closing day) on that proxy's clean stop, which always covers its stop day, or on the first tick without the opt-out, which ends it only once no recorded owner pid is still alive and otherwise extends it to today. A -`telemetry-state.json` that exists but cannot be read (symlink, oversized, malformed) stops sending +`telemetry-state.json` that exists but cannot be read (symlink, oversized, malformed, or with an +opt-out field or held decision that is present but invalid) stops sending (`state-unreadable`) rather than being read as empty: the tick checks it before choosing a day and again just before sending, the bookkeeping writes after a send or a `Retry-After` leave such a file alone, other writers (including every recorded decision, so sending does not stay paused) rebuild it as ineligible through the UTC day six hours from now, and the status snapshot reports `stateUnreadable`. A rebuild forgets `envOptOutOwners`, so an opted-out proxy records itself again -every 6 hours (`TELEMETRY_ENV_OPT_OUT_REFRESH_MS`), within that window. The state code lives in +every 6 hours (`TELEMETRY_ENV_OPT_OUT_REFRESH_MS`), within that window. A held decline whose +timestamp is damaged still applies. The state code lives in `src/telemetry/state.ts`, which `background-lifecycle.ts` imports statically; the scheduler, its aggregation and sender stay behind the dynamic import. Each tick sends at most one completed UTC day strictly after the UTC day of `decidedAt ?? noticeShownAt` and strictly after diff --git a/tests/usage/usage-telemetry-client.test.ts b/tests/usage/usage-telemetry-client.test.ts index 5d11d8f2c3e..85fc988d47c 100644 --- a/tests/usage/usage-telemetry-client.test.ts +++ b/tests/usage/usage-telemetry-client.test.ts @@ -1016,7 +1016,7 @@ describe("install identity, aggregation, disjoint tokens, and scheduler", () => } }); - test("a state file that cannot be inspected, or holds malformed opt-out fields, blocks sending", () => { + test("a state file that cannot be inspected, or holds malformed opt-out fields or a malformed held decision, blocks sending", () => { const statePath = telemetryStatePath(home); const base = { version: 1, sentDays: {} }; for (const bad of [ @@ -1024,6 +1024,9 @@ describe("install identity, aggregation, disjoint tokens, and scheduler", () => { ...base, envOptOutActiveSince: "not-a-time" }, { ...base, envOptOutOwners: "not-a-list" }, { ...base, envOptOutOwners: [{ pid: "1", since: "2026-11-01T00:00:00.000Z" }] }, + { ...base, pendingDecision: "declined" }, + { ...base, pendingDecision: { decision: "no", decidedAt: "2026-11-01T00:00:00.000Z" } }, + { ...base, pendingDecision: { decision: "accepted", decidedAt: "not-a-time" } }, ]) { writeFileSync(statePath, JSON.stringify(bad), "utf8"); expect(readTelemetryStateFile(home).unreadable, JSON.stringify(bad)).toBe(true); @@ -1031,6 +1034,9 @@ describe("install identity, aggregation, disjoint tokens, and scheduler", () => // Absent or null fields are legitimate. writeFileSync(statePath, JSON.stringify({ ...base, ineligibleThroughDay: null, envOptOutActiveSince: null, envOptOutOwners: [] }), "utf8"); expect(readTelemetryStateFile(home).unreadable).toBe(false); + // A held decline with a damaged timestamp still applies. + writeFileSync(statePath, JSON.stringify({ ...base, pendingDecision: { decision: "declined", decidedAt: "not-a-time" } }), "utf8"); + expect(readTelemetryStateFile(home)).toMatchObject({ unreadable: false, state: { pendingDecision: { decision: "declined" } } }); // An lstat failure other than "not found" is not read as a missing file. if (process.platform !== "win32" && process.getuid?.() !== 0) { chmodSync(home, 0o000); From e367ca6a4c3525c7721585bda89499d26cf4528e Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 9 Nov 2026 00:26:27 +0900 Subject: [PATCH 35/37] docs(structure): map the telemetry consent, carry and lifecycle files to telemetry.md --- structure/INDEX.md | 3 +++ structure/manifest.json | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/structure/INDEX.md b/structure/INDEX.md index 4fc13713fa7..61a5af4fcfb 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -336,6 +336,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/config/schema/account-auto-pause.ts` | [`account-auto-pause.md`](account-auto-pause.md) | | `src/config/schema/config-schema.ts` | [`transports/max-output-completion.md`](transports/max-output-completion.md)
[`clients/omo-export-dialect.md`](clients/omo-export-dialect.md) | | `src/config/schema/leaf-validators.ts` | [`transports/timeouts.md`](transports/timeouts.md)
[`pricing.md`](pricing.md)
[`clients/omo-export-dialect.md`](clients/omo-export-dialect.md) | +| `src/config/telemetry-carry.ts` | [`telemetry.md`](telemetry.md) | | `src/generated/` | [`runtime.md`](runtime.md) | | `src/github/` | [`runtime.md`](runtime.md) | | `src/grok/` | [`runtime.md`](runtime.md) | @@ -427,6 +428,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/server/audio-dictation.ts` | [`providers/codex-installation-identity.md`](providers/codex-installation-identity.md) | | `src/server/audio-live.ts` | [`providers/codex-installation-identity.md`](providers/codex-installation-identity.md) | | `src/server/auth-cors.ts` | [`transports/response-turn-membership.md`](transports/response-turn-membership.md)
[`providers/lm-studio-chat.md`](providers/lm-studio-chat.md) | +| `src/server/background-lifecycle.ts` | [`telemetry.md`](telemetry.md) | | `src/server/capture-admission-policy.ts` | [`advisor.md`](advisor.md) | | `src/server/claude-messages.ts` | [`data-planes/error-detail.md`](data-planes/error-detail.md)
[`providers/codex-installation-identity.md`](providers/codex-installation-identity.md) | | `src/server/context-history.ts` | [`providers/codex-installation-identity.md`](providers/codex-installation-identity.md) | @@ -447,6 +449,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/server/management/anthropic-pool-settings.ts` | [`providers/anthropic-account-pool.md`](providers/anthropic-account-pool.md) | | `src/server/management/codex-cli-update-routes.ts` | [`codex-cli-update.md`](codex-cli-update.md) | | `src/server/management/companion-routes.ts` | [`desktop-shell.md`](desktop-shell.md) | +| `src/server/management/consent-session.ts` | [`telemetry.md`](telemetry.md) | | `src/server/management/link-enrollment.ts` | [`remote-link.md`](remote-link.md) | | `src/server/management/link-routes.ts` | [`remote-link.md`](remote-link.md) | | `src/server/management/logs-usage-routes.ts` | [`usage-client-attribution.md`](usage-client-attribution.md) | diff --git a/structure/manifest.json b/structure/manifest.json index 8bc7cb5cb4e..f0f32d948c4 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -1438,7 +1438,10 @@ "src/telemetry/", "src/types/telemetry.ts", "src/server/management/telemetry-routes.ts", - "src/cli/telemetry.ts" + "src/cli/telemetry.ts", + "src/config/telemetry-carry.ts", + "src/server/management/consent-session.ts", + "src/server/background-lifecycle.ts" ] } ], From e27855ead29eacab813b5c74ebfac83892bb738f Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 9 Nov 2026 09:30:31 +0900 Subject: [PATCH 36/37] ci: retrigger on current dev From d2b4a5df2440970f70d9c0f31de2a1782f3a3ef5 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 9 Nov 2026 09:41:19 +0900 Subject: [PATCH 37/37] docs(skill): regenerate the surface counts after merging dev --- skills/ocx/references/01_management_surface.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index a381c0615f7..960a85b1068 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -1553,6 +1553,6 @@ Original invocation order. These headings preserve links to the previous single- ## Counts -- declared capabilities: 373 -- of those, state-changing: 230 +- declared capabilities: 376 +- of those, state-changing: 232 - head-resolved invocations: 2