From da600238587da667540a6762268f984bb49225a9 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:17:53 +0700 Subject: [PATCH 1/3] Port upstream 0.65.0: explain Antigravity offline fallback with a fixed-text detail --- rust/src/providers/antigravity/mod.rs | 17 ++- .../providers/antigravity/offline_reason.rs | 29 +++++ rust/src/providers/antigravity/tests.rs | 100 ++++++++++++++++++ 3 files changed, 141 insertions(+), 5 deletions(-) create mode 100644 rust/src/providers/antigravity/offline_reason.rs diff --git a/rust/src/providers/antigravity/mod.rs b/rust/src/providers/antigravity/mod.rs index d24eb8c637..0d79dd4000 100755 --- a/rust/src/providers/antigravity/mod.rs +++ b/rust/src/providers/antigravity/mod.rs @@ -12,6 +12,7 @@ pub mod local_sessions; mod local_sessions_reader; mod local_sqlite; mod local_step_resolver; +mod offline_reason; mod quota_summary; use legacy_status::{UserStatus, UserStatusResponse}; @@ -651,7 +652,8 @@ impl AntigravityProvider { /// A failed sign-in is actionable, so it always surfaces. Every other /// failure means the runtime/CLI is unavailable or inconclusive, so an /// available offline conversation-history snapshot is preferred over - /// discarding it for a transient error. + /// discarding it for a transient error. The offline snapshot carries a + /// fixed-text reason derived from the error type only. fn resolve_probe_failure( error: ProviderError, offline: Option, @@ -659,7 +661,11 @@ impl AntigravityProvider { if matches!(error, ProviderError::AuthRequired) { return Err(error); } - offline.ok_or(error) + offline + .map(|result| { + result.with_display_detail(offline_reason::live_unavailable_detail(&error)) + }) + .ok_or(error) } /// Map a managed-lifecycle outcome onto provider policy. @@ -780,9 +786,10 @@ impl AntigravityProvider { match failure { Some(error) => Self::resolve_probe_failure(error, offline), - None => { - offline.ok_or_else(|| ProviderError::NotInstalled(AGY_NOT_FOUND_MESSAGE.into())) - } + None => Self::resolve_probe_failure( + ProviderError::NotInstalled(AGY_NOT_FOUND_MESSAGE.into()), + offline, + ), } } diff --git a/rust/src/providers/antigravity/offline_reason.rs b/rust/src/providers/antigravity/offline_reason.rs new file mode 100644 index 0000000000..e09f551678 --- /dev/null +++ b/rust/src/providers/antigravity/offline_reason.rs @@ -0,0 +1,29 @@ +//! Fixed-text explanation for why live Antigravity usage fell back to offline +//! conversation history. +//! +//! The reason is derived only from the typed [`ProviderError`] variant. The +//! error's message is never read: it can embed local paths, URLs, response +//! bodies or account details, so it must not reach a display surface. + +use crate::core::{ProviderDisplayDetail, ProviderError}; + +use super::AGY_NOT_FOUND_MESSAGE; + +const DETAIL_ID: &str = "antigravity-live-unavailable"; +const DETAIL_TITLE: &str = "Live usage"; +const DETAIL_PREFIX: &str = "Live Antigravity usage is unavailable; showing offline data."; +const GENERIC_HINT: &str = "check Diagnostics for per-source details"; + +/// Detail row explaining the failure that led to the offline snapshot. +/// +/// `AuthRequired` is terminal and never reaches the offline snapshot, so it +/// yields no row. +pub(super) fn live_unavailable_detail(error: &ProviderError) -> Option { + let reason = match error { + ProviderError::AuthRequired => return None, + ProviderError::NotInstalled(_) => AGY_NOT_FOUND_MESSAGE, + ProviderError::Timeout => "the quota request timed out", + _ => GENERIC_HINT, + }; + ProviderDisplayDetail::new(DETAIL_ID, DETAIL_TITLE, format!("{DETAIL_PREFIX} {reason}")) +} diff --git a/rust/src/providers/antigravity/tests.rs b/rust/src/providers/antigravity/tests.rs index f18a9059c6..f45d6f704c 100644 --- a/rust/src/providers/antigravity/tests.rs +++ b/rust/src/providers/antigravity/tests.rs @@ -712,6 +712,106 @@ async fn cli_fallback_error_prefers_offline_history() { assert_eq!(result.usage.login_method.as_deref(), Some("offline")); } +const OFFLINE_DETAIL_PREFIX: &str = "Live Antigravity usage is unavailable; showing offline data."; +const HINT: &str = "check Diagnostics for per-source details"; + +fn offline_detail_value(result: &ProviderFetchResult) -> String { + let details = result.display_details(); + assert_eq!(details.len(), 1, "exactly one explanation row"); + assert_eq!(details[0].id(), "antigravity-live-unavailable"); + assert_eq!(details[0].title(), "Live usage"); + details[0].value().to_string() +} + +#[test] +fn offline_fallback_explains_each_typed_failure() { + let cases = [ + ( + ProviderError::NotInstalled("agy missing".to_string()), + AGY_NOT_FOUND_MESSAGE, + ), + (ProviderError::Timeout, "the quota request timed out"), + (ProviderError::Parse("bad json".to_string()), HINT), + (ProviderError::Other("boom".to_string()), HINT), + (ProviderError::NoCookies, HINT), + ]; + for (error, reason) in cases { + let resolved = AntigravityProvider::resolve_probe_failure(error, Some(offline_result())) + .expect("offline history is preserved"); + assert_eq!(resolved.source_label, "offline"); + assert_eq!( + offline_detail_value(&resolved), + format!("{OFFLINE_DETAIL_PREFIX} {reason}") + ); + } +} + +#[test] +fn offline_explanation_never_echoes_error_text() { + let secrets = [ + "user@example.com", + "https://lh3.googleusercontent.com/a/photo", + r"C:\Users\someone\agy.exe", + "HTTP 500", + ]; + let leaky = secrets.join(" "); + let errors = [ + ProviderError::NotInstalled(leaky.clone()), + ProviderError::Parse(leaky.clone()), + ProviderError::Other(leaky.clone()), + ProviderError::OAuth(leaky), + ]; + for error in errors { + let resolved = AntigravityProvider::resolve_probe_failure(error, Some(offline_result())) + .expect("offline history is preserved"); + let value = offline_detail_value(&resolved); + for secret in secrets { + assert!(!value.contains(secret), "{secret} leaked into {value}"); + } + } +} + +#[test] +fn auth_required_adds_no_offline_explanation() { + assert!(offline_reason::live_unavailable_detail(&ProviderError::AuthRequired).is_none()); + let resolved = AntigravityProvider::resolve_probe_failure( + ProviderError::AuthRequired, + Some(offline_result()), + ); + assert!(matches!(resolved, Err(ProviderError::AuthRequired))); +} + +#[tokio::test] +async fn successful_live_fallback_carries_no_offline_explanation() { + let live = ProviderFetchResult::new(UsageSnapshot::new(RateWindow::new(10.0)), "cli"); + let result = AntigravityProvider::new() + .resolve_runtime_fallback_with_offline( + Err(ProviderError::Timeout), + || async { Ok(Some(live)) }, + Some(offline_result()), + ) + .await + .expect("live CLI fallback wins over offline history"); + assert_eq!(result.source_label, "cli"); + assert!(result.display_details().is_empty()); +} + +#[tokio::test] +async fn failed_cli_fallback_offline_result_explains_failure() { + let result = AntigravityProvider::new() + .resolve_runtime_fallback_with_offline( + Err(ProviderError::AuthRequired), + || async { Err(ProviderError::Timeout) }, + Some(offline_result()), + ) + .await + .expect("offline history should survive a failed CLI probe"); + assert_eq!( + offline_detail_value(&result), + format!("{OFFLINE_DETAIL_PREFIX} the quota request timed out") + ); +} + #[test] fn user_tier_resolves_google_ai_ultra_plan_name() { let json = serde_json::json!({ From 093031ce9884219e99aa43c962f48bf2a0b06b5a Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:19:15 +0700 Subject: [PATCH 2/3] Address thermo review --- rust/src/providers/antigravity/mod.rs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/rust/src/providers/antigravity/mod.rs b/rust/src/providers/antigravity/mod.rs index 0d79dd4000..498a846b15 100755 --- a/rust/src/providers/antigravity/mod.rs +++ b/rust/src/providers/antigravity/mod.rs @@ -784,13 +784,9 @@ impl AntigravityProvider { } } - match failure { - Some(error) => Self::resolve_probe_failure(error, offline), - None => Self::resolve_probe_failure( - ProviderError::NotInstalled(AGY_NOT_FOUND_MESSAGE.into()), - offline, - ), - } + let error = + failure.unwrap_or_else(|| ProviderError::NotInstalled(AGY_NOT_FOUND_MESSAGE.into())); + Self::resolve_probe_failure(error, offline) } fn locate_agy_binary() -> Option { From 3444791481b673cf8f5196a32193488902c2d49b Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Thu, 1 Oct 2026 05:36:22 +0700 Subject: [PATCH 3/3] Classify Antigravity live failures for the offline explanation Port the rest of upstream CodexBar v0.65.0 #3865: failed agy print-usage runs are classified into fixed text (signed out, eligibility, network, exit code, missing or unlaunchable executable) from captured stderr that is never logged or displayed, and local language-server failures keep their HTTP status, session-expired, timeout or connection category for the offline "Live usage" row. Oversized output on either stream is rejected before the exit status, like upstream SubprocessRunner. --- .../src/providers/antigravity/cli_fallback.rs | 306 +++++++++++++--- .../antigravity/cli_print_failure.rs | 338 ++++++++++++++++++ rust/src/providers/antigravity/mod.rs | 70 ++-- .../providers/antigravity/offline_reason.rs | 163 ++++++++- .../antigravity/offline_reason_tests.rs | 295 +++++++++++++++ rust/src/providers/antigravity/tests.rs | 120 +------ 6 files changed, 1079 insertions(+), 213 deletions(-) create mode 100644 rust/src/providers/antigravity/cli_print_failure.rs create mode 100644 rust/src/providers/antigravity/offline_reason_tests.rs diff --git a/rust/src/providers/antigravity/cli_fallback.rs b/rust/src/providers/antigravity/cli_fallback.rs index ed3572358d..b7f4efa1d5 100644 --- a/rust/src/providers/antigravity/cli_fallback.rs +++ b/rust/src/providers/antigravity/cli_fallback.rs @@ -9,12 +9,16 @@ use tokio::process::Command as AsyncCommand; use uuid::Uuid; use super::AntigravityProvider; +use super::cli_print_failure::{CliPrintFailure, ExitClassification, classify_exit}; +use super::offline_reason::LiveFailure; use super::quota_summary; use crate::core::{ProviderError, ProviderFetchResult}; const REPORT_TIMEOUT: Duration = Duration::from_secs(90); +const REPORT_TOO_LARGE: &str = "Antigravity CLI usage report is too large"; const VERSION_ARGS: [&str; 1] = ["--version"]; const VERSION_TIMEOUT: Duration = Duration::from_secs(3); +const VERSION_TOO_LARGE: &str = "Antigravity CLI version output is too large"; const USAGE_ARGS: [&str; 6] = [ "-p", "/usage", @@ -80,15 +84,15 @@ impl Drop for PrivateWorkdir { } #[derive(Debug, PartialEq, Eq)] -struct BoundedStdout { +struct BoundedOutput { bytes: Vec, exceeded_limit: bool, } -/// Read a child stdout stream incrementally, retaining only the configured +/// Read a child output stream incrementally, retaining only the configured /// prefix while continuing to drain the pipe so the child cannot block on a -/// full stdout buffer. -async fn read_stdout_limited(mut reader: R, max_bytes: usize) -> std::io::Result +/// full pipe buffer. +async fn read_output_limited(mut reader: R, max_bytes: usize) -> std::io::Result where R: AsyncRead + Unpin, { @@ -110,7 +114,7 @@ where } } - Ok(BoundedStdout { + Ok(BoundedOutput { bytes, exceeded_limit, }) @@ -118,7 +122,7 @@ where pub(super) async fn try_fetch( binary: Option, -) -> Result, ProviderError> { +) -> Result, LiveFailure> { let Some(binary) = binary else { return Ok(None); }; @@ -138,37 +142,28 @@ pub(super) async fn managed_spawn_is_csrf_gated(binary: Option) -> bool let Some(binary) = binary else { return false; }; - let Ok(version) = run_cli_command(&binary, &VERSION_ARGS, VERSION_TIMEOUT).await else { + let Ok(version) = + run_cli_command(&binary, &VERSION_ARGS, VERSION_TIMEOUT, VERSION_TOO_LARGE).await + else { return false; }; - if version.exceeded_limit { - return false; - } - let version = String::from_utf8_lossy(&version.bytes); + let version = String::from_utf8_lossy(&version); is_csrf_gated_version(version.trim()) } -async fn fetch_print_usage(binary: &Path) -> Result { - let version = run_cli_command(binary, &VERSION_ARGS, VERSION_TIMEOUT).await?; - if version.exceeded_limit { - return Err(ProviderError::Parse( - "Antigravity CLI version output is too large".into(), - )); - } - let version = String::from_utf8_lossy(&version.bytes); +async fn fetch_print_usage(binary: &Path) -> Result { + let version = + run_cli_command(binary, &VERSION_ARGS, VERSION_TIMEOUT, VERSION_TOO_LARGE).await?; + let version = String::from_utf8_lossy(&version); if !is_supported_version(version.trim()) { return Err(ProviderError::Parse( "Antigravity CLI usage reports require agy 1.1.11 or later".into(), - )); + ) + .into()); } - let output = run_cli_command(binary, &USAGE_ARGS, REPORT_TIMEOUT).await?; - if output.exceeded_limit { - return Err(ProviderError::Parse( - "Antigravity CLI usage report is too large".into(), - )); - } - let usage = quota_summary::parse_cli_usage_report(&output.bytes)?; + let output = run_cli_command(binary, &USAGE_ARGS, REPORT_TIMEOUT, REPORT_TOO_LARGE).await?; + let usage = quota_summary::parse_cli_usage_report(&output)?; Ok(AntigravityProvider::fetch_result( usage, super::AntigravityStrategyId::Cli, @@ -183,7 +178,8 @@ fn prepare_command(binary: &Path, args: &[&str], working_dir: &Path) -> AsyncCom .env_remove(OAUTH_CREDENTIALS_ENV) .stdin(Stdio::null()) .stdout(Stdio::piped()) - .stderr(Stdio::null()) + // Captured only to classify a failed run; never logged or displayed. + .stderr(Stdio::piped()) .kill_on_drop(true); #[cfg(windows)] command.as_std_mut().creation_flags(0x0800_0000); @@ -194,36 +190,58 @@ async fn run_cli_command( binary: &Path, args: &[&str], timeout: Duration, -) -> Result { + too_large: &'static str, +) -> Result, LiveFailure> { let working_dir = PrivateWorkdir::create()?; let mut command = prepare_command(binary, args, working_dir.path()); let mut child = command .spawn() - .map_err(|_| ProviderError::Other("Failed to start Antigravity CLI".into()))?; - let stdout = child - .stdout - .take() - .ok_or_else(|| ProviderError::Other("Antigravity CLI usage report failed".into()))?; - - tokio::time::timeout(timeout, async { - let (stdout_result, status_result) = tokio::join!( - read_stdout_limited(stdout, REPORT_MAX_OUTPUT_BYTES), + .map_err(|error| LiveFailure::cli_report(CliPrintFailure::from_spawn_error(&error)))?; + let (Some(stdout), Some(stderr)) = (child.stdout.take(), child.stderr.take()) else { + return Err(report_failed()); + }; + + let (stdout, stderr, status) = tokio::time::timeout(timeout, async { + tokio::join!( + read_output_limited(stdout, REPORT_MAX_OUTPUT_BYTES), + read_output_limited(stderr, REPORT_MAX_OUTPUT_BYTES), child.wait() - ); - let stdout = stdout_result - .map_err(|_| ProviderError::Other("Antigravity CLI usage report failed".into()))?; - let status = status_result - .map_err(|_| ProviderError::Other("Antigravity CLI usage report failed".into()))?; - if !status.success() { - return Err(ProviderError::Other( - "Antigravity CLI usage report failed".into(), - )); - } - Ok(stdout) + ) }) .await - .map_err(|_| ProviderError::Timeout)? + .map_err(|_| LiveFailure::from(ProviderError::Timeout))?; + let (Ok(stdout), Ok(stderr), Ok(status)) = (stdout, stderr, status) else { + return Err(report_failed()); + }; + finish_run(stdout, stderr, status.code(), too_large) +} + +fn report_failed() -> LiveFailure { + ProviderError::Other("Antigravity CLI usage report failed".into()).into() +} + +/// Map a finished `agy` run onto the probe policy. Like upstream +/// `SubprocessRunner`, oversized output on either stream is rejected before +/// the exit status is considered. stderr only feeds the fixed classification. +fn finish_run( + stdout: BoundedOutput, + stderr: BoundedOutput, + exit_code: Option, + too_large: &'static str, +) -> Result, LiveFailure> { + if stdout.exceeded_limit || stderr.exceeded_limit { + return Err(ProviderError::Parse(too_large.into()).into()); + } + if exit_code == Some(0) { + return Ok(stdout.bytes); + } + match classify_exit(exit_code.unwrap_or(-1), &stderr.bytes) { + // A signed-out CLI stays a terminal, actionable sign-in error on + // Windows instead of being hidden behind offline history. + ExitClassification::SignedOut => Err(ProviderError::AuthRequired.into()), + ExitClassification::Failed(failure) => Err(LiveFailure::cli_report(failure)), + } } fn is_supported_version(version: &str) -> bool { @@ -252,6 +270,8 @@ fn parse_version(version: &str) -> Option<(u64, u64, u64)> { #[cfg(test)] mod tests { + use super::super::cli_print_failure::ExitReason; + use super::super::offline_reason::LiveFailureReason; use super::*; #[test] @@ -277,21 +297,197 @@ mod tests { } #[tokio::test] - async fn stdout_capture_retains_only_the_configured_limit() { - let output = read_stdout_limited(b"0123456789".as_slice(), 4) + async fn output_capture_retains_only_the_configured_limit() { + let output = read_output_limited(b"0123456789".as_slice(), 4) .await - .expect("stdout reader should succeed"); + .expect("output reader should succeed"); assert_eq!(output.bytes, b"0123"); assert!(output.exceeded_limit); - let output = read_stdout_limited(b"0123".as_slice(), 4) + let output = read_output_limited(b"0123".as_slice(), 4) .await - .expect("stdout reader should succeed"); + .expect("output reader should succeed"); assert_eq!(output.bytes, b"0123"); assert!(!output.exceeded_limit); } + fn output(bytes: &[u8], exceeded_limit: bool) -> BoundedOutput { + BoundedOutput { + bytes: bytes.to_vec(), + exceeded_limit, + } + } + + #[test] + fn oversized_output_on_either_stream_fails_before_the_exit_status() { + let cases = [ + (output(b"{}", true), output(b"", false), Some(0)), + (output(b"{}", false), output(b"noise", true), Some(0)), + (output(b"", true), output(b"not logged in", false), Some(1)), + ]; + for (stdout, stderr, exit_code) in cases { + let failure = finish_run(stdout, stderr, exit_code, REPORT_TOO_LARGE) + .expect_err("oversized output must be rejected"); + assert_eq!(failure.reason(), LiveFailureReason::Unclassified); + assert!(matches!( + failure.into_error(), + ProviderError::Parse(message) if message == REPORT_TOO_LARGE + )); + } + } + + #[test] + fn successful_run_returns_stdout_and_ignores_stderr() { + let stdout = finish_run( + output(b"{\"ok\":true}", false), + output(b"warning: not logged in to telemetry", false), + Some(0), + REPORT_TOO_LARGE, + ) + .expect("exit 0 succeeds"); + assert_eq!(stdout, b"{\"ok\":true}"); + } + + #[test] + fn failed_run_is_classified_without_echoing_stderr() { + let failure = finish_run( + output(b"", false), + output(b"synthetic-private-diagnostic", false), + Some(7), + REPORT_TOO_LARGE, + ) + .expect_err("exit 7 fails"); + assert_eq!( + failure.reason(), + LiveFailureReason::CliReport(CliPrintFailure::Exited { + code: 7, + reason: ExitReason::Unspecified, + }) + ); + let message = failure.into_error().to_string(); + assert_eq!(message, "Antigravity CLI usage report failed: agy exited 7"); + assert!(!message.contains("synthetic-private-diagnostic")); + + let failure = finish_run( + output(b"", false), + output(b"", false), + None, + REPORT_TOO_LARGE, + ) + .expect_err("a run without an exit code fails"); + assert_eq!( + failure.reason(), + LiveFailureReason::CliReport(CliPrintFailure::Exited { + code: -1, + reason: ExitReason::Unspecified, + }) + ); + } + + #[test] + fn signed_out_run_requires_authentication() { + let failure = finish_run( + output(b"", false), + output(b"Select login method:", false), + Some(1), + REPORT_TOO_LARGE, + ) + .expect_err("a login prompt fails"); + assert!(failure.is_auth_required()); + assert!(failure.offline_detail().is_none()); + } + + #[tokio::test] + async fn missing_agy_executable_is_classified() { + let dir = tempfile::tempdir().expect("fixture directory"); + let failure = fetch_print_usage(&dir.path().join("agy.exe")) + .await + .expect_err("a missing executable cannot report usage"); + assert_eq!( + failure.reason(), + LiveFailureReason::CliReport(CliPrintFailure::ExecutableNotFound) + ); + assert_eq!( + failure.into_error().to_string(), + "Antigravity CLI usage report failed: agy executable not found" + ); + } + + /// A stand-in `agy` that reports a supported version and then fails the + /// usage report with fixed stderr and exit code. + #[cfg(windows)] + fn failing_agy(stderr: &str, exit_code: i32) -> tempfile::TempDir { + let dir = tempfile::tempdir().expect("fixture directory"); + std::fs::write(dir.path().join("stderr.txt"), stderr).expect("stderr fixture"); + std::fs::write( + dir.path().join("agy.cmd"), + format!( + "@echo off\r\nif \"%~1\"==\"--version\" (\r\n echo 1.2.2\r\n exit /b 0\r\n)\r\n>&2 type \"%~dp0stderr.txt\"\r\nexit /b {exit_code}\r\n" + ), + ) + .expect("agy fixture"); + dir + } + + #[cfg(windows)] + #[tokio::test] + async fn failed_print_usage_process_is_classified_without_leaking_stderr() { + let cases = [ + ("synthetic-private-diagnostic", 7, ExitReason::Unspecified), + ( + r#"Eligibility check failed: failed to get profile picture: Get "https://lh3.googleusercontent.com/a/private": EOF"#, + 1, + ExitReason::EligibilityNetwork, + ), + ( + "Eligibility check failed: account does not support Google ToS", + 1, + ExitReason::Ineligible, + ), + ( + r#"Post "https://usage.invalid/v1": dial tcp: no such host"#, + 1, + ExitReason::Network, + ), + ]; + for (stderr, code, reason) in cases { + let fixture = failing_agy(stderr, code); + let failure = fetch_print_usage(&fixture.path().join("agy.cmd")) + .await + .expect_err("a failing agy cannot report usage"); + assert_eq!( + failure.reason(), + LiveFailureReason::CliReport(CliPrintFailure::Exited { code, reason }), + "{stderr}" + ); + let detail = failure + .offline_detail() + .expect("offline explanation") + .value() + .to_string(); + let message = failure.into_error().to_string(); + for secret in [ + "synthetic-private-diagnostic", + "googleusercontent", + "usage.invalid", + ] { + assert!(!message.contains(secret), "{secret} leaked into {message}"); + assert!(!detail.contains(secret), "{secret} leaked into {detail}"); + } + } + } + + #[cfg(windows)] + #[tokio::test] + async fn signed_out_print_usage_process_requires_authentication() { + let fixture = failing_agy("You are not logged into Antigravity", 1); + let failure = fetch_print_usage(&fixture.path().join("agy.cmd")) + .await + .expect_err("a signed-out agy cannot report usage"); + assert!(failure.is_auth_required()); + } + #[test] fn subprocess_uses_private_workdir_and_scrubs_oauth_credentials() { let workdir = PrivateWorkdir::create().expect("private working directory"); diff --git a/rust/src/providers/antigravity/cli_print_failure.rs b/rust/src/providers/antigravity/cli_print_failure.rs new file mode 100644 index 0000000000..5c99b10b2c --- /dev/null +++ b/rust/src/providers/antigravity/cli_print_failure.rs @@ -0,0 +1,338 @@ +//! Sanitized classification of a failed `agy` print-usage run. +//! +//! Port of upstream `AntigravityCLIPrintFailure` (CodexBar v0.65.0, #3865). +//! Raw stderr must never reach the user or the logs: it can embed +//! account-identifying URLs (Google profile pictures), proxy details or local +//! paths. stderr is only matched against the fixed markers below and every +//! branch produces fixed text, so the classification stays reviewable. + +use std::sync::LazyLock; + +use regex_lite::Regex; + +/// A failed `agy` print-usage invocation, without raw subprocess output. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum CliPrintFailure { + ExecutableNotFound, + LaunchFailed, + Exited { code: i32, reason: ExitReason }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ExitReason { + Unspecified, + Network, + EligibilityNetwork, + Ineligible, +} + +/// How a non-zero `agy` exit is surfaced. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ExitClassification { + /// `agy` showed a login prompt or reported a signed-out session. + SignedOut, + Failed(CliPrintFailure), +} + +impl CliPrintFailure { + /// Classify a failure to start the `agy` process. + pub(super) fn from_spawn_error(error: &std::io::Error) -> Self { + if error.kind() == std::io::ErrorKind::NotFound { + Self::ExecutableNotFound + } else { + Self::LaunchFailed + } + } + + fn message(self) -> String { + match self { + Self::ExecutableNotFound => "agy executable not found".to_string(), + Self::LaunchFailed => "agy failed to launch".to_string(), + Self::Exited { code, reason } => { + let detail = match reason { + ExitReason::Unspecified => "", + ExitReason::Network => { + "; a network request failed (check network or proxy settings)" + } + ExitReason::EligibilityNetwork => { + "; the eligibility check failed on a network request (check network or proxy settings)" + } + ExitReason::Ineligible => "; the account is not eligible for Antigravity", + }; + format!("agy exited {code}{detail}") + } + } + } + + /// Fixed text, identical to upstream's `cliReportFailed` description. + pub(super) fn description(self) -> String { + format!("Antigravity CLI usage report failed: {}", self.message()) + } +} + +/// Upstream `AntigravityCLIAuthenticationPrompt`: only a blocking login +/// prompt, an explicit Antigravity logout or exhausted keyring authentication +/// proves that `agy` cannot recover its credentials. Matched case-insensitively +/// on the ASCII projection of the output. The first pattern also covers +/// upstream's literal `Select login method:` evidence. +static AUTHENTICATION_PROMPTS: LazyLock<[Regex; 3]> = LazyLock::new(|| { + [ + r"(?i)select\s+login\s+method\s*:?", + r"(?i)you\s+are\s+not\s+logged\s+into\s+antigravity", + r"(?i)keyring\s*auth\s*:\s*timed\s+out\b", + ] + .map(|pattern| Regex::new(pattern).expect("valid agy authentication prompt pattern")) +}); + +const SIGN_IN_MARKERS: [&str; 7] = [ + "not logged in", + "not signed in", + "unauthenticated", + "authentication required", + "login required", + "please log in", + "please sign in", +]; + +const ELIGIBILITY_MARKERS: [&str; 5] = [ + "eligibility check failed", + "not eligible", + "does not support google tos", + "unsupported country", + "unsupported region", +]; + +/// Transport-shaped fragments Go CLIs print when a request dies on the wire. +/// Word boundaries keep `eof` and friends from matching inside other words. +static NETWORK_MARKERS: LazyLock> = LazyLock::new(|| { + [ + r"\beof\b", + r"\btimed out\b", + r"\btimeout\b", + r"\bdeadline exceeded\b", + r"\bi/o timeout\b", + r"\bno such host\b", + r"\bdns\b", + r"\bconnection refused\b", + r"\bconnection reset\b", + r"\bnetwork is unreachable\b", + r"\bunreachable\b", + r"\bproxy\b", + r"\bcertificate\b", + r"\btls\b", + r"\bssl\b", + r#"get "http"#, + r#"post "http"#, + ] + .into_iter() + .map(|pattern| Regex::new(pattern).expect("valid agy network marker pattern")) + .collect() +}); + +fn contains_authentication_prompt(output: &[u8]) -> bool { + let ascii: String = output + .iter() + .map(|&byte| { + if byte.is_ascii() { + char::from(byte) + } else { + ' ' + } + }) + .collect(); + AUTHENTICATION_PROMPTS + .iter() + .any(|pattern| pattern.is_match(&ascii)) +} + +/// Classify a non-zero `agy` exit from its captured stderr. Nothing from +/// `stderr` is retained in the result. +pub(super) fn classify_exit(code: i32, stderr: &[u8]) -> ExitClassification { + let text = String::from_utf8_lossy(stderr).to_lowercase(); + if contains_authentication_prompt(stderr) + || SIGN_IN_MARKERS.iter().any(|marker| text.contains(marker)) + { + return ExitClassification::SignedOut; + } + let eligibility_failed = ELIGIBILITY_MARKERS + .iter() + .any(|marker| text.contains(marker)); + let network_failed = NETWORK_MARKERS + .iter() + .any(|pattern| pattern.is_match(&text)); + let reason = match (eligibility_failed, network_failed) { + (true, true) => ExitReason::EligibilityNetwork, + (true, false) => ExitReason::Ineligible, + (false, true) => ExitReason::Network, + (false, false) => ExitReason::Unspecified, + }; + ExitClassification::Failed(CliPrintFailure::Exited { code, reason }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn exited(code: i32, reason: ExitReason) -> ExitClassification { + ExitClassification::Failed(CliPrintFailure::Exited { code, reason }) + } + + #[test] + fn descriptions_match_upstream_fixed_text() { + let cases = [ + ( + CliPrintFailure::ExecutableNotFound, + "Antigravity CLI usage report failed: agy executable not found", + ), + ( + CliPrintFailure::LaunchFailed, + "Antigravity CLI usage report failed: agy failed to launch", + ), + ( + CliPrintFailure::Exited { + code: 7, + reason: ExitReason::Unspecified, + }, + "Antigravity CLI usage report failed: agy exited 7", + ), + ( + CliPrintFailure::Exited { + code: 1, + reason: ExitReason::Network, + }, + "Antigravity CLI usage report failed: agy exited 1; a network request failed (check network or proxy settings)", + ), + ( + CliPrintFailure::Exited { + code: 1, + reason: ExitReason::EligibilityNetwork, + }, + "Antigravity CLI usage report failed: agy exited 1; the eligibility check failed on a network request (check network or proxy settings)", + ), + ( + CliPrintFailure::Exited { + code: 1, + reason: ExitReason::Ineligible, + }, + "Antigravity CLI usage report failed: agy exited 1; the account is not eligible for Antigravity", + ), + ]; + for (failure, expected) in cases { + assert_eq!(failure.description(), expected); + } + } + + #[test] + fn stderr_maps_to_upstream_categories() { + let cases = [ + ( + r#"Eligibility check failed: failed to get profile picture: Get "https://lh3.googleusercontent.com/a/private": EOF"#, + exited(1, ExitReason::EligibilityNetwork), + ), + ( + "Eligibility check failed: account does not support Google ToS", + exited(1, ExitReason::Ineligible), + ), + ( + "You are not logged into Antigravity", + ExitClassification::SignedOut, + ), + ( + r#"Post "https://usage.invalid/v1": dial tcp: no such host"#, + exited(1, ExitReason::Network), + ), + ]; + for (stderr, expected) in cases { + assert_eq!(classify_exit(1, stderr.as_bytes()), expected, "{stderr}"); + } + } + + #[test] + fn blank_and_unrecognized_stderr_stay_unspecified() { + assert_eq!(classify_exit(2, b" "), exited(2, ExitReason::Unspecified)); + assert_eq!( + classify_exit(7, b"synthetic-private-diagnostic"), + exited(7, ExitReason::Unspecified) + ); + } + + #[test] + fn login_prompts_and_sign_in_markers_mean_signed_out() { + for stderr in [ + "Select login method:", + "select LOGIN\nmethod", + "keyring auth: timed out", + "Error: Not signed in", + "please log in to continue", + "UNAUTHENTICATED: request had invalid credentials", + ] { + assert_eq!( + classify_exit(1, stderr.as_bytes()), + ExitClassification::SignedOut, + "{stderr}" + ); + } + } + + #[test] + fn authentication_prompt_ignores_non_ascii_bytes() { + // Box-drawing and other non-ASCII bytes around the prompt become + // spaces, so the prompt still matches. + let stderr = "\u{2502}Select\u{00a0}login method\u{2502}".as_bytes(); + assert_eq!(classify_exit(1, stderr), ExitClassification::SignedOut); + assert_eq!( + classify_exit(1, b"\xff\xfeYou are not logged into Antigravity"), + ExitClassification::SignedOut + ); + } + + #[test] + fn network_markers_respect_word_boundaries() { + assert_eq!( + classify_exit(3, b"geoffrey sslx proxyless tlsv"), + exited(3, ExitReason::Unspecified) + ); + assert_eq!( + classify_exit(3, b"read: connection reset by peer"), + exited(3, ExitReason::Network) + ); + assert_eq!( + classify_exit(3, b"x509: CERTIFICATE signed by unknown authority"), + exited(3, ExitReason::Network) + ); + } + + #[test] + fn spawn_errors_distinguish_a_missing_executable() { + let missing = std::io::Error::from(std::io::ErrorKind::NotFound); + let denied = std::io::Error::from(std::io::ErrorKind::PermissionDenied); + assert_eq!( + CliPrintFailure::from_spawn_error(&missing), + CliPrintFailure::ExecutableNotFound + ); + assert_eq!( + CliPrintFailure::from_spawn_error(&denied), + CliPrintFailure::LaunchFailed + ); + } + + #[test] + fn classification_never_retains_stderr_text() { + let stderr = r#"Eligibility check failed: Get "https://lh3.googleusercontent.com/a/private": EOF C:\Users\someone\agy.exe user@example.com"#; + let ExitClassification::Failed(failure) = classify_exit(1, stderr.as_bytes()) else { + panic!("expected a classified failure"); + }; + let description = failure.description(); + for secret in [ + "googleusercontent", + "private", + r"C:\Users", + "user@example.com", + ] { + assert!( + !description.contains(secret), + "{secret} leaked into {description}" + ); + } + } +} diff --git a/rust/src/providers/antigravity/mod.rs b/rust/src/providers/antigravity/mod.rs index 498a846b15..71c5c4c48a 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_print_failure; mod cost; mod legacy_status; mod local_history; @@ -16,6 +17,7 @@ mod offline_reason; mod quota_summary; use legacy_status::{UserStatus, UserStatusResponse}; +use offline_reason::LiveFailure; #[cfg(windows)] use crate::managed_process::{ManagedProcess, ManagedProcessConfig, ManagedProcessError}; @@ -374,7 +376,7 @@ impl AntigravityProvider { usage } - async fn fetch_user_status(&self) -> Result, ProviderError> { + async fn fetch_user_status(&self) -> Result, LiveFailure> { let process_info = tokio::task::spawn_blocking(Self::detect_process_info) .await .map_err(|error| { @@ -395,7 +397,7 @@ impl AntigravityProvider { &self, process_info: &ProcessInfo, api_port: u16, - ) -> Result { + ) -> Result { // SECURITY: TLS verification disabled only for this loopback language server. let client = crate::core::credentialed_http_client_builder() .no_proxy() @@ -474,8 +476,8 @@ impl AntigravityProvider { .await?; let response: UserStatusResponse = serde_json::from_slice(&bytes) .map_err(|e| ProviderError::Parse(format!("Failed to parse response: {e}")))?; - self.parse_user_status(response) - .map(|usage| Self::fetch_result(usage, AntigravityStrategyId::Local)) + let usage = self.parse_user_status(response)?; + Ok(Self::fetch_result(usage, AntigravityStrategyId::Local)) } pub(super) fn fetch_result( @@ -485,7 +487,7 @@ impl AntigravityProvider { ProviderFetchResult::new(Self::with_cadence_labels(usage), strategy.as_str()) } - async fn try_print_usage_fallback(&self) -> Result, ProviderError> { + async fn try_print_usage_fallback(&self) -> Result, LiveFailure> { cli_fallback::try_fetch(Self::locate_agy_binary()).await } @@ -513,7 +515,7 @@ impl AntigravityProvider { match tokio::time::timeout(recheck_budget, self.fetch_user_status()).await { Ok(Ok(Some(usage))) => return Ok(ManagedAgyOutcome::Reused(usage)), Ok(Ok(None)) => {} - Ok(Err(error)) => return Err(error), + Ok(Err(error)) => return Err(error.into_error()), Err(_) => return Err(Self::managed_agy_timeout()), } @@ -574,10 +576,10 @@ impl AntigravityProvider { .await { Ok(Ok(usage)) => return Ok(ManagedAgyOutcome::Fetched(usage)), - Ok(Err(ProviderError::AuthRequired)) => { + Ok(Err(error)) if error.is_auth_required() => { return Err(ProviderError::AuthRequired); } - Ok(Err(error)) => last_error = Some(error), + Ok(Err(error)) => last_error = Some(error.into_error()), Err(_) => break, } } @@ -652,20 +654,19 @@ impl AntigravityProvider { /// A failed sign-in is actionable, so it always surfaces. Every other /// failure means the runtime/CLI is unavailable or inconclusive, so an /// available offline conversation-history snapshot is preferred over - /// discarding it for a transient error. The offline snapshot carries a - /// fixed-text reason derived from the error type only. + /// discarding it for a transient error. The offline snapshot carries the + /// failure's fixed-text reason, never the error message. fn resolve_probe_failure( - error: ProviderError, + failure: impl Into, offline: Option, ) -> Result { - if matches!(error, ProviderError::AuthRequired) { - return Err(error); + let failure = failure.into(); + match offline { + Some(result) if !failure.is_auth_required() => { + Ok(result.with_display_detail(failure.offline_detail())) + } + _ => Err(failure.into_error()), } - offline - .map(|result| { - result.with_display_detail(offline_reason::live_unavailable_detail(&error)) - }) - .ok_or(error) } /// Map a managed-lifecycle outcome onto provider policy. @@ -706,12 +707,12 @@ impl AntigravityProvider { /// terminal and never starts another CLI process. async fn resolve_runtime_fallback( &self, - local_result: Result, ProviderError>, + local_result: Result, LiveFailure>, cli_fallback: F, ) -> Result where F: FnOnce() -> Fut, - Fut: Future, ProviderError>>, + Fut: Future, LiveFailure>>, { self.resolve_runtime_fallback_with_offline( local_result, @@ -723,19 +724,19 @@ impl AntigravityProvider { async fn resolve_runtime_fallback_with_offline( &self, - local_result: Result, ProviderError>, + local_result: Result, LiveFailure>, cli_fallback: F, offline: Option, ) -> Result where F: FnOnce() -> Fut, - Fut: Future, ProviderError>>, + Fut: Future, LiveFailure>>, { let (mut failure, allow_managed_runtime) = match local_result { Ok(Some(result)) => return Ok(result), Ok(None) => (None, true), Err(error) => { - if !matches!(error, ProviderError::AuthRequired) { + if !error.is_auth_required() { tracing::debug!(%error, "Antigravity local probe failed"); } (Some(error), false) @@ -761,7 +762,7 @@ impl AntigravityProvider { return Err(error); } tracing::debug!(%error, "managed Antigravity CLI probe failed"); - failure = Some(error); + failure = Some(error.into()); } } } @@ -774,7 +775,7 @@ impl AntigravityProvider { Ok(Some(result)) => return Ok(result), Ok(None) => {} Err(error) => { - if !matches!(error, ProviderError::AuthRequired) { + if !error.is_auth_required() { tracing::debug!(%error, "structured Antigravity CLI fallback failed"); } // A failed CLI attempt is an inconclusive runtime probe. Let @@ -784,9 +785,7 @@ impl AntigravityProvider { } } - let error = - failure.unwrap_or_else(|| ProviderError::NotInstalled(AGY_NOT_FOUND_MESSAGE.into())); - Self::resolve_probe_failure(error, offline) + Self::resolve_probe_failure(failure.unwrap_or_else(LiveFailure::not_running), offline) } fn locate_agy_binary() -> Option { @@ -832,7 +831,7 @@ impl AntigravityProvider { path: &str, body: &serde_json::Value, timeout: std::time::Duration, - ) -> Result, ProviderError> { + ) -> Result, LiveFailure> { let url = format!("https://127.0.0.1:{api_port}{path}"); let requires_csrf = process_info.source == ProcessSource::Ide; let csrf_token = process_info @@ -851,13 +850,13 @@ impl AntigravityProvider { let response = request .send() .await - .map_err(|e| ProviderError::Other(format!("API request failed: {e}")))?; + .map_err(|e| LiveFailure::request("API request failed", &e))?; if response.status().is_success() { return response .bytes() .await .map(|bytes| bytes.to_vec()) - .map_err(|e| ProviderError::Other(format!("Failed to read response: {e}"))); + .map_err(|e| LiveFailure::request("Failed to read response", &e)); } let status = response.status(); @@ -879,7 +878,7 @@ impl AntigravityProvider { .bytes() .await .map(|bytes| bytes.to_vec()) - .map_err(|e| ProviderError::Other(format!("Failed to read response: {e}"))); + .map_err(|e| LiveFailure::request("Failed to read response", &e)); } } @@ -890,9 +889,12 @@ impl AntigravityProvider { || text.to_ascii_lowercase().contains("login method") || text.to_ascii_lowercase().contains("keyring")) { - return Err(ProviderError::AuthRequired); + return Err(ProviderError::AuthRequired.into()); } - Err(ProviderError::Other(format!("API error {status}: {text}"))) + Err(LiveFailure::http_status( + status.as_u16(), + ProviderError::Other(format!("API error {status}: {text}")), + )) } fn resolve_plan_name(status: &UserStatus) -> Option { diff --git a/rust/src/providers/antigravity/offline_reason.rs b/rust/src/providers/antigravity/offline_reason.rs index e09f551678..458ed66e89 100644 --- a/rust/src/providers/antigravity/offline_reason.rs +++ b/rust/src/providers/antigravity/offline_reason.rs @@ -1,29 +1,162 @@ //! Fixed-text explanation for why live Antigravity usage fell back to offline //! conversation history. //! -//! The reason is derived only from the typed [`ProviderError`] variant. The -//! error's message is never read: it can embed local paths, URLs, response -//! bodies or account details, so it must not reach a display surface. +//! Port of upstream `AntigravityOfflineFetchStrategy.diagnostic(forPriorFailure:)` +//! (CodexBar v0.65.0, #3865). A failed live probe carries a typed +//! [`LiveFailureReason`] next to the [`ProviderError`] it surfaces when no +//! offline history exists. The reason is chosen where the failure happens and +//! the error's message is never read for it: messages can embed local paths, +//! URLs, response bodies or account details, so they must not reach a display +//! surface. + +use std::fmt; use crate::core::{ProviderDisplayDetail, ProviderError}; use super::AGY_NOT_FOUND_MESSAGE; +use super::cli_print_failure::CliPrintFailure; const DETAIL_ID: &str = "antigravity-live-unavailable"; const DETAIL_TITLE: &str = "Live usage"; const DETAIL_PREFIX: &str = "Live Antigravity usage is unavailable; showing offline data."; const GENERIC_HINT: &str = "check Diagnostics for per-source details"; -/// Detail row explaining the failure that led to the offline snapshot. -/// -/// `AuthRequired` is terminal and never reaches the offline snapshot, so it -/// yields no row. -pub(super) fn live_unavailable_detail(error: &ProviderError) -> Option { - let reason = match error { - ProviderError::AuthRequired => return None, - ProviderError::NotInstalled(_) => AGY_NOT_FOUND_MESSAGE, - ProviderError::Timeout => "the quota request timed out", - _ => GENERIC_HINT, - }; - ProviderDisplayDetail::new(DETAIL_ID, DETAIL_TITLE, format!("{DETAIL_PREFIX} {reason}")) +/// Sanitized category of a failed live probe. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum LiveFailureReason { + /// Neither a desktop language server nor an `agy` CLI was found. + NotRunning, + TimedOut, + /// The local language server rejected the request (HTTP 401/403). + SessionExpired, + HttpStatus(u16), + /// The local language server refused or dropped the connection. + CannotConnect, + CliReport(CliPrintFailure), + /// Parse, port-detection and other transport failures, plus any error + /// that was not classified where it happened. + Unclassified, +} + +impl LiveFailureReason { + fn text(self) -> String { + match self { + Self::NotRunning => AGY_NOT_FOUND_MESSAGE.to_string(), + Self::TimedOut => "Antigravity quota request timed out.".to_string(), + Self::SessionExpired => { + "Antigravity session expired. Restart Antigravity and retry.".to_string() + } + Self::HttpStatus(status) => format!("the usage request failed (HTTP {status})"), + Self::CannotConnect => "Could not connect to the server.".to_string(), + Self::CliReport(failure) => failure.description(), + Self::Unclassified => GENERIC_HINT.to_string(), + } + } +} + +/// A failed live Antigravity probe: the error surfaced when no offline history +/// exists, plus the fixed-text reason shown next to offline data. +#[derive(Debug)] +pub(super) struct LiveFailure { + error: ProviderError, + reason: LiveFailureReason, +} + +impl LiveFailure { + fn new(error: ProviderError, reason: LiveFailureReason) -> Self { + Self { error, reason } + } + + /// No live runtime answered and no `agy` executable was found. + pub(super) fn not_running() -> Self { + Self::new( + ProviderError::NotInstalled(AGY_NOT_FOUND_MESSAGE.into()), + LiveFailureReason::NotRunning, + ) + } + + /// The local language server answered with a non-success HTTP status. + pub(super) fn http_status(status: u16, error: ProviderError) -> Self { + let reason = match status { + 401 | 403 => LiveFailureReason::SessionExpired, + _ => LiveFailureReason::HttpStatus(status), + }; + Self::new(error, reason) + } + + /// A local language-server request failed before an HTTP status arrived. + /// + /// Like upstream's `URLError(code)` reduction, only the transport category + /// survives: the error text can carry the request URL. A timeout reuses + /// the fixed quota-timeout text; a refused connection uses the text + /// upstream shows for `URLError.cannotConnectToHost`. + pub(super) fn request(context: &str, error: &reqwest::Error) -> Self { + let reason = if error.is_timeout() { + LiveFailureReason::TimedOut + } else if error.is_connect() { + LiveFailureReason::CannotConnect + } else { + LiveFailureReason::Unclassified + }; + Self::new(ProviderError::Other(format!("{context}: {error}")), reason) + } + + /// A classified `agy` print-usage failure. The error text is the same + /// fixed description, so raw subprocess output never reaches it. + pub(super) fn cli_report(failure: CliPrintFailure) -> Self { + Self::new( + ProviderError::Other(failure.description()), + LiveFailureReason::CliReport(failure), + ) + } + + pub(super) fn is_auth_required(&self) -> bool { + matches!(self.error, ProviderError::AuthRequired) + } + + pub(super) fn into_error(self) -> ProviderError { + self.error + } + + /// Detail row explaining the failure that led to the offline snapshot. + /// + /// `AuthRequired` is terminal and never reaches the offline snapshot, so it + /// yields no row. + pub(super) fn offline_detail(&self) -> Option { + if self.is_auth_required() { + return None; + } + ProviderDisplayDetail::new( + DETAIL_ID, + DETAIL_TITLE, + format!("{DETAIL_PREFIX} {}", self.reason.text()), + ) + } + + #[cfg(test)] + pub(super) fn reason(&self) -> LiveFailureReason { + self.reason + } } + +impl From for LiveFailure { + /// Only the typed timeout is recognizable after the fact; everything else + /// reduces to the fixed Diagnostics hint. + fn from(error: ProviderError) -> Self { + let reason = match error { + ProviderError::Timeout => LiveFailureReason::TimedOut, + _ => LiveFailureReason::Unclassified, + }; + Self::new(error, reason) + } +} + +impl fmt::Display for LiveFailure { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(&self.error, formatter) + } +} + +#[cfg(test)] +#[path = "offline_reason_tests.rs"] +mod tests; diff --git a/rust/src/providers/antigravity/offline_reason_tests.rs b/rust/src/providers/antigravity/offline_reason_tests.rs new file mode 100644 index 0000000000..00e12c9196 --- /dev/null +++ b/rust/src/providers/antigravity/offline_reason_tests.rs @@ -0,0 +1,295 @@ +//! Offline-explanation tests for failed live Antigravity probes (upstream +//! CodexBar v0.65.0, #3865). + +use super::super::AntigravityProvider; +use super::super::cli_print_failure::ExitReason; +use super::super::tests::offline_result; +use super::*; +use crate::core::{ProviderFetchResult, RateWindow, UsageSnapshot}; + +const OFFLINE_DETAIL_PREFIX: &str = "Live Antigravity usage is unavailable; showing offline data."; +const HINT: &str = "check Diagnostics for per-source details"; + +fn offline_detail_value(result: &ProviderFetchResult) -> String { + let details = result.display_details(); + assert_eq!(details.len(), 1, "exactly one explanation row"); + assert_eq!(details[0].id(), "antigravity-live-unavailable"); + assert_eq!(details[0].title(), "Live usage"); + details[0].value().to_string() +} + +#[test] +fn offline_fallback_explains_each_typed_failure() { + let other = |message: &str| ProviderError::Other(message.to_string()); + let cases = [ + (LiveFailure::not_running(), AGY_NOT_FOUND_MESSAGE), + // Only the not-running marker proves the runtime is absent; other + // NotInstalled texts (agy exited early, process detection failed) must + // not claim that agy was not found. + ( + ProviderError::NotInstalled("Failed to detect Antigravity process".to_string()).into(), + HINT, + ), + ( + ProviderError::Timeout.into(), + "Antigravity quota request timed out.", + ), + ( + LiveFailure::http_status(500, other("API error 500 Internal Server Error: busy")), + "the usage request failed (HTTP 500)", + ), + ( + LiveFailure::http_status(401, other("API error 401 Unauthorized")), + "Antigravity session expired. Restart Antigravity and retry.", + ), + ( + LiveFailure::http_status(403, other("API error 403 Forbidden")), + "Antigravity session expired. Restart Antigravity and retry.", + ), + ( + LiveFailure::cli_report(CliPrintFailure::ExecutableNotFound), + "Antigravity CLI usage report failed: agy executable not found", + ), + ( + LiveFailure::cli_report(CliPrintFailure::Exited { + code: 1, + reason: ExitReason::Unspecified, + }), + "Antigravity CLI usage report failed: agy exited 1", + ), + (ProviderError::Parse("bad json".to_string()).into(), HINT), + (other("boom").into(), HINT), + (ProviderError::NoCookies.into(), HINT), + ]; + for (failure, reason) in cases { + let resolved = AntigravityProvider::resolve_probe_failure(failure, Some(offline_result())) + .expect("offline history is preserved"); + assert_eq!(resolved.source_label, "offline"); + assert_eq!( + offline_detail_value(&resolved), + format!("{OFFLINE_DETAIL_PREFIX} {reason}") + ); + } +} + +#[test] +fn offline_explanation_keeps_only_the_http_status() { + let body = AntigravityProvider::resolve_probe_failure( + LiveFailure::http_status( + 500, + ProviderError::Other( + r#"API error 500 Internal Server Error: {"secret":"token-value"}"#.to_string(), + ), + ), + Some(offline_result()), + ) + .expect("offline history is preserved"); + let body = offline_detail_value(&body); + assert!(body.contains("HTTP 500"), "{body}"); + assert!(!body.contains("token-value"), "{body}"); + + let expired = AntigravityProvider::resolve_probe_failure( + LiveFailure::http_status( + 403, + ProviderError::Other(r#"API error 403 Forbidden: {"detail":"private"}"#.to_string()), + ), + Some(offline_result()), + ) + .expect("offline history is preserved"); + let expired = offline_detail_value(&expired); + assert!(expired.contains("session expired"), "{expired}"); + assert!(!expired.contains("private"), "{expired}"); +} + +#[test] +fn http_failure_without_history_surfaces_the_original_error() { + let resolved = AntigravityProvider::resolve_probe_failure( + LiveFailure::http_status( + 500, + ProviderError::Other("API error 500 Internal Server Error: busy".to_string()), + ), + None, + ); + assert!(matches!( + resolved, + Err(ProviderError::Other(message)) if message == "API error 500 Internal Server Error: busy" + )); +} + +#[tokio::test] +async fn offline_fallback_explains_the_masked_cli_failure() { + let result = AntigravityProvider::new() + .resolve_runtime_fallback_with_offline( + Err(LiveFailure::not_running()), + || async { + Err(LiveFailure::cli_report(CliPrintFailure::Exited { + code: 1, + reason: ExitReason::EligibilityNetwork, + })) + }, + Some(offline_result()), + ) + .await + .expect("offline history should survive a failed CLI probe"); + assert_eq!(result.source_label, "offline"); + assert_eq!( + offline_detail_value(&result), + "Live Antigravity usage is unavailable; showing offline data. Antigravity CLI usage report failed: agy exited 1; the eligibility check failed on a network request (check network or proxy settings)" + ); +} + +#[tokio::test] +async fn unavailable_cli_keeps_the_local_failure_reason() { + let result = AntigravityProvider::new() + .resolve_runtime_fallback_with_offline( + Err(LiveFailure::http_status( + 500, + ProviderError::Other("API error 500 Internal Server Error: busy".to_string()), + )), + || async { Ok(None) }, + Some(offline_result()), + ) + .await + .expect("offline history is preserved"); + assert_eq!( + offline_detail_value(&result), + format!("{OFFLINE_DETAIL_PREFIX} the usage request failed (HTTP 500)") + ); +} + +#[tokio::test] +async fn request_timeouts_are_classified_without_the_request_url() { + // Accepted by the OS backlog but never answered, so the client times out. + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("loopback listener"); + let url = format!( + "http://127.0.0.1:{}/private-path", + listener.local_addr().expect("listener address").port() + ); + let client = reqwest::Client::builder() + .no_proxy() + .timeout(std::time::Duration::from_millis(200)) + .build() + .expect("test client"); + let error = client + .post(&url) + .send() + .await + .expect_err("an unanswered request must time out"); + + let failure = LiveFailure::request("API request failed", &error); + assert_eq!(failure.reason(), LiveFailureReason::TimedOut); + let detail = failure + .offline_detail() + .expect("offline explanation") + .value() + .to_string(); + assert_eq!( + detail, + format!("{OFFLINE_DETAIL_PREFIX} Antigravity quota request timed out.") + ); + assert!(!detail.contains("private-path"), "{detail}"); + drop(listener); +} + +#[tokio::test] +async fn refused_requests_are_classified_without_the_request_url() { + // Bind and release a loopback port so nothing listens on it. + let port = std::net::TcpListener::bind("127.0.0.1:0") + .and_then(|listener| listener.local_addr()) + .expect("loopback port") + .port(); + let client = reqwest::Client::builder() + .no_proxy() + .timeout(std::time::Duration::from_secs(10)) + .build() + .expect("test client"); + let error = client + .post(format!("http://127.0.0.1:{port}/private-path")) + .send() + .await + .expect_err("nothing listens on the released port"); + + let failure = LiveFailure::request("API request failed", &error); + assert_eq!(failure.reason(), LiveFailureReason::CannotConnect); + let detail = failure + .offline_detail() + .expect("offline explanation") + .value() + .to_string(); + assert_eq!( + detail, + format!("{OFFLINE_DETAIL_PREFIX} Could not connect to the server.") + ); + assert!(!detail.contains("private-path"), "{detail}"); + assert!(!detail.contains(&port.to_string()), "{detail}"); +} + +#[test] +fn offline_explanation_never_echoes_error_text() { + let secrets = [ + "user@example.com", + "https://lh3.googleusercontent.com/a/photo", + r"C:\Users\someone\agy.exe", + "HTTP 500", + ]; + let leaky = secrets.join(" "); + let errors = [ + ProviderError::NotInstalled(leaky.clone()), + ProviderError::Parse(leaky.clone()), + ProviderError::Other(leaky.clone()), + ProviderError::OAuth(leaky), + ]; + for error in errors { + let resolved = AntigravityProvider::resolve_probe_failure(error, Some(offline_result())) + .expect("offline history is preserved"); + let value = offline_detail_value(&resolved); + for secret in secrets { + assert!(!value.contains(secret), "{secret} leaked into {value}"); + } + } +} + +#[test] +fn auth_required_adds_no_offline_explanation() { + assert!( + LiveFailure::from(ProviderError::AuthRequired) + .offline_detail() + .is_none() + ); + let resolved = AntigravityProvider::resolve_probe_failure( + ProviderError::AuthRequired, + Some(offline_result()), + ); + assert!(matches!(resolved, Err(ProviderError::AuthRequired))); +} + +#[tokio::test] +async fn successful_live_fallback_carries_no_offline_explanation() { + let live = ProviderFetchResult::new(UsageSnapshot::new(RateWindow::new(10.0)), "cli"); + let result = AntigravityProvider::new() + .resolve_runtime_fallback_with_offline( + Err(ProviderError::Timeout.into()), + || async { Ok(Some(live)) }, + Some(offline_result()), + ) + .await + .expect("live CLI fallback wins over offline history"); + assert_eq!(result.source_label, "cli"); + assert!(result.display_details().is_empty()); +} + +#[tokio::test] +async fn failed_cli_fallback_offline_result_explains_failure() { + let result = AntigravityProvider::new() + .resolve_runtime_fallback_with_offline( + Err(ProviderError::AuthRequired.into()), + || async { Err(LiveFailure::from(ProviderError::Timeout)) }, + Some(offline_result()), + ) + .await + .expect("offline history should survive a failed CLI probe"); + assert_eq!( + offline_detail_value(&result), + format!("{OFFLINE_DETAIL_PREFIX} Antigravity quota request timed out.") + ); +} diff --git a/rust/src/providers/antigravity/tests.rs b/rust/src/providers/antigravity/tests.rs index f45d6f704c..07a9ab1233 100644 --- a/rust/src/providers/antigravity/tests.rs +++ b/rust/src/providers/antigravity/tests.rs @@ -576,7 +576,7 @@ fn strategy_ids_are_stable_and_reject_unknown_sources() { assert_eq!(AntigravityStrategyId::Cli.as_str(), "cli"); } -fn offline_result() -> ProviderFetchResult { +pub(super) fn offline_result() -> ProviderFetchResult { ProviderFetchResult::new( UsageSnapshot::new(RateWindow::informational("Offline ยท 2 conversations")) .with_login_method("offline"), @@ -643,7 +643,7 @@ async fn local_probe_success_does_not_run_structured_cli_fallback() { #[tokio::test] async fn local_auth_probe_failure_uses_structured_cli_fallback() { let result = AntigravityProvider::new() - .resolve_runtime_fallback(Err(ProviderError::AuthRequired), || async { + .resolve_runtime_fallback(Err(ProviderError::AuthRequired.into()), || async { Ok(Some(structured_cli_result())) }) .await @@ -657,7 +657,7 @@ async fn local_auth_probe_failure_uses_structured_cli_fallback() { async fn generic_local_probe_failure_uses_valid_structured_cli_json() { let result = AntigravityProvider::new() .resolve_runtime_fallback( - Err(ProviderError::Other("local API unavailable".to_string())), + Err(ProviderError::Other("local API unavailable".to_string()).into()), || async { Ok(Some(structured_cli_result())) }, ) .await @@ -670,7 +670,9 @@ async fn generic_local_probe_failure_uses_valid_structured_cli_json() { #[tokio::test] async fn unauthenticated_local_and_unavailable_cli_paths_remain_auth_required() { let result = AntigravityProvider::new() - .resolve_runtime_fallback(Err(ProviderError::AuthRequired), || async { Ok(None) }) + .resolve_runtime_fallback(Err(ProviderError::AuthRequired.into()), || async { + Ok(None) + }) .await; assert!(matches!(result, Err(ProviderError::AuthRequired))); @@ -680,11 +682,11 @@ async fn unauthenticated_local_and_unavailable_cli_paths_remain_auth_required() async fn malformed_structured_cli_json_from_fallback_is_a_parse_error() { let result = AntigravityProvider::new() .resolve_runtime_fallback_with_offline( - Err(ProviderError::AuthRequired), + Err(ProviderError::AuthRequired.into()), || async { let error = quota_summary::parse_cli_usage_report(br#"{"status":"SUCCESS""#) .expect_err("malformed JSON must fail parsing"); - Err(error) + Err(LiveFailure::from(error)) }, None, ) @@ -697,11 +699,11 @@ async fn malformed_structured_cli_json_from_fallback_is_a_parse_error() { async fn cli_fallback_error_prefers_offline_history() { let result = AntigravityProvider::new() .resolve_runtime_fallback_with_offline( - Err(ProviderError::AuthRequired), + Err(ProviderError::AuthRequired.into()), || async { - Err(ProviderError::Parse( + Err(LiveFailure::from(ProviderError::Parse( "Antigravity CLI usage report: malformed JSON".to_string(), - )) + ))) }, Some(offline_result()), ) @@ -712,106 +714,6 @@ async fn cli_fallback_error_prefers_offline_history() { assert_eq!(result.usage.login_method.as_deref(), Some("offline")); } -const OFFLINE_DETAIL_PREFIX: &str = "Live Antigravity usage is unavailable; showing offline data."; -const HINT: &str = "check Diagnostics for per-source details"; - -fn offline_detail_value(result: &ProviderFetchResult) -> String { - let details = result.display_details(); - assert_eq!(details.len(), 1, "exactly one explanation row"); - assert_eq!(details[0].id(), "antigravity-live-unavailable"); - assert_eq!(details[0].title(), "Live usage"); - details[0].value().to_string() -} - -#[test] -fn offline_fallback_explains_each_typed_failure() { - let cases = [ - ( - ProviderError::NotInstalled("agy missing".to_string()), - AGY_NOT_FOUND_MESSAGE, - ), - (ProviderError::Timeout, "the quota request timed out"), - (ProviderError::Parse("bad json".to_string()), HINT), - (ProviderError::Other("boom".to_string()), HINT), - (ProviderError::NoCookies, HINT), - ]; - for (error, reason) in cases { - let resolved = AntigravityProvider::resolve_probe_failure(error, Some(offline_result())) - .expect("offline history is preserved"); - assert_eq!(resolved.source_label, "offline"); - assert_eq!( - offline_detail_value(&resolved), - format!("{OFFLINE_DETAIL_PREFIX} {reason}") - ); - } -} - -#[test] -fn offline_explanation_never_echoes_error_text() { - let secrets = [ - "user@example.com", - "https://lh3.googleusercontent.com/a/photo", - r"C:\Users\someone\agy.exe", - "HTTP 500", - ]; - let leaky = secrets.join(" "); - let errors = [ - ProviderError::NotInstalled(leaky.clone()), - ProviderError::Parse(leaky.clone()), - ProviderError::Other(leaky.clone()), - ProviderError::OAuth(leaky), - ]; - for error in errors { - let resolved = AntigravityProvider::resolve_probe_failure(error, Some(offline_result())) - .expect("offline history is preserved"); - let value = offline_detail_value(&resolved); - for secret in secrets { - assert!(!value.contains(secret), "{secret} leaked into {value}"); - } - } -} - -#[test] -fn auth_required_adds_no_offline_explanation() { - assert!(offline_reason::live_unavailable_detail(&ProviderError::AuthRequired).is_none()); - let resolved = AntigravityProvider::resolve_probe_failure( - ProviderError::AuthRequired, - Some(offline_result()), - ); - assert!(matches!(resolved, Err(ProviderError::AuthRequired))); -} - -#[tokio::test] -async fn successful_live_fallback_carries_no_offline_explanation() { - let live = ProviderFetchResult::new(UsageSnapshot::new(RateWindow::new(10.0)), "cli"); - let result = AntigravityProvider::new() - .resolve_runtime_fallback_with_offline( - Err(ProviderError::Timeout), - || async { Ok(Some(live)) }, - Some(offline_result()), - ) - .await - .expect("live CLI fallback wins over offline history"); - assert_eq!(result.source_label, "cli"); - assert!(result.display_details().is_empty()); -} - -#[tokio::test] -async fn failed_cli_fallback_offline_result_explains_failure() { - let result = AntigravityProvider::new() - .resolve_runtime_fallback_with_offline( - Err(ProviderError::AuthRequired), - || async { Err(ProviderError::Timeout) }, - Some(offline_result()), - ) - .await - .expect("offline history should survive a failed CLI probe"); - assert_eq!( - offline_detail_value(&result), - format!("{OFFLINE_DETAIL_PREFIX} the quota request timed out") - ); -} - #[test] fn user_tier_resolves_google_ai_ultra_plan_name() { let json = serde_json::json!({