diff --git a/README.md b/README.md index 7447dd1..488a0f9 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ Environment overrides: `AUTTER_API_BASE_URL`, `AUTTER_WEB_URL`, `AUTTER_NOTES_BA ### Telemetry -Autter reports **anonymous** usage analytics and error data so the tool can improve. Telemetry is **on by default**, is confirmed during `autter onboard`, and is built to be safe and fully auditable: +Autter reports **anonymous** usage analytics and error data so the tool can improve. Telemetry is **off until you opt in** during `autter onboard` (or with `autter telemetry on`), and is built to be safe and fully auditable: - **Never any personal data** — no code, prompts, file paths, repo names, usernames, or IP addresses. - **Only coarse, anonymous signals** — a random install ID, device info (OS, CPU architecture, core count), the Autter version, and error/exception messages. diff --git a/data-privacy.md b/data-privacy.md index f8ec7ca..d3acb99 100644 --- a/data-privacy.md +++ b/data-privacy.md @@ -2,9 +2,11 @@ ## OSS Mode -If you install Autter open source and don't login **no code, prompts, or agent usage data is ever sent to Autter**. Autter runs entirely on your machine and writes attribution data into your local git repository and prompts to a local SQLite. +Unless you choose connected mode (`autter onboard --connect`, or "Connected" in `autter onboard`), **no code, prompts, or agent usage data is ever sent to Autter**. Signing in with `autter login` alone does not turn on uploads. Autter runs entirely on your machine and writes attribution data into your local git repository and prompts to a local SQLite. `autter whoami` and `autter sync status` show "Cloud upload: off" exactly when nothing is being uploaded. -The only data Autter sends externally in open source mode is error and exception telemetry, which is enabled by default to help us improve the tool. You can disable or redirect it at any time by turning `telemetry_oss` to `off`. See [configuration options](https://autter.dev/docs/cli/configuration#configuration-options) for details. +Before you finish `autter onboard`, Autter does not read your AI agents' session history, and plain commands such as `autter status` or `autter doctor` do not start the background service. + +The only other data Autter can send is anonymous error and usage telemetry. It is off until you opt in during `autter onboard` or with `autter telemetry on`, and you can turn it off at any time by setting `telemetry_oss` to `off`. See [configuration options](https://autter.dev/docs/cli/configuration#configuration-options) for details. ## Data diff --git a/src/auth/notice.rs b/src/auth/notice.rs index 1034700..9887fc9 100644 --- a/src/auth/notice.rs +++ b/src/auth/notice.rs @@ -267,9 +267,10 @@ pub enum CloudSyncAttention { DaemonNotRunning, } +/// Whether cloud upload is on. Same predicate the uploaders check, so the +/// status line can never say "off" while data is being sent. fn cloud_sync_enabled() -> bool { - let cfg = crate::config::Config::fresh(); - cfg.platform_sync_enabled() && cfg.notes_backend_kind().uses_http() + crate::config::platform_sync_enabled_now() } fn background_service_running() -> bool { @@ -322,7 +323,8 @@ pub struct PendingSyncCountsJson { /// Machine-readable cloud-sync health for status commands. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] pub struct CloudSyncStatusReport { - /// Whether the configured notes backend uploads to autter cloud. + /// Whether this machine uploads to autter cloud (connected mode). Matches + /// what every uploader checks: when false, nothing is sent. pub enabled: bool, pub daemon_running: bool, /// Overall state: healthy, draining a backlog, or blocked. diff --git a/src/commands/autter_handlers.rs b/src/commands/autter_handlers.rs index 90b974d..b2e5b82 100644 --- a/src/commands/autter_handlers.rs +++ b/src/commands/autter_handlers.rs @@ -119,9 +119,15 @@ pub fn handle_autter(args: &[String]) { ); if needs_daemon { use crate::daemon::telemetry_handle::{ - DaemonTelemetryInitResult, init_daemon_telemetry_handle, + DaemonTelemetryInitResult, init_daemon_telemetry_handle_with, }; - match init_daemon_telemetry_handle() { + // Before onboarding, plain commands use a daemon that is already + // running but never start one: the background service is a system + // integration that needs the user's consent. Agent checkpoints come + // from hooks the user already installed, so they may still start it. + let allow_spawn = + args[0].as_str() == "checkpoint" || crate::config::collection_consented_now(); + match init_daemon_telemetry_handle_with(allow_spawn) { DaemonTelemetryInitResult::Connected | DaemonTelemetryInitResult::Skipped => {} DaemonTelemetryInitResult::Failed(err) => { eprintln!( diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs index 84f03e0..a618240 100644 --- a/src/commands/doctor.rs +++ b/src/commands/doctor.rs @@ -160,7 +160,13 @@ fn run_doctor(options: &DoctorOptions) -> DoctorOutput { "background service", crate::diagnostics::ensure_daemon_ready_for_self_checks(), ); - let daemon_ok = daemon_check.status != DoctorStatus::Failed; + // Skipped means the service is intentionally not running (before + // onboarding), so there is nothing to send a git event to. + let daemon_check_skipped = daemon_check.status == DoctorStatus::Skipped; + let daemon_ok = !matches!( + daemon_check.status, + DoctorStatus::Failed | DoctorStatus::Skipped + ); reporter.add(daemon_check); // Separately: do real git trace2 events reach the service? A failure here @@ -176,7 +182,11 @@ fn run_doctor(options: &DoctorOptions) -> DoctorOutput { reporter.add(skipped_check( SECTION_SERVICE, "trace2 ingestion", - "skipped -- fix the background service check first", + if daemon_check_skipped { + "skipped -- the background service starts after `autter onboard`" + } else { + "skipped -- fix the background service check first" + }, )); } diff --git a/src/commands/login.rs b/src/commands/login.rs index 57d4384..3ac1db6 100644 --- a/src/commands/login.rs +++ b/src/commands/login.rs @@ -86,8 +86,8 @@ pub fn run_device_login_with(login_ui: LoginUi) -> Result let ctx = crate::api::client::ApiContext::new(None); if ctx.auth_token.is_some() { crate::auth::notice::clear_sync_auth_blocked(); - eprintln!("Stored credentials still work. Resuming cloud sync."); - resume_cloud_sync_after_login(false); + eprintln!("Stored credentials still work."); + resume_cloud_sync_after_login(false, login_ui.decline); return Ok(LoginOutcome::LoggedIn); } eprintln!("Stored credentials could not authenticate. Starting a new sign-in...\n"); @@ -162,7 +162,7 @@ pub fn run_device_login_with(login_ui: LoginUi) -> Result caps.paint("1;32", &format!("{ok} CLI authorization successful.")) ); print_login_identity(&creds.access_token); - resume_cloud_sync_after_login(false); + resume_cloud_sync_after_login(false, login_ui.decline); Ok(LoginOutcome::LoggedIn) } @@ -267,7 +267,7 @@ fn finish_interactive( &format!("Signed in as {}", identity_summary(&creds.access_token)), login_ui.counter, ); - resume_cloud_sync_after_login(true); + resume_cloud_sync_after_login(true, login_ui.decline); Ok(LoginOutcome::LoggedIn) } @@ -330,7 +330,7 @@ pub fn run_pat_login(token: &str) -> Result { eprintln!("Successfully logged in with a Personal Access Token."); print_login_identity(&creds.access_token); - resume_cloud_sync_after_login(false); + resume_cloud_sync_after_login(false, Decline::Cancel); Ok(LoginOutcome::LoggedIn) } @@ -382,10 +382,13 @@ fn print_login_identity(access_token: &str) { /// any in-memory sync backoff. Skipped in test harnesses, which manage their /// own daemon lifecycle. Best-effort: a failed restart must not fail login. /// `on_rail` prints the notes under an interactive flow's rail. -fn resume_cloud_sync_after_login(on_rail: bool) { - if std::env::var_os("AUTTER_TEST_DB_PATH").is_some() { - return; - } +/// +/// Signing in never turns cloud sync on by itself. Outside connected mode +/// (see [`crate::config::Config::platform_sync_enabled`]) nothing uploads, so +/// say so instead of promising an upload, and leave the service alone. +/// `decline` tells standalone `autter login` (`Cancel`) apart from onboarding +/// (`ContinueLocally`), which picks the mode itself right after sign-in. +fn resume_cloud_sync_after_login(on_rail: bool, decline: Decline) { let say = |text: &str| { if on_rail { rail::print_note(text); @@ -394,6 +397,20 @@ fn resume_cloud_sync_after_login(on_rail: bool) { } }; + if !crate::config::platform_sync_enabled_now() { + // Inside onboarding, `setup_connected` switches to connected mode right + // after this returns, and onboarding restarts the service itself. + if decline == Decline::Cancel { + say("Cloud upload is off: signing in does not upload anything."); + say("To sync this machine with your org, run `autter onboard --connect`."); + } + return; + } + + if std::env::var_os("AUTTER_TEST_DB_PATH").is_some() { + return; + } + let pending = crate::auth::notice::pending_sync_counts(); if pending.total() > 0 { say(&format!( diff --git a/src/commands/onboard.rs b/src/commands/onboard.rs index ca3b816..c9e0d2d 100644 --- a/src/commands/onboard.rs +++ b/src/commands/onboard.rs @@ -89,7 +89,10 @@ pub fn handle_onboard(args: &[String]) { let logged_in = matches!(collect_auth_status().state, AuthState::LoggedIn); let interactive = ui::is_interactive(); - let ask_mode = !(choose_connect || choose_local || logged_in); + // Being signed in is not a choice to upload: credentials can come from + // `autter login` or an install nonce without the user ever picking + // connected mode. Only `--connect` or the interactive answer turns sync on. + let ask_mode = !(choose_connect || choose_local); if ask_mode && !interactive { // Non-interactive shell with no explicit choice: don't block automated @@ -117,12 +120,8 @@ pub fn handle_onboard(args: &[String]) { true } else if choose_local { false - } else if logged_in { - // Already authenticated (e.g. via an install nonce): treat as connected - // without prompting. - true } else { - prompt_mode_choice(steps.next().as_deref()) + prompt_mode_choice(steps.next().as_deref(), logged_in) }; steps.set_total(plan.total(connect)); @@ -142,7 +141,12 @@ pub fn handle_onboard(args: &[String]) { crate::upload_hold::PURGE_COMMAND, ); } - if let Err(e) = setup_connected(&mut file_config, logged_in, !ask_mode, &mut steps) { + if let Err(e) = setup_connected( + &mut file_config, + logged_in, + !ask_mode || logged_in, + &mut steps, + ) { report.problem( format!( "Could not connect to the Autter platform ({e}); set up in local mode instead" @@ -338,11 +342,16 @@ fn restart_daemon_for_mode_change() -> Result<(), String> { } /// The interactive connected-vs-local choice, as an arrow-key selector. -fn prompt_mode_choice(counter: Option<&str>) -> bool { +fn prompt_mode_choice(counter: Option<&str>, logged_in: bool) -> bool { + let connected_hint = if logged_in { + "Powers prompt search, team dashboards, and PR authorship views. Uses the account you're signed in with." + } else { + "Powers prompt search, team dashboards, and PR authorship views. Sign-in opens in your browser." + }; let items = [ SelectItem::new( "Connected — link this machine to your Autter org (recommended)", - "Powers prompt search, team dashboards, and PR authorship views. Sign-in opens in your browser.", + connected_hint, ), SelectItem::new( "Local only — everything stays on this machine", @@ -683,7 +692,7 @@ fn print_status_summary() { /// Show the current telemetry setting and where to inspect what's sent. fn print_telemetry_summary(cfg: &config::FileConfig) { - let enabled = cfg.telemetry_oss.as_deref() != Some("off"); + let enabled = config::telemetry_oss_opted_in(cfg.telemetry_oss.as_deref()); eprintln!(); if enabled { eprintln!("Anonymous telemetry is ON. Everything sent is logged at:"); diff --git a/src/commands/telemetry.rs b/src/commands/telemetry.rs index 7d7d3e4..71f4faf 100644 --- a/src/commands/telemetry.rs +++ b/src/commands/telemetry.rs @@ -33,12 +33,13 @@ fn log_path() -> Option { fn is_enabled() -> bool { // Reads the persisted choice directly so it reflects edits made outside this - // process. Telemetry is on unless explicitly set to "off". - config::load_file_config_public() - .ok() - .and_then(|c| c.telemetry_oss) - .as_deref() - != Some("off") + // process. Telemetry is off until explicitly set to "on". + config::telemetry_oss_opted_in( + config::load_file_config_public() + .ok() + .and_then(|c| c.telemetry_oss) + .as_deref(), + ) } fn print_status() { diff --git a/src/config.rs b/src/config.rs index 2aadb2b..f62a891 100644 --- a/src/config.rs +++ b/src/config.rs @@ -577,20 +577,31 @@ impl Config { /// Whether this machine may send data to the Autter platform at all. /// - /// Local mode is `prompt_storage = "local"` (what `autter onboard --local` - /// writes, and what onboarding itself treats as "not connected"). In local - /// mode nothing is uploaded: metrics, transcripts (CAS), authorship notes, - /// commit summaries and file-change counts all stay on the machine, even - /// when credentials are present. Every platform uploader must check this - /// before it builds an API client or enqueues upload-only data. + /// This is the single source of truth for cloud sync. The status line + /// (`whoami`, `sync status`, `doctor`, `autter` home) and every uploader + /// (metrics, commit summaries, transcripts/CAS, authorship notes, + /// file-change counts) must agree on it, so "Cloud upload: off" always + /// means nothing is sent. /// - /// Connected mode is every other value. Uploads there still require valid - /// credentials; this predicate only says the mode permits them. + /// Sync is opt-in. It is on only in connected mode, which needs both: + /// - `prompt_storage` is not `"local"`, and + /// - the notes backend uploads over HTTP (`notes_backend.kind` is `http` + /// or `both`). + /// + /// The HTTP notes backend is written only by an explicit choice to connect + /// (`autter onboard --connect`, or the connected answer in interactive + /// onboarding). Its default is git notes, so a fresh install, and a + /// machine that only ran `autter login`, never upload. `autter onboard + /// --local` writes `prompt_storage = "local"`, which wins over any + /// notes backend (a contradictory config fails closed). + /// + /// Uploads still require valid credentials; this predicate only says the + /// mode permits them. /// /// Anonymous telemetry (`telemetry_oss`, PostHog/Sentry) is a separate, /// separately consented setting and is not governed by this predicate. pub fn platform_sync_enabled(&self) -> bool { - self.prompt_storage != PromptStorageMode::Local.as_str() + sync_mode_permits_upload(&self.prompt_storage, self.notes_backend.kind) } /// Returns the effective prompt storage mode for a given repository. @@ -1071,11 +1082,9 @@ fn build_config() -> Config { .ok() }) .collect(); - let telemetry_oss_disabled = file_cfg - .as_ref() - .and_then(|c| c.telemetry_oss.clone()) - .filter(|s| s == "off") - .is_some(); + let telemetry_oss_disabled = !telemetry_oss_opted_in( + file_cfg.as_ref().and_then(|c| c.telemetry_oss.as_deref()), + ); let telemetry_enterprise_dsn = file_cfg .as_ref() .and_then(|c| c.telemetry_enterprise_dsn.clone()) @@ -1223,14 +1232,7 @@ fn build_config() -> Config { // Resolve notes_backend config: env vars override file config, which overrides defaults. let file_backend = file_cfg.as_ref().and_then(|c| c.notes_backend.clone()); - let kind_from_env = env::var("AUTTER_NOTES_BACKEND_KIND") - .ok() - .and_then(|s| match s.as_str() { - "http" => Some(NotesBackendKind::Http), - "git_notes" | "git-notes" => Some(NotesBackendKind::GitNotes), - "both" => Some(NotesBackendKind::Both), - _ => None, - }); + let kind_from_env = notes_backend_kind_from_env(); let url_from_env = env::var("AUTTER_NOTES_BACKEND_URL").ok(); let notes_backend = NotesBackendConfig { @@ -1525,6 +1527,54 @@ pub fn skills_dir_path() -> Option { autter_dir_path().map(|dir| dir.join("skills")) } +/// Whether the persisted `telemetry_oss` value is an opt-in. Anonymous +/// telemetry is off until the user says yes (`"on"`, written by onboarding or +/// `autter telemetry on`); an unset value means nobody asked yet, so nothing is +/// collected or sent before that choice. Every reader of `telemetry_oss` uses +/// this so the status output and the senders agree. +pub fn telemetry_oss_opted_in(value: Option<&str>) -> bool { + value == Some("on") +} + +/// Whether the user has made a setup choice that permits background +/// collection on this machine (the transcript worker and its sweep of agent +/// session history): onboarding finished in either mode, or connected mode is +/// configured explicitly. Before that, nothing is read from agent history. +/// +/// Test builds treat an explicit `AUTTER_TEST_CONFIG_PATCH` as that choice, +/// since the harness configures each repository directly instead of running +/// onboarding. +pub fn collection_consented_now() -> bool { + #[cfg(any(test, feature = "test-support"))] + if env::var_os("AUTTER_TEST_CONFIG_PATCH").is_some() { + return true; + } + + if platform_sync_enabled_now() { + return true; + } + load_file_config().and_then(|cfg| cfg.onboarding_completed) == Some(true) +} + +/// The connected-mode rule behind [`Config::platform_sync_enabled`] and +/// [`platform_sync_enabled_now`]. Keep both on this one function so the status +/// line and the uploaders can never disagree. +fn sync_mode_permits_upload(prompt_storage: &str, notes_backend: NotesBackendKind) -> bool { + prompt_storage != PromptStorageMode::Local.as_str() && notes_backend.uses_http() +} + +/// Parse `AUTTER_NOTES_BACKEND_KIND`, matching the resolution in `build_config`. +fn notes_backend_kind_from_env() -> Option { + env::var("AUTTER_NOTES_BACKEND_KIND") + .ok() + .and_then(|s| match s.as_str() { + "http" => Some(NotesBackendKind::Http), + "git_notes" | "git-notes" => Some(NotesBackendKind::GitNotes), + "both" => Some(NotesBackendKind::Both), + _ => None, + }) +} + /// [`Config::platform_sync_enabled`] on a fresh config snapshot. /// /// Long-lived processes (the daemon) must use this rather than @@ -1532,22 +1582,45 @@ pub fn skills_dir_path() -> Option { /// immediately, even if the daemon restart that follows onboarding fails. /// /// Deliberately cheaper than `Config::fresh()` (which probes for the git -/// binary): it only reads `prompt_storage`, because it runs on hot paths such -/// as every recorded metric event. The result must stay identical to -/// `Config::fresh().platform_sync_enabled()`. +/// binary): it only reads `prompt_storage` and the notes backend kind, because +/// it runs on hot paths such as every recorded metric event. The result must +/// stay identical to `Config::fresh().platform_sync_enabled()`. pub fn platform_sync_enabled_now() -> bool { + let file_cfg = load_file_config(); + let prompt_storage = file_cfg + .as_ref() + .and_then(|cfg| cfg.prompt_storage.clone()) + .filter(|value| matches!(value.as_str(), "default" | "notes" | "local")) + .unwrap_or_else(|| PromptStorageMode::Default.as_str().to_string()); + let notes_backend = notes_backend_kind_from_env() + .or_else(|| { + file_cfg + .as_ref() + .and_then(|cfg| cfg.notes_backend.as_ref().map(|nb| nb.kind)) + }) + .unwrap_or(NotesBackendKind::GitNotes); + + // Mirror the test config patch, which `Config::fresh()` applies last. #[cfg(any(test, feature = "test-support"))] - if let Ok(patch_json) = env::var("AUTTER_TEST_CONFIG_PATCH") - && let Ok(patch) = serde_json::from_str::(&patch_json) - && let Some(prompt_storage) = patch.prompt_storage - && matches!(prompt_storage.as_str(), "default" | "notes" | "local") - { - return prompt_storage != PromptStorageMode::Local.as_str(); - } + let (prompt_storage, notes_backend) = { + let mut prompt_storage = prompt_storage; + let mut notes_backend = notes_backend; + if let Ok(patch_json) = env::var("AUTTER_TEST_CONFIG_PATCH") + && let Ok(patch) = serde_json::from_str::(&patch_json) + { + if let Some(patched) = patch.prompt_storage + && matches!(patched.as_str(), "default" | "notes" | "local") + { + prompt_storage = patched; + } + if let Some(nb) = patch.notes_backend { + notes_backend = nb.kind; + } + } + (prompt_storage, notes_backend) + }; - load_file_config() - .and_then(|cfg| cfg.prompt_storage) - .is_none_or(|prompt_storage| prompt_storage != PromptStorageMode::Local.as_str()) + sync_mode_permits_upload(&prompt_storage, notes_backend) } /// Public accessor for ID file path (~/.autter/internal/distinct_id) @@ -2394,7 +2467,7 @@ mod tests { } #[test] - fn test_platform_sync_disabled_only_in_local_mode() { + fn test_platform_sync_enabled_only_in_connected_mode() { let local = create_test_config_with_include_prompts(vec![], vec![], "local", None); assert!( !local.platform_sync_enabled(), @@ -2407,12 +2480,28 @@ mod tests { local_http.notes_backend.kind = NotesBackendKind::Http; assert!(!local_http.platform_sync_enabled()); - for connected in ["default", "notes"] { - let cfg = create_test_config_with_include_prompts(vec![], vec![], connected, None); + for prompt_storage in ["default", "notes"] { + // Fresh install / logged in but never connected: the notes backend + // is still the git-notes default, so nothing may upload. This is + // the state where the status line said "Cloud upload: off" while + // the daemon uploaded metrics and transcripts. + let not_connected = + create_test_config_with_include_prompts(vec![], vec![], prompt_storage, None); + assert_eq!(not_connected.notes_backend.kind, NotesBackendKind::GitNotes); assert!( - cfg.platform_sync_enabled(), - "prompt_storage={connected} is connected mode" + !not_connected.platform_sync_enabled(), + "prompt_storage={prompt_storage} without the HTTP notes backend is not connected" ); + + for kind in [NotesBackendKind::Http, NotesBackendKind::Both] { + let mut connected = + create_test_config_with_include_prompts(vec![], vec![], prompt_storage, None); + connected.notes_backend.kind = kind; + assert!( + connected.platform_sync_enabled(), + "prompt_storage={prompt_storage} with notes_backend={kind} is connected mode" + ); + } } } diff --git a/src/daemon.rs b/src/daemon.rs index add5471..5bbea98 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -9042,10 +9042,22 @@ pub(crate) async fn run_daemon(config: DaemonConfig) -> Result DaemonTelemetryInitResult { + init_daemon_telemetry_handle_with(true) +} + +/// Like [`init_daemon_telemetry_handle`], but with `allow_spawn = false` it +/// only connects to a daemon that is already running and returns `Skipped` +/// otherwise. Plain `autter` commands use this before onboarding, so looking +/// at `status` or `doctor` never starts background collection on its own. +pub fn init_daemon_telemetry_handle_with(allow_spawn: bool) -> DaemonTelemetryInitResult { // Don't initialize if we're inside the daemon process itself if crate::daemon::daemon_process_active() { let _ = DAEMON_TELEMETRY_HANDLE.get_or_init(|| Mutex::new(None)); @@ -124,6 +132,8 @@ pub fn init_daemon_telemetry_handle() -> DaemonTelemetryInitResult { // (i.e., wrapper-daemon mode where the test harness manages the daemon). #[cfg(any(test, feature = "test-support"))] { + // Test builds never spawn a daemon, so there is nothing to gate. + let _ = allow_spawn; let socket_path = std::env::var("AUTTER_DAEMON_CONTROL_SOCKET") .ok() .filter(|p| !p.trim().is_empty()) @@ -158,13 +168,22 @@ pub fn init_daemon_telemetry_handle() -> DaemonTelemetryInitResult { #[cfg(not(any(test, feature = "test-support")))] { // Try to ensure daemon is running and connect - let config = match crate::commands::daemon::ensure_daemon_running( - DAEMON_TELEMETRY_CONNECT_TIMEOUT, - ) { - Ok(config) => config, - Err(e) => { - let _ = DAEMON_TELEMETRY_HANDLE.get_or_init(|| Mutex::new(None)); - return DaemonTelemetryInitResult::Failed(e); + let config = if allow_spawn { + match crate::commands::daemon::ensure_daemon_running(DAEMON_TELEMETRY_CONNECT_TIMEOUT) + { + Ok(config) => config, + Err(e) => { + let _ = DAEMON_TELEMETRY_HANDLE.get_or_init(|| Mutex::new(None)); + return DaemonTelemetryInitResult::Failed(e); + } + } + } else { + match crate::daemon::DaemonConfig::from_env_or_default_paths() { + Ok(config) if crate::commands::daemon::daemon_is_up(&config) => config, + _ => { + let _ = DAEMON_TELEMETRY_HANDLE.get_or_init(|| Mutex::new(None)); + return DaemonTelemetryInitResult::Skipped; + } } }; diff --git a/src/diagnostics.rs b/src/diagnostics.rs index 4d0c48a..d563a6a 100644 --- a/src/diagnostics.rs +++ b/src/diagnostics.rs @@ -197,6 +197,17 @@ pub fn ensure_daemon_ready_for_self_checks() -> DiagnosticCheckResult { let mut details = initial.details(&config); details.push(format!("initial state: {}", initial.summary)); + // Before onboarding, a stopped service is expected: starting it is a + // system integration that needs consent, so doctor must not do it. + if initial.state == DaemonHealthState::NotRunning && !crate::config::collection_consented_now() + { + details.push("not started: run `autter onboard` to set up this machine".to_string()); + return DiagnosticCheckResult::skipped( + "not running; starts after setup (`autter onboard`)", + details, + ); + } + let fix = match initial.state { DaemonHealthState::Healthy => None, DaemonHealthState::NotRunning => Some(( diff --git a/tests/daemon_mode.rs b/tests/daemon_mode.rs index 97201c7..4a7077d 100644 --- a/tests/daemon_mode.rs +++ b/tests/daemon_mode.rs @@ -10,8 +10,8 @@ use autter::daemon::{ use autter::git::find_repository_in_path; use repos::test_file::ExpectedLineExt; use repos::test_repo::{ - DaemonTestCompletionLogEntry, DaemonTestScope, GitTestMode, TestRepo, get_binary_path, - real_git_executable, + DaemonTestCompletionLogEntry, DaemonTestScope, GitTestMode, TestRepo, connected_mode, + get_binary_path, real_git_executable, }; use serde_json::Value; use serde_json::json; @@ -777,7 +777,7 @@ fn assert_post_commit_uploads_prompt_cas(mode: GitTestMode) { let mut repo = TestRepo::new_with_mode_and_daemon_scope(mode, DaemonTestScope::Dedicated); repo.patch_autter_config(|patch| { patch.exclude_prompts_in_repositories = Some(vec![]); - patch.prompt_storage = Some("default".to_string()); + connected_mode(patch); patch.telemetry_oss_disabled = Some(true); }); diff --git a/tests/integration/context_command.rs b/tests/integration/context_command.rs index 8dd0d2e..04630d6 100644 --- a/tests/integration/context_command.rs +++ b/tests/integration/context_command.rs @@ -4,7 +4,7 @@ //! past the sharing policy stops at "Sign in", which makes the connected run //! the control. -use crate::repos::test_repo::TestRepo; +use crate::repos::test_repo::{TestRepo, connected_mode}; fn repo_with_remote() -> TestRepo { let repo = TestRepo::new(); @@ -42,7 +42,7 @@ fn context_sends_nothing_in_local_mode() { fn context_sends_nothing_for_an_excluded_repository() { let mut repo = repo_with_remote(); repo.patch_autter_config(|patch| { - patch.prompt_storage = Some("default".to_string()); + connected_mode(patch); patch.exclude_prompts_in_repositories = Some(vec!["https://github.com/acme/*".to_string()]); }); let output = command_error(&repo, &["context", "add retries to the webhook"]); @@ -52,7 +52,7 @@ fn context_sends_nothing_for_an_excluded_repository() { #[test] fn context_in_connected_mode_needs_a_login() { let mut repo = repo_with_remote(); - repo.patch_autter_config(|patch| patch.prompt_storage = Some("default".to_string())); + repo.patch_autter_config(connected_mode); let output = command_error( &repo, &["context", "add retries", "--path", "src/http/retry.rs"], @@ -81,7 +81,7 @@ fn context_rejects_bad_usage_before_anything_else() { #[test] fn context_paths_must_be_inside_the_repository() { let mut repo = repo_with_remote(); - repo.patch_autter_config(|patch| patch.prompt_storage = Some("default".to_string())); + repo.patch_autter_config(connected_mode); let output = command_error(&repo, &["context", "fix it", "--path", "../outside.rs"]); assert!(output.contains("outside this repository"), "{output}"); } diff --git a/tests/integration/local_mode_privacy.rs b/tests/integration/local_mode_privacy.rs index 2ac1463..6e429f5 100644 --- a/tests/integration/local_mode_privacy.rs +++ b/tests/integration/local_mode_privacy.rs @@ -9,7 +9,7 @@ //! which makes the connected run a control for the local-mode assertions. use crate::repos::test_file::ExpectedLineExt; -use crate::repos::test_repo::TestRepo; +use crate::repos::test_repo::{TestRepo, connected_mode}; use autter::config::{NotesBackendConfig, NotesBackendKind}; use autter::daemon::{ControlRequest, TelemetryEnvelope, send_control_request}; use autter::metrics::{CommittedValues, EventAttributes, MetricEvent, PosEncoded}; @@ -31,13 +31,9 @@ fn local_mode_repo() -> TestRepo { fn connected_mode_repo() -> TestRepo { TestRepo::new_dedicated_daemon_with_initial_config(|patch| { - patch.prompt_storage = Some("default".to_string()); + connected_mode(patch); patch.telemetry_oss_disabled = Some(true); patch.feature_flags = Some(serde_json::json!({"transcript_sweep": false})); - patch.notes_backend = Some(NotesBackendConfig { - kind: NotesBackendKind::GitNotes, - backend_url: None, - }); }) } @@ -186,7 +182,7 @@ fn doctor_reports_local_mode_as_not_uploading() { /// is ordinary prompt text, so secret redaction cannot hide a missing repo gate. fn streamed_prompt_is_queued(excluded: bool) -> bool { let repo = TestRepo::new_dedicated_daemon_with_initial_config(|patch| { - patch.prompt_storage = Some("default".to_string()); + connected_mode(patch); patch.telemetry_oss_disabled = Some(true); patch.disable_version_checks = Some(true); patch.disable_auto_updates = Some(true); diff --git a/tests/integration/memory_command.rs b/tests/integration/memory_command.rs index 4fb3f20..992e488 100644 --- a/tests/integration/memory_command.rs +++ b/tests/integration/memory_command.rs @@ -3,7 +3,7 @@ //! tests are not logged in: a command that gets past the sharing policy stops //! at "Sign in", which makes the connected run the control. -use crate::repos::test_repo::TestRepo; +use crate::repos::test_repo::{TestRepo, connected_mode}; fn repo_with_remote() -> TestRepo { let repo = TestRepo::new(); @@ -40,7 +40,7 @@ fn memory_search_sends_nothing_in_local_mode() { fn memory_search_sends_nothing_for_an_excluded_repository() { let mut repo = repo_with_remote(); repo.patch_autter_config(|patch| { - patch.prompt_storage = Some("default".to_string()); + connected_mode(patch); patch.exclude_prompts_in_repositories = Some(vec!["https://github.com/acme/*".to_string()]); }); let output = memory_error(&repo, &["memory", "search", "how do we retry"]); @@ -56,7 +56,7 @@ fn memory_search_sends_nothing_for_an_excluded_repository() { #[test] fn memory_search_in_connected_mode_needs_a_login() { let mut repo = repo_with_remote(); - repo.patch_autter_config(|patch| patch.prompt_storage = Some("default".to_string())); + repo.patch_autter_config(connected_mode); let output = memory_error(&repo, &["memory", "search", "how do we retry"]); assert!(output.contains("autter login"), "{output}"); } @@ -88,7 +88,7 @@ fn memory_for_files_sends_nothing_in_local_mode() { #[test] fn memory_for_without_changes_asks_for_files() { let mut repo = repo_with_remote(); - repo.patch_autter_config(|patch| patch.prompt_storage = Some("default".to_string())); + repo.patch_autter_config(connected_mode); let output = memory_error(&repo, &["memory", "for"]); assert!( output.contains("Nothing changed in this checkout"), diff --git a/tests/integration/repos/test_repo.rs b/tests/integration/repos/test_repo.rs index 527dfee..e22472b 100644 --- a/tests/integration/repos/test_repo.rs +++ b/tests/integration/repos/test_repo.rs @@ -32,6 +32,20 @@ use std::sync::{Arc, Mutex, OnceLock}; use std::thread; use std::time::{Duration, Instant}; +/// Patch a test config into connected mode, the state `autter onboard +/// --connect` writes: prompts are shared (`prompt_storage = "default"`) and +/// the notes backend syncs to the platform. Both are required for any upload +/// (see `Config::platform_sync_enabled`). `Both` keeps writing local git notes +/// so attribution assertions still read them. +#[allow(dead_code)] +pub fn connected_mode(patch: &mut ConfigPatch) { + patch.prompt_storage = Some("default".to_string()); + patch.notes_backend = Some(autter::config::NotesBackendConfig { + kind: autter::config::NotesBackendKind::Both, + backend_url: None, + }); +} + #[cfg(windows)] use windows_sys::Win32::Foundation::{CloseHandle, HANDLE}; #[cfg(windows)] diff --git a/tests/integration/upload_hold.rs b/tests/integration/upload_hold.rs index d15a634..c0650e1 100644 --- a/tests/integration/upload_hold.rs +++ b/tests/integration/upload_hold.rs @@ -10,7 +10,7 @@ //! These run without a background service, so nothing can drain the queues //! behind the test's back; "held" is observed through `autter sync status`. -use crate::repos::test_repo::{DaemonTestScope, GitTestMode, TestRepo}; +use crate::repos::test_repo::{DaemonTestScope, GitTestMode, TestRepo, connected_mode}; use autter::file_changes::FileChangesDatabase; use autter::notes::db::NotesDatabase; use serde_json::Value; @@ -77,7 +77,9 @@ fn sign_in_offline(repo: &TestRepo) { #[test] fn transcript_backlog_from_completed_legacy_migration_requires_consent() { - let repo = repo_without_service(); + let mut repo = repo_without_service(); + // `sync import` only works in connected mode. + repo.patch_autter_config(connected_mode); let internal = repo.test_home_path().join(".autter/internal"); fs::create_dir_all(&internal).unwrap(); // Version 2.2.1 already applied the old hold, but could subsequently queue @@ -129,7 +131,9 @@ fn transcript_backlog_from_completed_legacy_migration_requires_consent() { #[test] fn legacy_backlog_is_held_until_explicit_import() { - let repo = repo_without_service(); + let mut repo = repo_without_service(); + // `sync import` only works in connected mode. + repo.patch_autter_config(connected_mode); let _ = fs::remove_file(hold_marker(&repo)); seed_backlog(&repo); diff --git a/tests/integration/wrapped.rs b/tests/integration/wrapped.rs index 5e71145..db6c4ec 100644 --- a/tests/integration/wrapped.rs +++ b/tests/integration/wrapped.rs @@ -1,7 +1,7 @@ //! `autter wrapped`: a fixture year of commits with explicit checkpoints //! and dates, checked through the JSON contract and the `--no-color` card. -use crate::repos::test_repo::TestRepo; +use crate::repos::test_repo::{TestRepo, connected_mode}; use serde_json::{Value, json}; use std::fs; use std::path::Path; @@ -492,7 +492,7 @@ fn wrapped_share_sends_nothing_in_local_mode() { fn wrapped_share_without_sign_in_sends_nothing() { let mut repo = TestRepo::new(); build_fixture(&mut repo); - repo.patch_autter_config(|patch| patch.prompt_storage = Some("default".to_string())); + repo.patch_autter_config(connected_mode); let mut server = mockito::Server::new(); let post = server .mock("POST", "/api/cli/wrapped-share") @@ -510,7 +510,7 @@ fn wrapped_share_without_sign_in_sends_nothing() { fn wrapped_share_posts_the_summary_and_prints_the_link() { let mut repo = TestRepo::new(); build_fixture(&mut repo); - repo.patch_autter_config(|patch| patch.prompt_storage = Some("default".to_string())); + repo.patch_autter_config(connected_mode); sign_in(&repo); let mut server = mockito::Server::new(); let post = server @@ -570,7 +570,7 @@ fn wrapped_share_posts_the_summary_and_prints_the_link() { fn wrapped_share_reports_server_errors_in_one_line() { let mut repo = TestRepo::new(); build_fixture(&mut repo); - repo.patch_autter_config(|patch| patch.prompt_storage = Some("default".to_string())); + repo.patch_autter_config(connected_mode); sign_in(&repo); let mut server = mockito::Server::new(); let post = server @@ -592,7 +592,7 @@ fn wrapped_unshare_deletes_by_link_or_token() { fs::write(repo.path().join("README.md"), "# Hello\n").unwrap(); repo.stage_all_and_commit("Initial").unwrap(); let mut repo = repo; - repo.patch_autter_config(|patch| patch.prompt_storage = Some("default".to_string())); + repo.patch_autter_config(connected_mode); sign_in(&repo); let mut server = mockito::Server::new(); let ok = server