From b45656e880071eaa5c4792951c44fee19e3e493a Mon Sep 17 00:00:00 2001 From: Jay Yu Date: Fri, 11 Sep 2026 12:00:53 -0400 Subject: [PATCH] fix(coding-agent): remove install reporting - Stop reporting CLI installs and updates to Pi - Separate optional provider attribution with an off default --- AGENTS.md | 4 ++ docs/release/install-telemetry-removal.md | 41 ++++++++++++++++ packages/coding-agent/README.md | 20 ++++---- .../docs/environment-variables.md | 6 +-- packages/coding-agent/docs/settings.md | 14 ++++-- packages/coding-agent/src/cli/args.ts | 2 +- .../src/core/provider-attribution.ts | 8 +++- .../coding-agent/src/core/settings-manager.ts | 12 ++--- packages/coding-agent/src/core/telemetry.ts | 13 ----- .../components/settings-selector.ts | 16 +++---- .../src/modes/interactive/interactive-mode.ts | 28 ++--------- .../test/fixtures/version-update-client.ts | 10 +++- .../test/install-telemetry-e2e.test.ts | 35 ++++++++++++++ .../test/sdk-openrouter-attribution.test.ts | 48 +++++++++++++------ .../test/settings-selector.test.ts | 18 ++++++- .../test/version-update-e2e.test.ts | 1 + scripts/fixtures/release-client-canary.mjs | 5 +- 17 files changed, 190 insertions(+), 91 deletions(-) create mode 100644 docs/release/install-telemetry-removal.md create mode 100644 packages/coding-agent/test/install-telemetry-e2e.test.ts diff --git a/AGENTS.md b/AGENTS.md index 351dacf4f..eecb66df2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -170,3 +170,7 @@ If the user's instructions conflict with any rule in this document, ask for expl - 2026-09-10: Node ambient fetch types omitted the runtime-supported cache option -> Validate request options against the installed Undici types used by the HTTP dispatcher. - 2026-09-10: A staging canary counted intercepted install telemetry as version traffic -> Assert version requests and actual socket destinations separately, and report blocked unrelated dependencies. + +- 2026-09-11: Install-report tests confused the package version with the inherited changelog checkpoint -> Derive checkpoint fixtures from changelog entries and assert attempted requests independently. +- 2026-09-11: A multi-file rename stopped midway and validation loaded stale fixtures -> Preflight replacements and finish all edits before launching tests. +- 2026-09-11: An SDK regression expected undefined headers although the SDK returns an empty object -> Assert header contents at the SDK boundary. diff --git a/docs/release/install-telemetry-removal.md b/docs/release/install-telemetry-removal.md new file mode 100644 index 000000000..b8cea7505 --- /dev/null +++ b/docs/release/install-telemetry-removal.md @@ -0,0 +1,41 @@ +# Install telemetry removal + +The CLI no longer reports installations or changelog-detected updates to Pi. +This change is based on CLI PR #205 (`feat/staging-version-canaries`). +Inspection of `diffex-cloud` and `diffex-landing` found no install-report ingest implementation to remove. + +## Reproduction and regression boundary + +Run from `packages/coding-agent`: + +```sh +node ../../node_modules/vitest/dist/cli.js --run test/install-telemetry-e2e.test.ts +``` + +The test starts the real source CLI with `--no-session --no-extensions --no-skills` in an isolated 100x30 PTY. +It supplies no provider credentials and intercepts every fetch before network access, with a socket guard for alternate HTTP clients. +Before removal, first install, upgrade, legacy opt-in, provider attribution opt-in, and normal startup attempted an anonymous `GET https://pi.dev/api/report-install?version=0.2.0` with a platform/runtime User-Agent. +Those five regression cases failed on the attempted request before the implementation changed. + +After removal, all eight cases pass: first install, upgrade, current checkpoint, legacy opt-in, legacy opt-out, provider attribution opt-in, offline, and startup with version checks enabled. +Only the last case attempts a request, to the canonical Diffex latest-version endpoint. +Changelog acknowledgement still persists the current changelog checkpoint. +Each process exits through `/exit`, and the harness verifies removal of its temporary home, project, settings, and event record. + +## Settings behavior + +The old install-telemetry setting, environment-variable reader, settings callbacks, and reporting helper are removed. +Optional provider headers now use `enableProviderAttribution` and `DIFFEX_PROVIDER_ATTRIBUTION`. +The independent setting defaults off, so removing the old shared opt-out cannot re-enable optional headers. +Explicit provider attribution opt-in never triggers an install report. +Provider identity strings, OpenCode session headers, runtime tracing, and experimental analytics are separate functionality. + +A real terminal smoke run opened `/settings`, searched for `attribution`, toggled **Provider attribution** from false to true, closed the selector, and exited. +The setting persisted and the recorded request list remained empty. +The settings selector tests also verify its compact layouts. + +## Release checks + +The staging client canary now rejects every unexpected startup request, including the former install-report exception. +The earlier [staging acceptance record](client-canaries.md) describes historical behavior before this removal. +This validation uses source clients with controlled HTTP responses; it does not deploy services or publish rebuilt CLI artifacts. diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index f05fcd92f..c10b19449 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -333,14 +333,18 @@ If no extension or saved decision applies, `defaultProjectTrust` controls the fa Use `/trust` in interactive mode to save a project trust decision for future sessions, including trust for the immediate parent folder. It writes `~/.diffex/agent/trust.json` only; the current session is not reloaded, so restart Diffex for changes to take effect. -### Telemetry and update checks +### Provider attribution and update checks -Diffex has two separate startup features: +Diffex does not send install or update telemetry reports. +The former `enableInstallTelemetry` setting and `DIFFEX_TELEMETRY` variable are no longer used. -- **Update check:** queries the upstream compatibility release service for the latest CLI version. Disable it with `DIFFEX_SKIP_VERSION_CHECK=1`. Disabling update checks only turns off this check. -- **Install/update telemetry:** after first install or a changelog-detected update, sends an anonymous version ping to the upstream compatibility telemetry service. This setting also controls optional provider attribution headers for OpenRouter, Cloudflare, and direct NVIDIA NIM requests. Opt out by setting `enableInstallTelemetry` to `false` in `settings.json`, or by setting `DIFFEX_TELEMETRY=0`. This does not disable update checks; Diffex may still contact the upstream compatibility release service unless update checks are disabled or offline mode is enabled. +Optional attribution headers for OpenRouter, Cloudflare, and direct NVIDIA NIM requests have a separate `enableProviderAttribution` setting, which defaults to `false`. +Enable it through **Provider attribution** in `/settings`, or override it with `DIFFEX_PROVIDER_ATTRIBUTION=1` (`0` disables it). +This control does not affect update checks, OpenCode session headers, or explicitly configured request headers. -Use `--offline` or `DIFFEX_OFFLINE=1` to disable all startup network operations described here, including update checks, package update checks, and install/update telemetry. +Startup update checks query `https://api.diffex.ai/api/latest-version`. +Disable automatic checks with `DIFFEX_SKIP_VERSION_CHECK=1`. +Use `--offline` or `DIFFEX_OFFLINE=1` to disable startup network operations, including update checks and package update checks. --- @@ -698,9 +702,9 @@ diffex --thinking high "Solve this complex problem" | `DIFFEX_CODING_AGENT_DIR` | Override config directory (default: `~/.diffex/agent`) | | `DIFFEX_CODING_AGENT_SESSION_DIR` | Override session storage directory (overridden by `--session-dir`) | | `DIFFEX_PACKAGE_DIR` | Override package directory (useful for Nix/Guix where store paths tokenize poorly) | -| `DIFFEX_OFFLINE` | Disable startup network operations, including update checks, package update checks, and install/update telemetry | -| `DIFFEX_SKIP_VERSION_CHECK` | Skip the upstream compatibility release check at startup | -| `DIFFEX_TELEMETRY` | Override install/update telemetry and provider attribution headers. Use `1`/`true`/`yes` to enable or `0`/`false`/`no` to disable. This does not disable update checks | +| `DIFFEX_OFFLINE` | Disable startup network operations, including update checks and package update checks | +| `DIFFEX_SKIP_VERSION_CHECK` | Skip the Diffex release check at startup | +| `DIFFEX_PROVIDER_ATTRIBUTION` | Override optional provider attribution headers: `1`/`true`/`yes` enables them; `0`/`false`/`no` disables them. Does not control update checks | | `DIFFEX_CACHE_RETENTION` | Set to `long` for extended prompt cache (Anthropic: 1h, OpenAI: 24h) | | `VISUAL`, `EDITOR` | Fallback external editor for Ctrl+G when `externalEditor` is unset; defaults to Notepad on Windows and `nano` elsewhere | diff --git a/packages/coding-agent/docs/environment-variables.md b/packages/coding-agent/docs/environment-variables.md index 44b87876d..ebcb389fa 100644 --- a/packages/coding-agent/docs/environment-variables.md +++ b/packages/coding-agent/docs/environment-variables.md @@ -76,9 +76,9 @@ These variables are read by Diffex itself: | `DIFFEX_CODING_AGENT_DIR` | Override the config directory; default is `~/.diffex/agent` | | `DIFFEX_CODING_AGENT_SESSION_DIR` | Override session storage; overridden by `--session-dir` | | `DIFFEX_PACKAGE_DIR` | Override the package directory, useful for Nix/Guix store paths | -| `DIFFEX_OFFLINE` | Disable startup network operations, including update checks, package updates, and install/update telemetry | -| `DIFFEX_SKIP_VERSION_CHECK` | Disable the upstream compatibility release check | -| `DIFFEX_TELEMETRY` | Override install/update telemetry and provider attribution headers: `1`/`true`/`yes` or `0`/`false`/`no` | +| `DIFFEX_OFFLINE` | Disable startup network operations, including update checks and package updates | +| `DIFFEX_SKIP_VERSION_CHECK` | Disable the automatic Diffex release check | +| `DIFFEX_PROVIDER_ATTRIBUTION` | Override optional provider attribution headers: `1`/`true`/`yes` or `0`/`false`/`no`; defaults off | | `DIFFEX_CACHE_RETENTION` | Set to `long` for extended provider prompt caching where supported | | `DIFFEX_SHARE_VIEWER_URL` | Override the base URL used by `/share` | | `DIFFEX_HARDWARE_CURSOR` | Set to `1` to show the hardware cursor; see [Terminal setup](terminal-setup.md) | diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index c4b0b2766..78bf3e1b8 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -58,7 +58,7 @@ Use `/model` or Ctrl+L to select a model, then choose from its supported thinkin | `quietStartup` | boolean | `false` | Hide startup header | | `defaultProjectTrust` | string | `"ask"` | Fallback project trust behavior: `"ask"`, `"always"`, or `"never"`. Global setting only | | `collapseChangelog` | boolean | `false` | Show condensed changelog after updates | -| `enableInstallTelemetry` | boolean | `true` | Send an anonymous install/update version ping after first install or changelog-detected updates. This does not control update checks | +| `enableProviderAttribution` | boolean | `false` | Add optional attribution headers to OpenRouter, Cloudflare, and direct NVIDIA NIM requests | | `enableAnalytics` | boolean | `false` | Opt-in analytics data sharing. Currently only asked for during the experimental first-time setup (`DIFFEX_EXPERIMENTAL=1`) | | `trackingId` | string | - | Analytics tracking identifier, generated when `enableAnalytics` is turned on | | `doubleEscapeAction` | string | `"tree"` | Action for double-escape: `"tree"`, `"fork"`, or `"none"` | @@ -78,11 +78,17 @@ For VS Code, include `--wait` so Diffex resumes after the editor exits: } ``` -### Telemetry and update checks +### Provider attribution and update checks -`enableInstallTelemetry` only controls the anonymous install/update ping to the upstream compatibility telemetry service. Opting out of telemetry does not disable update checks; Diffex can still query the upstream compatibility release service for the latest CLI version. +Diffex does not send install or update telemetry reports. +The former `enableInstallTelemetry` setting and `DIFFEX_TELEMETRY` variable are ignored. +Optional provider attribution defaults off so removing the old shared setting cannot re-enable headers for users who previously opted out. +Use `enableProviderAttribution` or `DIFFEX_PROVIDER_ATTRIBUTION` to control optional OpenRouter, Cloudflare, and direct NVIDIA NIM headers independently. +The environment variable takes precedence: `1`/`true`/`yes` enables attribution; `0`/`false`/`no` disables it. +This setting does not control update checks, OpenCode session headers, or explicitly configured request headers. -Set `DIFFEX_SKIP_VERSION_CHECK=1` to disable the upstream compatibility release check. Use `--offline` or `DIFFEX_OFFLINE=1` to disable all startup network operations described here, including update checks, package update checks, and install/update telemetry. +Set `DIFFEX_SKIP_VERSION_CHECK=1` to disable automatic release checks against `https://api.diffex.ai/api/latest-version`. +Use `--offline` or `DIFFEX_OFFLINE=1` to disable startup network operations, including update checks and package update checks. ### Network diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 5fc315522..664d67432 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -403,7 +403,7 @@ ${chalk.bold("Environment Variables:")} ${ENV_SESSION_DIR.padEnd(32)} - Session storage directory (overridden by --session-dir) DIFFEX_PACKAGE_DIR - Override package directory (for Nix/Guix store paths) DIFFEX_OFFLINE - Disable startup network operations when set to 1/true/yes - DIFFEX_TELEMETRY - Override install telemetry when set to 1/true/yes or 0/false/no + DIFFEX_PROVIDER_ATTRIBUTION - Override optional provider attribution headers when set to 1/true/yes or 0/false/no DIFFEX_SHARE_VIEWER_URL - Base URL for /share command (default: https://gist.github.com/) ${chalk.bold("Built-in Tool Names:")} diff --git a/packages/coding-agent/src/core/provider-attribution.ts b/packages/coding-agent/src/core/provider-attribution.ts index ea2b380a8..79dcc6462 100644 --- a/packages/coding-agent/src/core/provider-attribution.ts +++ b/packages/coding-agent/src/core/provider-attribution.ts @@ -1,6 +1,5 @@ import type { Api, Model, ProviderHeaders } from "@diffexai/diffex-ai"; import type { SettingsManager } from "./settings-manager.ts"; -import { isInstallTelemetryEnabled } from "./telemetry.ts"; const OPENROUTER_HOST = "openrouter.ai"; const NVIDIA_NIM_HOST = "integrate.api.nvidia.com"; @@ -37,7 +36,12 @@ function getDefaultAttributionHeaders( model: Model, settingsManager: SettingsManager, ): Record | undefined { - if (!isInstallTelemetryEnabled(settingsManager)) { + const attributionEnv = process.env.DIFFEX_PROVIDER_ATTRIBUTION; + const enabled = + attributionEnv !== undefined + ? ["1", "true", "yes"].includes(attributionEnv.toLowerCase()) + : settingsManager.getEnableProviderAttribution(); + if (!enabled) { return undefined; } diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index bf4f02087..3f4b61e50 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -117,7 +117,7 @@ export interface Settings { shellCommandPrefix?: string; // Prefix prepended to every bash command (e.g., "shopt -s expand_aliases" for alias support) npmCommand?: string[]; // Command used for npm package lookup/install operations, argv-style (e.g., ["mise", "exec", "node@20", "--", "npm"]) collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full) - enableInstallTelemetry?: boolean; // default: true - anonymous version/update ping after changelog-detected updates + enableProviderAttribution?: boolean; // default: false - optional provider attribution headers enableAnalytics?: boolean; // default: false - opt-in analytics data sharing trackingId?: string; // analytics tracking identifier, generated when analytics is enabled packages?: PackageSource[]; // Array of npm/git package sources (string or object with filtering) @@ -1036,13 +1036,13 @@ export class SettingsManager { this.save(); } - getEnableInstallTelemetry(): boolean { - return this.settings.enableInstallTelemetry ?? true; + getEnableProviderAttribution(): boolean { + return this.settings.enableProviderAttribution ?? false; } - setEnableInstallTelemetry(enabled: boolean): void { - this.globalSettings.enableInstallTelemetry = enabled; - this.markModified("enableInstallTelemetry"); + setEnableProviderAttribution(enabled: boolean): void { + this.globalSettings.enableProviderAttribution = enabled; + this.markModified("enableProviderAttribution"); this.save(); } diff --git a/packages/coding-agent/src/core/telemetry.ts b/packages/coding-agent/src/core/telemetry.ts index 14e156959..57bdef88e 100644 --- a/packages/coding-agent/src/core/telemetry.ts +++ b/packages/coding-agent/src/core/telemetry.ts @@ -8,19 +8,6 @@ import type { TelemetrySchemaSpanStartAttributes, TelemetrySchemaSpanUnion, } from "@diffexai/diffex-agent-core"; -import type { SettingsManager } from "./settings-manager.ts"; - -function isTruthyEnvFlag(value: string | undefined): boolean { - if (!value) return false; - return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes"; -} - -export function isInstallTelemetryEnabled( - settingsManager: SettingsManager, - telemetryEnv: string | undefined = process.env.DIFFEX_TELEMETRY, -): boolean { - return telemetryEnv !== undefined ? isTruthyEnvFlag(telemetryEnv) : settingsManager.getEnableInstallTelemetry(); -} const SKILL_OBSERVATION_ATTRIBUTES = { "diffex.skill.id": { diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index 1d5f1cf30..de6ad23a4 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -63,7 +63,7 @@ export interface SettingsConfig { mermaidRenderingMode: MermaidRenderingMode; showCacheMissNotices: boolean; collapseChangelog: boolean; - enableInstallTelemetry: boolean; + enableProviderAttribution: boolean; doubleEscapeAction: "fork" | "tree" | "none"; treeFilterMode: "default" | "no-tools" | "user-only" | "labeled-only" | "all"; showHardwareCursor: boolean; @@ -95,7 +95,7 @@ export interface SettingsCallbacks { onMermaidRenderingModeChange: (mode: MermaidRenderingMode) => void; onShowCacheMissNoticesChange: (shown: boolean) => void; onCollapseChangelogChange: (collapsed: boolean) => void; - onEnableInstallTelemetryChange: (enabled: boolean) => void; + onEnableProviderAttributionChange: (enabled: boolean) => void; onDoubleEscapeActionChange: (action: "fork" | "tree" | "none") => void; onTreeFilterModeChange: (mode: "default" | "no-tools" | "user-only" | "labeled-only" | "all") => void; onShowHardwareCursorChange: (enabled: boolean) => void; @@ -558,10 +558,10 @@ export class SettingsSelectorComponent extends Container implements Focusable { values: ["true", "false"], }, { - id: "install-telemetry", - label: "Install telemetry", - description: "Send an anonymous version/update ping after changelog-detected updates", - currentValue: config.enableInstallTelemetry ? "true" : "false", + id: "provider-attribution", + label: "Provider attribution", + description: "Add optional attribution headers to OpenRouter, Cloudflare, and direct NVIDIA NIM requests", + currentValue: config.enableProviderAttribution ? "true" : "false", values: ["true", "false"], }, { @@ -791,8 +791,8 @@ export class SettingsSelectorComponent extends Container implements Focusable { case "quiet-startup": callbacks.onQuietStartupChange(newValue === "true"); break; - case "install-telemetry": - callbacks.onEnableInstallTelemetryChange(newValue === "true"); + case "provider-attribution": + callbacks.onEnableProviderAttributionChange(newValue === "true"); break; case "default-project-trust": { const defaultProjectTrust = DEFAULT_PROJECT_TRUST_BY_LABEL.get(newValue); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 1758fc050..b9a6abac3 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -110,7 +110,6 @@ import { SettingsManager, type TuiMode } from "../../core/settings-manager.ts"; import type { SkillReference } from "../../core/skills.ts"; import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.ts"; import type { SourceInfo } from "../../core/source-info.ts"; -import { isInstallTelemetryEnabled } from "../../core/telemetry.ts"; import type { TruncationResult } from "../../core/tools/truncate.ts"; import { hasTrustRequiringProjectResources, ProjectTrustStore } from "../../core/trust-manager.ts"; import { getUsageCostBreakdown } from "../../core/usage-totals.ts"; @@ -123,7 +122,6 @@ import { } from "../../utils/changelog.ts"; import { copyToClipboard, readClipboardText } from "../../utils/clipboard.ts"; import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts"; -import { getDiffexUserAgent } from "../../utils/diffex-user-agent.ts"; import { parseGitUrl } from "../../utils/git.ts"; import { openBrowser } from "../../utils/open-browser.ts"; import { getCwdRelativePath } from "../../utils/paths.ts"; @@ -1336,7 +1334,6 @@ export class InteractiveMode { if (selection.checkpointVersion && selection.checkpointVersion !== lastVersion) { this.settingsManager.setLastChangelogVersion(selection.checkpointVersion); - this.reportInstallTelemetry(VERSION); } return selection.entries.length > 0 @@ -1344,25 +1341,6 @@ export class InteractiveMode { : undefined; } - private reportInstallTelemetry(version: string): void { - if (process.env.DIFFEX_OFFLINE) { - return; - } - - if (!isInstallTelemetryEnabled(this.settingsManager)) { - return; - } - - void fetch(`https://pi.dev/api/report-install?version=${encodeURIComponent(version)}`, { - headers: { - "User-Agent": getDiffexUserAgent(version), - }, - signal: AbortSignal.timeout(5000), - }) - .then(() => undefined) - .catch(() => undefined); - } - private getMarkdownThemeWithSettings(): MarkdownTheme { return { ...getMarkdownTheme(), @@ -5224,7 +5202,7 @@ export class InteractiveMode { hideThinkingBlock: this.hideThinkingBlock, mermaidRenderingMode: this.settingsManager.getMermaidRenderingMode(), collapseChangelog: this.settingsManager.getCollapseChangelog(), - enableInstallTelemetry: this.settingsManager.getEnableInstallTelemetry(), + enableProviderAttribution: this.settingsManager.getEnableProviderAttribution(), doubleEscapeAction: this.settingsManager.getDoubleEscapeAction(), treeFilterMode: this.settingsManager.getTreeFilterMode(), showHardwareCursor: this.settingsManager.getShowHardwareCursor(), @@ -5302,8 +5280,8 @@ export class InteractiveMode { onCollapseChangelogChange: (collapsed) => { this.settingsManager.setCollapseChangelog(collapsed); }, - onEnableInstallTelemetryChange: (enabled) => { - this.settingsManager.setEnableInstallTelemetry(enabled); + onEnableProviderAttributionChange: (enabled) => { + this.settingsManager.setEnableProviderAttribution(enabled); }, onQuietStartupChange: (enabled) => { this.settingsManager.setQuietStartup(enabled); diff --git a/packages/coding-agent/test/fixtures/version-update-client.ts b/packages/coding-agent/test/fixtures/version-update-client.ts index c74242741..c9887680f 100644 --- a/packages/coding-agent/test/fixtures/version-update-client.ts +++ b/packages/coding-agent/test/fixtures/version-update-client.ts @@ -28,6 +28,9 @@ export interface RecordedEvent { } export async function reproduce(options: { + settings?: Record; + legacyTelemetryEnv?: string; + providerAttributionEnv?: string; staging?: boolean; installedVersion?: string; interactive?: boolean; @@ -47,7 +50,7 @@ export async function reproduce(options: { const homeDir = join(root, "home"); const cwd = join(root, "project"); for (const dir of [agentDir, homeDir, cwd]) mkdirSync(dir); - writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ theme: "dark", packages: [] })); + writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ theme: "dark", packages: [], ...options.settings })); writeFileSync(recordPath, ""); const env: NodeJS.ProcessEnv = { PATH: process.env.PATH, @@ -61,6 +64,8 @@ export async function reproduce(options: { TSX_TSCONFIG_PATH: join(repositoryDir, "tsconfig.json"), DIFFEX_CODING_AGENT_DIR: agentDir, DIFFEX_NO_LOCAL_LLM: "1", + DIFFEX_TELEMETRY: options.legacyTelemetryEnv, + DIFFEX_PROVIDER_ATTRIBUTION: options.providerAttributionEnv, DIFFEX_REPRO_RECORD: recordPath, DIFFEX_REPRO_STAGING: options.staging ? "1" : "", DIFFEX_REPRO_VERSION: options.version ?? (options.force ? installed.version : "9999.0.0"), @@ -142,7 +147,8 @@ export async function reproduce(options: { [], output, ); - return { code, events, output: stripVTControlCharacters(output), quitSent, prefix: root }; + const settings = JSON.parse(readFileSync(join(agentDir, "settings.json"), "utf8")) as Record; + return { code, events, output: stripVTControlCharacters(output), quitSent, prefix: root, settings }; } finally { clearTimeout(timeout); clearInterval(exitPoll); diff --git a/packages/coding-agent/test/install-telemetry-e2e.test.ts b/packages/coding-agent/test/install-telemetry-e2e.test.ts new file mode 100644 index 000000000..d326ea9de --- /dev/null +++ b/packages/coding-agent/test/install-telemetry-e2e.test.ts @@ -0,0 +1,35 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { endpoint, installed, reproduce } from "./fixtures/version-update-client.ts"; + +const changelog = readFileSync(new URL("../CHANGELOG.md", import.meta.url), "utf8"); +const [checkpoint, previousCheckpoint] = [...changelog.matchAll(/^## \[([0-9]+\.[0-9]+\.[0-9]+)\]/gm)].map( + (match) => match[1], +); + +// Run the real CLI in a PTY with isolated settings and all outbound traffic intercepted. +describe.skipIf(process.platform === "win32")("interactive startup without install reporting", () => { + it.each([ + { name: "first install", settings: {} }, + { name: "upgrade", settings: { lastChangelogVersion: previousCheckpoint, collapseChangelog: true } }, + { name: "current install", settings: { lastChangelogVersion: checkpoint } }, + { name: "legacy opt-in", settings: { enableInstallTelemetry: true }, legacyTelemetryEnv: "1" }, + { name: "legacy opt-out", settings: { enableInstallTelemetry: false }, legacyTelemetryEnv: "0" }, + { + name: "provider attribution opt-in", + settings: { enableProviderAttribution: true }, + providerAttributionEnv: "1", + }, + { name: "offline", settings: {}, offline: true }, + { name: "version check enabled", settings: {}, skip: false }, + ])("never sends an install report for $name", async ({ name: _name, ...options }) => { + const result = await reproduce({ interactive: true, skip: true, packageName: installed.name, ...options }); + expect(result.code, result.output).toBe(0); + expect(result.quitSent).toBe(true); + expect(result.settings.lastChangelogVersion).toBe(checkpoint); + expect(result.events.filter((event) => event.type === "request").map((event) => event.url)).toEqual( + options.skip === false ? [endpoint] : [], + ); + expect(result.events.filter((event) => event.type === "spawn")).toEqual([]); + }); +}); diff --git a/packages/coding-agent/test/sdk-openrouter-attribution.test.ts b/packages/coding-agent/test/sdk-openrouter-attribution.test.ts index ee23376a2..91a2a1707 100644 --- a/packages/coding-agent/test/sdk-openrouter-attribution.test.ts +++ b/packages/coding-agent/test/sdk-openrouter-attribution.test.ts @@ -21,7 +21,7 @@ describe("createAgentSession provider attribution headers", () => { let tempDir: string; let cwd: string; let agentDir: string; - let originalTelemetryEnv: string | undefined; + let originalAttributionEnv: string | undefined; beforeEach(() => { tempDir = join(tmpdir(), `pi-sdk-attribution-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); @@ -29,15 +29,15 @@ describe("createAgentSession provider attribution headers", () => { agentDir = join(tempDir, "agent"); mkdirSync(cwd, { recursive: true }); mkdirSync(agentDir, { recursive: true }); - originalTelemetryEnv = process.env.DIFFEX_TELEMETRY; - delete process.env.DIFFEX_TELEMETRY; + originalAttributionEnv = process.env.DIFFEX_PROVIDER_ATTRIBUTION; + delete process.env.DIFFEX_PROVIDER_ATTRIBUTION; }); afterEach(async () => { - if (originalTelemetryEnv === undefined) { - delete process.env.DIFFEX_TELEMETRY; + if (originalAttributionEnv === undefined) { + delete process.env.DIFFEX_PROVIDER_ATTRIBUTION; } else { - process.env.DIFFEX_TELEMETRY = originalTelemetryEnv; + process.env.DIFFEX_PROVIDER_ATTRIBUTION = originalAttributionEnv; } if (tempDir && existsSync(tempDir)) { rmSync(tempDir, { recursive: true, force: true }); @@ -85,16 +85,14 @@ describe("createAgentSession provider attribution headers", () => { async function captureHeaders( model: Model, options: { - telemetryEnabled?: boolean; + attributionEnabled?: boolean; providerHeaders?: Record; requestHeaders?: Record; sessionId?: string; } = {}, ): Promise { const settingsManager = SettingsManager.create(cwd, agentDir); - if (options.telemetryEnabled === false) { - settingsManager.setEnableInstallTelemetry(false); - } + settingsManager.setEnableProviderAttribution(options.attributionEnabled ?? true); const authStorage = AuthStorage.inMemory({ [model.provider]: { type: "api_key", key: "test-api-key" }, @@ -143,6 +141,28 @@ describe("createAgentSession provider attribution headers", () => { } } + it.each(["0", "false", "NO", ""])('lets the environment disable provider attribution with "%s"', async (value) => { + process.env.DIFFEX_PROVIDER_ATTRIBUTION = value; + const headers = await captureHeaders(createModel("openrouter", "https://openrouter.ai/api/v1")); + expect(headers ?? {}).toEqual({}); + }); + + it.each(["1", "TRUE", "yes"])('lets the environment enable provider attribution with "%s"', async (value) => { + process.env.DIFFEX_PROVIDER_ATTRIBUTION = value; + const headers = await captureHeaders(createModel("openrouter", "https://openrouter.ai/api/v1"), { + attributionEnabled: false, + }); + expect(headers?.["X-OpenRouter-Categories"]).toBe("cli-agent"); + }); + + it("defaults provider attribution off and persists its independent setting", async () => { + const settings = SettingsManager.create(cwd, agentDir); + expect(settings.getEnableProviderAttribution()).toBe(false); + settings.setEnableProviderAttribution(true); + await settings.flush(); + expect(SettingsManager.create(cwd, agentDir).getEnableProviderAttribution()).toBe(true); + }); + it("adds default attribution headers for OpenRouter models", async () => { const headers = await captureHeaders(createModel("openrouter", "https://openrouter.ai/api/v1")); @@ -151,9 +171,9 @@ describe("createAgentSession provider attribution headers", () => { expect(headers?.["X-OpenRouter-Categories"]).toBe("cli-agent"); }); - it("does not add attribution headers when telemetry is disabled", async () => { + it("does not add attribution headers when provider attribution is disabled", async () => { const headers = await captureHeaders(createModel("openrouter", "https://openrouter.ai/api/v1"), { - telemetryEnabled: false, + attributionEnabled: false, }); expect(headers?.["HTTP-Referer"]).toBeUndefined(); @@ -205,9 +225,9 @@ describe("createAgentSession provider attribution headers", () => { expect(headers?.["X-BILLING-INVOKE-ORIGIN"]).toBe("Pi"); }); - it("does not add NVIDIA NIM attribution headers when telemetry is disabled", async () => { + it("does not add NVIDIA NIM attribution headers when provider attribution is disabled", async () => { const headers = await captureHeaders(createModel("nvidia", "https://integrate.api.nvidia.com/v1"), { - telemetryEnabled: false, + attributionEnabled: false, }); expect(headers?.["X-BILLING-INVOKE-ORIGIN"]).toBeUndefined(); diff --git a/packages/coding-agent/test/settings-selector.test.ts b/packages/coding-agent/test/settings-selector.test.ts index 0af42356e..497baeb9b 100644 --- a/packages/coding-agent/test/settings-selector.test.ts +++ b/packages/coding-agent/test/settings-selector.test.ts @@ -33,7 +33,7 @@ function settingsConfig(overrides: Partial = {}): SettingsConfig mermaidRenderingMode: "final", showCacheMissNotices: true, collapseChangelog: true, - enableInstallTelemetry: true, + enableProviderAttribution: false, doubleEscapeAction: "tree", treeFilterMode: "default", showHardwareCursor: false, @@ -245,6 +245,22 @@ describe("SettingsSelectorComponent", () => { expect(restoredLines.at(-1)).toContain("Type to search · Enter/Space to change · Esc to cancel"); }); + it("offers provider attribution without an install telemetry control", () => { + const onEnableProviderAttributionChange = vi.fn(); + const selector = new SettingsSelectorComponent(settingsConfig(), { + onEnableProviderAttributionChange, + } as unknown as SettingsCallbacks); + const settingsList = selector.getSettingsList(); + + for (const character of "attribution") settingsList.handleInput(character); + const rendered = stripAnsi(selector.render(80).join("\n")); + expect(rendered).toContain("Provider attribution"); + expect(rendered).toContain("false"); + expect(rendered).not.toContain("Install telemetry"); + settingsList.handleInput("\r"); + expect(onEnableProviderAttributionChange).toHaveBeenCalledWith(true); + }); + it("only exposes thinking visibility in settings", () => { const onHideThinkingBlockChange = vi.fn(); const selector = new SettingsSelectorComponent(settingsConfig(), { diff --git a/packages/coding-agent/test/version-update-e2e.test.ts b/packages/coding-agent/test/version-update-e2e.test.ts index 37302422f..f00e2e084 100644 --- a/packages/coding-agent/test/version-update-e2e.test.ts +++ b/packages/coding-agent/test/version-update-e2e.test.ts @@ -167,6 +167,7 @@ describe.skipIf(process.platform === "win32")("version discovery through the sou expect(result.code, result.output).toBe(0); expect(result.quitSent).toBe(true); expect(versionRequests(result.events)).toHaveLength(expectedRequests); + expect(result.events.filter((event) => event.type === "request" && event.url !== endpoint)).toEqual([]); expect(result.events.filter((event) => event.type === "spawn")).toEqual([]); expect(result.output).not.toContain("New version"); expect(result.output).not.toContain("Could not determine latest"); diff --git a/scripts/fixtures/release-client-canary.mjs b/scripts/fixtures/release-client-canary.mjs index e03817fcf..7f0eed081 100644 --- a/scripts/fixtures/release-client-canary.mjs +++ b/scripts/fixtures/release-client-canary.mjs @@ -6,10 +6,7 @@ export async function checkStagingClient(name, options, expected) { assert.equal(result.code, expected.code, result.output); const requests = result.events.filter((event) => event.type === "request" && event.url === endpoint); const interceptedOtherRequests = result.events.filter((event) => event.type === "request" && event.url !== endpoint); - for (const request of interceptedOtherRequests) { - // Install telemetry is a separate migration; the preload returns 503 without forwarding it. - assert.match(request.url, /^https:\/\/pi\.dev\/api\/report-install\?version=[0-9.]+$/); - } + assert.deepEqual(interceptedOtherRequests, [], "Unexpected startup request"); const connections = result.events.filter((event) => event.type === "staging-connect"); assert.equal(connections.length > 0, expected.requests > 0); for (const connection of connections) {