diff --git a/CHANGELOG.md b/CHANGELOG.md index c054768f2c1..07be6fad1b8 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ menu-bar layout. - DeepSeek: show reported per-model spend in the provider details while preserving the billing currency, reporting period, zero values, and incomplete-total safeguards. ### Fixed +- Antigravity: a set but unusable `ANTIGRAVITY_CLI_PATH` (empty, missing, or not a file) now turns off automatic `agy` discovery instead of falling back to another CLI that could start an interactive login during a background refresh; a running desktop app still answers first, offline history is still shown, and unsetting the variable restores discovery. - Claude: when Hide Personal Info is enabled, keep saved account rows distinguishable with stable localized `Account N` labels and matching redacted tooltips. --- diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 99e470487bd..e8a7d301a8b 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -127,6 +127,23 @@ skills, and other Grok home files stay in place. `XAI_API_KEY` and `GROK_OAUTH_TOKEN` are unset for the isolated sign-in so the browser OAuth flow is used. +## Antigravity CLI path + +When neither the Antigravity desktop app nor a signed-in `agy` session is +running, the Antigravity provider can use the `agy` CLI. Win-CodexBar looks +for it on `PATH`, then at `%LOCALAPPDATA%\agy\bin\agy.exe`, then at +`%USERPROFILE%\.local\bin\agy.exe`. Set `ANTIGRAVITY_CLI_PATH` to the full +path of `agy.exe` when it is installed somewhere else. + +A set `ANTIGRAVITY_CLI_PATH` is authoritative. If it is empty or does not +point to a file, Win-CodexBar skips the CLI source and names the variable in +the provider error instead of discovering another `agy` through `PATH` or the +install directories, so a background refresh never starts a different CLI +that could ask for an interactive sign-in. A running desktop app still +answers first, and offline conversation history is still shown when it +exists. Unset the variable to restore automatic discovery. Other providers +keep their own override behavior. + ## Source mode CLI `--source` values on this port (see `codexbar usage --help`): `auto`, `web`, `cli`, `oauth`. diff --git a/rust/src/providers/antigravity/cli_resolution.rs b/rust/src/providers/antigravity/cli_resolution.rs new file mode 100644 index 00000000000..c1196948917 --- /dev/null +++ b/rust/src/providers/antigravity/cli_resolution.rs @@ -0,0 +1,187 @@ +//! `agy` executable resolution (upstream `BinaryLocator.resolveAntigravityBinary`). +//! +//! A set `ANTIGRAVITY_CLI_PATH` is authoritative: background refreshes must not +//! discover and launch another `agy` (which could start an interactive login) +//! when the explicit override is empty, missing or not a file. Only an unset +//! variable enables discovery through PATH and the known install directories. + +use std::path::PathBuf; + +use crate::core::ProviderError; + +pub(super) fn locate_agy_binary() -> Result, ProviderError> { + resolve_agy_binary( + std::env::var_os("ANTIGRAVITY_CLI_PATH").map(PathBuf::from), + || { + agy_binary_candidates( + which::which("agy").ok(), + std::env::var_os("LOCALAPPDATA").map(PathBuf::from), + dirs::home_dir(), + ) + }, + ) +} + +fn validate_agy_binary_override( + explicit: Option, +) -> Result, ProviderError> { + let Some(path) = explicit else { + return Ok(None); + }; + if path.is_file() { + Ok(Some(path)) + } else { + Err(ProviderError::NotInstalled(format!( + "ANTIGRAVITY_CLI_PATH is set but does not point to a usable agy file: {}. Fix or unset the variable; automatic CLI discovery is disabled while it is set.", + path.display() + ))) + } +} + +fn resolve_agy_binary( + explicit: Option, + discover: F, +) -> Result, ProviderError> +where + F: FnOnce() -> Vec, +{ + if let Some(path) = validate_agy_binary_override(explicit)? { + return Ok(Some(path)); + } + Ok(discover().into_iter().find(|path| path.is_file())) +} + +fn agy_binary_candidates( + path_lookup: Option, + local_app_data: Option, + home: Option, +) -> Vec { + let mut candidates = Vec::new(); + if let Some(path) = path_lookup { + candidates.push(path); + } + if let Some(root) = local_app_data { + candidates.push(root.join("agy").join("bin").join("agy.exe")); + } + if let Some(root) = home { + candidates.push(root.join(".local").join("bin").join(if cfg!(windows) { + "agy.exe" + } else { + "agy" + })); + } + candidates +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn candidates_prefer_path_then_known_installs() { + let path_lookup = PathBuf::from(r"C:\path\agy.exe"); + let local_app_data = PathBuf::from(r"C:\Users\test\AppData\Local"); + let home = PathBuf::from(r"C:\Users\test"); + + let candidates = agy_binary_candidates( + Some(path_lookup.clone()), + Some(local_app_data.clone()), + Some(home.clone()), + ); + + assert_eq!(candidates[0], path_lookup); + assert_eq!( + candidates[1], + local_app_data.join("agy").join("bin").join("agy.exe") + ); + assert_eq!( + candidates[2], + home.join(".local") + .join("bin") + .join(if cfg!(windows) { "agy.exe" } else { "agy" }) + ); + } + + #[test] + fn usable_override_is_selected_without_discovery() { + let temp = tempfile::tempdir().expect("temporary directory"); + let override_path = temp.path().join("configured-agy.exe"); + std::fs::write(&override_path, b"test executable placeholder").expect("write fixture"); + + let resolved = resolve_agy_binary(Some(override_path.clone()), || { + panic!("a configured override must not trigger automatic discovery") + }) + .expect("usable override should resolve"); + + assert_eq!(resolved, Some(override_path)); + } + + /// Upstream `unusable env override fails without ambient fallback` + /// (arguments: a missing path, `""`, `" "` and the bare name `agy`), plus + /// a directory as the Windows form of "not executable". Discovery (PATH and + /// install directories) must never run, so it panics here. + #[test] + fn unusable_override_fails_without_automatic_discovery() { + let temp = tempfile::tempdir().expect("temporary directory"); + let overrides = [ + temp.path().join("missing-agy.exe"), + PathBuf::from(""), + PathBuf::from(" "), + PathBuf::from("agy"), + temp.path().to_path_buf(), + ]; + + for value in overrides { + let error = resolve_agy_binary(Some(value.clone()), || { + panic!("an unusable configured override must block automatic discovery") + }) + .expect_err("unusable override should fail closed"); + + assert!( + matches!(error, ProviderError::NotInstalled(_)), + "{value:?}: {error:?}" + ); + let message = error.to_string(); + assert!(message.contains("ANTIGRAVITY_CLI_PATH is set"), "{value:?}"); + assert!( + message.contains("automatic CLI discovery is disabled"), + "{value:?}" + ); + } + } + + #[test] + fn unset_override_preserves_automatic_discovery() { + let temp = tempfile::tempdir().expect("temporary directory"); + let discovered_path = temp.path().join("discovered-agy.exe"); + std::fs::write(&discovered_path, b"test executable placeholder").expect("write fixture"); + + let resolved = resolve_agy_binary(None, || { + vec![ + temp.path().join("missing-first.exe"), + discovered_path.clone(), + ] + }) + .expect("automatic discovery should resolve"); + + assert_eq!(resolved, Some(discovered_path)); + } + + /// Upstream `minimal fallback preserves a hit or no executable`: the last + /// candidate still resolves (above); with no installed CLI the source is + /// simply missing, which is not an error. + #[test] + fn unset_override_without_an_installed_cli_resolves_to_none() { + let temp = tempfile::tempdir().expect("temporary directory"); + + let resolved = resolve_agy_binary(None, || { + vec![ + temp.path().join("missing-first.exe"), + temp.path().join("missing-last.exe"), + ] + }) + .expect("discovery without a hit is not an error"); + + assert_eq!(resolved, None); + } +} diff --git a/rust/src/providers/antigravity/mod.rs b/rust/src/providers/antigravity/mod.rs index d24eb8c6377..96a399e984d 100755 --- a/rust/src/providers/antigravity/mod.rs +++ b/rust/src/providers/antigravity/mod.rs @@ -4,6 +4,7 @@ //! Uses Windows process detection to find CSRF token mod cli_fallback; +mod cli_resolution; mod cost; mod legacy_status; mod local_history; @@ -27,7 +28,6 @@ use std::ffi::OsString; use std::future::Future; #[cfg(windows)] use std::os::windows::process::CommandExt; -use std::path::PathBuf; use std::process::Command; use std::sync::LazyLock; #[cfg(windows)] @@ -485,7 +485,7 @@ impl AntigravityProvider { } async fn try_print_usage_fallback(&self) -> Result, ProviderError> { - cli_fallback::try_fetch(Self::locate_agy_binary()).await + cli_fallback::try_fetch(cli_resolution::locate_agy_binary()?).await } /// Start a short-lived, headless `agy` session when neither the Antigravity @@ -516,7 +516,7 @@ impl AntigravityProvider { Err(_) => return Err(Self::managed_agy_timeout()), } - let Some(binary) = Self::locate_agy_binary() else { + let Some(binary) = cli_resolution::locate_agy_binary()? else { return Ok(ManagedAgyOutcome::Missing); }; let probe_client = crate::core::credentialed_http_client_builder() @@ -738,26 +738,35 @@ impl AntigravityProvider { #[cfg(windows)] if allow_managed_runtime { - if cli_fallback::managed_spawn_is_csrf_gated(Self::locate_agy_binary()).await { - tracing::debug!( - "skipping managed agy readiness wait because the local server requires CSRF" - ); - } else { - match self.fetch_with_managed_agy().await { - Ok(ManagedAgyOutcome::Reused(result)) => return Ok(result), - Ok(ManagedAgyOutcome::Fetched(mut result)) => { - result.source_label = AntigravityStrategyId::Cli.as_str().to_string(); - return Ok(result); - } - Ok(ManagedAgyOutcome::Missing) => {} - Err(error) => { - if matches!(error, ProviderError::AuthRequired) { - return Err(error); + match cli_resolution::locate_agy_binary() { + Ok(binary) => { + if cli_fallback::managed_spawn_is_csrf_gated(binary).await { + tracing::debug!( + "skipping managed agy readiness wait because the local server requires CSRF" + ); + } else { + match self.fetch_with_managed_agy().await { + Ok(ManagedAgyOutcome::Reused(result)) => return Ok(result), + Ok(ManagedAgyOutcome::Fetched(mut result)) => { + result.source_label = + AntigravityStrategyId::Cli.as_str().to_string(); + return Ok(result); + } + Ok(ManagedAgyOutcome::Missing) => {} + Err(error) => { + if matches!(error, ProviderError::AuthRequired) { + return Err(error); + } + tracing::debug!(%error, "managed Antigravity CLI probe failed"); + failure = Some(error); + } } - tracing::debug!(%error, "managed Antigravity CLI probe failed"); - failure = Some(error); } } + Err(error) => { + tracing::debug!(%error, "managed Antigravity CLI resolution failed"); + failure = Some(error); + } } } @@ -786,42 +795,6 @@ impl AntigravityProvider { } } - fn locate_agy_binary() -> Option { - let candidates = Self::agy_binary_candidates( - std::env::var_os("ANTIGRAVITY_CLI_PATH").map(PathBuf::from), - which::which("agy").ok(), - std::env::var_os("LOCALAPPDATA").map(PathBuf::from), - dirs::home_dir(), - ); - candidates.into_iter().find(|path| path.is_file()) - } - - fn agy_binary_candidates( - explicit: Option, - path_lookup: Option, - local_app_data: Option, - home: Option, - ) -> Vec { - let mut candidates = Vec::new(); - if let Some(path) = explicit { - candidates.push(path); - } - if let Some(path) = path_lookup { - candidates.push(path); - } - if let Some(root) = local_app_data { - candidates.push(root.join("agy").join("bin").join("agy.exe")); - } - if let Some(root) = home { - candidates.push(root.join(".local").join("bin").join(if cfg!(windows) { - "agy.exe" - } else { - "agy" - })); - } - candidates - } - async fn fetch_local_payload( client: &reqwest::Client, process_info: &ProcessInfo, diff --git a/rust/src/providers/antigravity/tests.rs b/rust/src/providers/antigravity/tests.rs index f18a9059c6b..5d36a4b0b3f 100644 --- a/rust/src/providers/antigravity/tests.rs +++ b/rust/src/providers/antigravity/tests.rs @@ -268,36 +268,6 @@ fn missing_cli_error_explains_runtime_state() { assert!(error.contains("agy CLI was not found")); } -#[test] -fn managed_agy_candidates_prefer_override_then_path_then_known_installs() { - let explicit = PathBuf::from(r"D:\tools\agy.exe"); - let path_lookup = PathBuf::from(r"C:\path\agy.exe"); - let local_app_data = PathBuf::from(r"C:\Users\test\AppData\Local"); - let home = PathBuf::from(r"C:\Users\test"); - - let candidates = AntigravityProvider::agy_binary_candidates( - Some(explicit.clone()), - Some(path_lookup.clone()), - Some(local_app_data.clone()), - Some(home.clone()), - ); - - assert_eq!(candidates[0], explicit); - assert_eq!(candidates[1], path_lookup); - // Build expectations with `join` so the assertions match on every host: - // on Unix `\` is an ordinary character and `join` inserts `/`. - assert_eq!( - candidates[2], - local_app_data.join("agy").join("bin").join("agy.exe") - ); - assert_eq!( - candidates[3], - home.join(".local") - .join("bin") - .join(if cfg!(windows) { "agy.exe" } else { "agy" }) - ); -} - // ── Managed lifecycle policy (fake outcomes) ─────────────────────── // // The process lifecycle itself is covered by `crate::managed_process`; these @@ -640,6 +610,32 @@ async fn local_probe_success_does_not_run_structured_cli_fallback() { assert!(!fallback_called.load(Ordering::SeqCst)); } +#[tokio::test] +async fn local_probe_success_wins_over_an_invalid_cli_override() { + let provider = AntigravityProvider::new(); + let fallback_called = Arc::new(AtomicBool::new(false)); + let marker = Arc::clone(&fallback_called); + let local = ProviderFetchResult::new(UsageSnapshot::new(RateWindow::new(10.0)), "local"); + + let result = provider + .resolve_runtime_fallback_with_offline( + Ok(Some(local)), + move || async move { + marker.store(true, Ordering::SeqCst); + Err(ProviderError::NotInstalled( + "ANTIGRAVITY_CLI_PATH is set but unusable".to_string(), + )) + }, + Some(offline_result()), + ) + .await + .expect("successful local desktop probe must remain authoritative"); + + assert_eq!(result.source_label, "local"); + assert_eq!(result.usage.primary.used_percent, 10.0); + assert!(!fallback_called.load(Ordering::SeqCst)); +} + #[tokio::test] async fn local_auth_probe_failure_uses_structured_cli_fallback() { let result = AntigravityProvider::new() @@ -712,6 +708,47 @@ async fn cli_fallback_error_prefers_offline_history() { assert_eq!(result.usage.login_method.as_deref(), Some("offline")); } +#[tokio::test] +async fn unusable_cli_override_prefers_offline_history() { + let result = AntigravityProvider::new() + .resolve_runtime_fallback_with_offline( + Err(ProviderError::AuthRequired), + || async { + Err(ProviderError::NotInstalled( + "ANTIGRAVITY_CLI_PATH is set but does not point to a usable agy file".into(), + )) + }, + Some(offline_result()), + ) + .await + .expect("offline history should survive an unusable CLI override"); + + assert_eq!(result.source_label, "offline"); + assert_eq!(result.usage.login_method.as_deref(), Some("offline")); +} + +#[tokio::test] +async fn unusable_cli_override_without_history_reports_the_override() { + let result = AntigravityProvider::new() + .resolve_runtime_fallback_with_offline( + Err(ProviderError::Other("local API unavailable".to_string())), + || async { + Err(ProviderError::NotInstalled( + "ANTIGRAVITY_CLI_PATH is set but does not point to a usable agy file".into(), + )) + }, + None, + ) + .await; + + match result { + Err(ProviderError::NotInstalled(message)) => { + assert!(message.contains("ANTIGRAVITY_CLI_PATH"), "{message}"); + } + other => panic!("expected the actionable override error, got {other:?}"), + } +} + #[test] fn user_tier_resolves_google_ai_ultra_plan_name() { let json = serde_json::json!({