diff --git a/README.md b/README.md index 3b1cd17750e..02ec20ec5af 100644 --- a/README.md +++ b/README.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, 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. +- 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, 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..1e36eb0060d --- /dev/null +++ b/desktop/scripts/telemetry-installer-dialog.ts @@ -0,0 +1,188 @@ +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 (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`. + * + * `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_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: "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 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). +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; +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; + const noteY = checkboxY + 17; + if (noteY + LINE_HEIGHT > 232) { + throw new Error(`The telemetry disclosure no longer fits the installer dialog (it ends at ${noteY + LINE_HEIGHT}).`); + } + return ` + + + + + + + + + + + + + + + + + + + + + ${TELEMETRY_INSTALLER_CHOICE_PROPERTY} <> "" AND ${TELEMETRY_INSTALLER_CHOICE_PROPERTY} ~<> "${TELEMETRY_INSTALLER_SEND}" + + ${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_CHOICE_PROPERTY} = "" + + ${TELEMETRY_INSTALLER_CHOICE_PROPERTY} ~= "${TELEMETRY_INSTALLER_SEND}" + + + + + + + + + + + + + + +${controls.join("\n")} + + Read the full privacy policy]]> + + + + + 1 + + + ${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 + + + +`; +} + +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..8631adc98b2 --- /dev/null +++ b/desktop/src-tauri/src/installer_telemetry.rs @@ -0,0 +1,545 @@ +//! 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 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) 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 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). 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. +//! 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; +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"; +#[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. +#[allow(dead_code)] +pub const OPT_OUT: &str = "optout"; +pub const SEND: &str = "send"; + +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), + Duration::from_secs(30), + Duration::from_secs(120), + Duration::from_secs(600), +]; +const RETRY_TIMEOUT: Duration = Duration::from_secs(30); + +/// 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. 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, + 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 every pending stamp is recorded. + Applied, + /// 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, +} + +/// Fail closed like the installer: any recorded value other than `send` (or nothing) is an opt-out. +fn is_opt_out(choice: &str) -> bool { + 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. +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. +pub fn decline_persisted(exit_code: Option) -> bool { + exit_code == Some(0) +} + +/// 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, +) -> Outcome { + if pending.is_empty() { + return Outcome::NothingPending; + } + if !persist() { + return Outcome::NotPersisted; + } + 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 + } +} + +/// 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 current user's opencodex config. +pub async fn apply(app: &AppHandle, deadline: Instant) -> Outcome { + 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(®istry, &pending, || persisted); + 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", + "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, pending).await }); + } + 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", + ), + Outcome::NothingPending | Outcome::Applied => {} + } + outcome +} + +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 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", + ); + } + return; + } + } + crate::logging::log_once( + "the installer's telemetry opt-out could not be saved after retries", + "the next launch tries again; check that config.json is writable", + ); +} + +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 super::{InstallerChoice, InstallerRegistry, Scope}; + use winreg::enums::{ + HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, KEY_READ, KEY_WOW64_32KEY, KEY_WOW64_64KEY, + }; + use winreg::RegKey; + + 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 + } + + 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() + } + + 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 { + use super::{InstallerChoice, InstallerRegistry, Scope}; + + /// Only the Windows installer records a choice. + pub struct SystemRegistry; + + 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_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 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 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!( + registry.applied(Scope::Machine).as_deref(), + Some("11/8/2026 10:12:33") + ); + // 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 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 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, " Send ", "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 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(&[ + (Scope::User, "optout", "u1"), + (Scope::Machine, " OptOut ", "m1"), + ]); + let pending = pending_opt_outs(®istry); + assert_eq!( + pending + .iter() + .map(|choice| choice.scope) + .collect::>(), + vec![Scope::Machine, Scope::User] + ); + // 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!( + 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/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 1e36a108fba..a9325d3a6fd 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..56b36c19f40 --- /dev/null +++ b/desktop/src-tauri/wix/telemetry-choice.wxs @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + OCX_TELEMETRY_CHOICE <> "" AND OCX_TELEMETRY_CHOICE ~<> "send" + + 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_CHOICE = "" + + OCX_TELEMETRY_CHOICE ~= "send" + + + + + + + + + + + + + + + + + + + + + Read the full privacy policy]]> + + + + + 1 + + + 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 33a593d15e4..9d6ce126ebc 100644 --- a/docs-site/src/content/docs/guides/desktop-app.md +++ b/docs-site/src/content/docs/guides/desktop-app.md @@ -28,6 +28,37 @@ 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. 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 +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. +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. 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. + +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/docs-site/src/content/docs/reference/code-signing.md b/docs-site/src/content/docs/reference/code-signing.md index 5de0a2d2fb9..64a2b79dc0a 100644 --- a/docs-site/src/content/docs/reference/code-signing.md +++ b/docs-site/src/content/docs/reference/code-signing.md @@ -35,10 +35,11 @@ version, release channel, platform, install channel, provider kind, coarse model 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 +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 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 304305ab1d5..989363a26c9 100644 --- a/docs-site/src/content/docs/tr/reference/code-signing.md +++ b/docs-site/src/content/docs/tr/reference/code-signing.md @@ -36,13 +36,14 @@ 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. +bilgisayar adları veya dosya 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 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 9d7b7d526b6..1bfdcebf72a 100644 --- a/readme/README.fr.md +++ b/readme/README.fr.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, 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. +- 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, 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 e528a6a985a..fc73032fae5 100644 --- a/readme/README.ja.md +++ b/readme/README.ja.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, 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. +- 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, 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 fbd6d2b1768..26e9ea27356 100644 --- a/readme/README.ko.md +++ b/readme/README.ko.md @@ -479,7 +479,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, 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. +- 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, 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 2db01d2971c..e823e29e314 100644 --- a/readme/README.ru.md +++ b/readme/README.ru.md @@ -508,7 +508,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, 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. +- 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, 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 28f074d6b40..0072163b7b4 100644 --- a/readme/README.tr.md +++ b/readme/README.tr.md @@ -504,7 +504,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, 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. +- 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, 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 71bc7f80e73..b27dc3f2ccd 100644 --- a/readme/README.zh-CN.md +++ b/readme/README.zh-CN.md @@ -477,7 +477,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, 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. +- 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, 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 3acac389a62..b9fbc417b0f 100644 --- a/readme/README.zh-TW.md +++ b/readme/README.zh-TW.md @@ -476,7 +476,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, 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. +- 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, 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 f10a3cbb2c7..ecc421766fe 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": "773cb151a0c73420e0a2dfbf4b8ff274c865a5cb0235875185bf769fbeaf10b0" + "sourceSha256": "a0206f4cc9443941194c818728dd433d9efdd366a253b4fda4f07aafba26a61c" }, "ko": { "file": "readme/README.ko.md", "label": "한국어", "docsPath": "ko", - "sourceSha256": "773cb151a0c73420e0a2dfbf4b8ff274c865a5cb0235875185bf769fbeaf10b0" + "sourceSha256": "a0206f4cc9443941194c818728dd433d9efdd366a253b4fda4f07aafba26a61c" }, "zh-CN": { "file": "readme/README.zh-CN.md", "label": "简体中文", "docsPath": "zh-cn", - "sourceSha256": "773cb151a0c73420e0a2dfbf4b8ff274c865a5cb0235875185bf769fbeaf10b0" + "sourceSha256": "a0206f4cc9443941194c818728dd433d9efdd366a253b4fda4f07aafba26a61c" }, "zh-TW": { "file": "readme/README.zh-TW.md", "label": "繁體中文", "docsPath": "zh-tw", - "sourceSha256": "773cb151a0c73420e0a2dfbf4b8ff274c865a5cb0235875185bf769fbeaf10b0" + "sourceSha256": "a0206f4cc9443941194c818728dd433d9efdd366a253b4fda4f07aafba26a61c" }, "ru": { "file": "readme/README.ru.md", "label": "Русский", "docsPath": "ru", - "sourceSha256": "773cb151a0c73420e0a2dfbf4b8ff274c865a5cb0235875185bf769fbeaf10b0" + "sourceSha256": "a0206f4cc9443941194c818728dd433d9efdd366a253b4fda4f07aafba26a61c" }, "ja": { "file": "readme/README.ja.md", "label": "日本語", "docsPath": "ja", - "sourceSha256": "773cb151a0c73420e0a2dfbf4b8ff274c865a5cb0235875185bf769fbeaf10b0" + "sourceSha256": "a0206f4cc9443941194c818728dd433d9efdd366a253b4fda4f07aafba26a61c" }, "tr": { "file": "readme/README.tr.md", "label": "Türkçe", "docsPath": "tr", - "sourceSha256": "773cb151a0c73420e0a2dfbf4b8ff274c865a5cb0235875185bf769fbeaf10b0" + "sourceSha256": "a0206f4cc9443941194c818728dd433d9efdd366a253b4fda4f07aafba26a61c" } } } diff --git a/scripts/test-layout/layout-clients.json b/scripts/test-layout/layout-clients.json index 6add5da82b2..bd7c02a5eed 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 8cbc0be3d65..94aea773ffc 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 { @@ -36,12 +37,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 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", - " • 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).", + ` ${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..469702d5603 --- /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 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", + "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).", +]); diff --git a/structure/desktop-shell.md b/structure/desktop-shell.md index 3cb595286a7..9f080238590 100644 --- a/structure/desktop-shell.md +++ b/structure/desktop-shell.md @@ -361,6 +361,67 @@ 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. 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; 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; 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 +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 `~<>`). 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; 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. 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 +`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 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 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 cb2762b1c27..12eaafe47a9 100644 --- a/structure/telemetry.md +++ b/structure/telemetry.md @@ -46,6 +46,9 @@ 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 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. diff --git a/tests/clients/desktop-telemetry-installer.test.ts b/tests/clients/desktop-telemetry-installer.test.ts new file mode 100644 index 00000000000..3b2304f6ed1 --- /dev/null +++ b/tests/clients/desktop-telemetry-installer.test.ts @@ -0,0 +1,411 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync, 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_KEEP_NOTE, + TELEMETRY_INSTALLER_OPT_OUT, + TELEMETRY_INSTALLER_REGISTRY_KEY, + TELEMETRY_INSTALLER_REGISTRY_VALUE, + 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"; +import { + TELEMETRY_DISCLOSURE_HEADING, + TELEMETRY_DISCLOSURE_POINTS, + TELEMETRY_POLICY_URL, +} from "../../src/telemetry/disclosure"; +import { readTelemetryState, runTelemetryTick, withPendingTelemetryDecision } 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("&", "&"); +} + +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; + /** In a full-UI run: the box state at each Next click, with Back in between (overrides `box`). */ + clicks?: 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 !== 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) { + 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 next = //.exec(wxs)?.[0] ?? ""; + // 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. + 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"); + + 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).toMatch(new RegExp(`Type="CheckBox"[^>]*Property="${TELEMETRY_INSTALLER_CHECKBOX_PROPERTY}" CheckBoxValue="1" Text="${TELEMETRY_INSTALLER_CHECKBOX_TEXT}"`)); + // 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} ~= "send"`, + ); + // 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; 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( + `${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", () => { + // 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("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); + } + } + }); + + 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"; + 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("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 }); + }); + + 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("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); + // 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 }); + }); + }); + + 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. + // 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(/ { + 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 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", () => { + 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"])`); + // 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, pending\)\.await \}\)/); + }); + + 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); + // On a fresh install the decline is held without creating config.json, so `ocx init` still runs setup. + expect(existsSync(join(home, "ocx", "config.json"))).toBe(false); + expect(readTelemetryState(join(home, "ocx")).pendingDecision?.decision).toBe("declined"); + expect(resolveTelemetryStatus(withPendingTelemetryDecision({}, join(home, "ocx")), {}).effective).toBe(false); + + let fetchCalls = 0; + const tick = await runTelemetryTick({ + configDir: join(home, "ocx"), + readConfig: () => ({}), + 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 e6333d7f99c..bc0c4ffa66b 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -838,7 +838,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",