1use std::time::Duration;
34
35use chrono::{DateTime, Utc};
36
37use super::cloud;
38use super::credential::{self, StoredToken};
39use crate::cache::{Cache, acquire_lock_async};
40use crate::error::{AppError, Result};
41use crate::usage::{AntigravitySnapshot, AntigravitySource, UsageWindow};
42
43const HTTP_TIMEOUT: Duration = Duration::from_secs(5);
44const LOCK_TIMEOUT: Duration = Duration::from_secs(15);
45
46const QUOTA_RPC: &str = "exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary";
47const STATUS_RPC: &str = "exa.language_server_pb.LanguageServerService/GetUserStatus";
48
49const DEFAULT_PLAN: &str = "Antigravity";
50
51const NO_LOCAL_SERVER: &str = "Antigravity: no local server found. Quota is only served while \
52 Antigravity is running — open the Antigravity app, or an interactive \
53 `agy` session, or point ANTIGRAVITY_LS_ADDRESS at a host:port.";
54
55const AGY_CSRF_UNAVAILABLE: &str = "Antigravity: the running `agy` server requires a CSRF token \
56 that it does not publish.";
57
58const NO_SAVED_SESSION: &str = "Or sign in to Antigravity once, so its saved Google session can \
62 be used while it is closed.";
63
64const SESSION_EXPIRED: &str =
65 "Antigravity's saved Google session expired; open Antigravity to sign in again";
66
67pub type FetchOutcome = crate::outcome::Outcome<AntigravitySnapshot>;
70
71impl From<FetchOutcome> for crate::vendor::VendorOutcome {
72 fn from(o: FetchOutcome) -> Self {
73 o.map(crate::usage::VendorSnapshot::Antigravity)
74 }
75}
76
77#[derive(Debug, Clone, Copy, Default)]
81pub enum SavedCredential<'a> {
82 #[default]
84 Keyring,
85 Blob(&'a str),
87 File(&'a std::path::Path),
90 Absent,
92}
93
94pub struct RemoteOverride<'a> {
99 pub credential: SavedCredential<'a>,
101 pub endpoints: Option<&'a cloud::Endpoints>,
103 pub local_bases: Option<Vec<Candidate>>,
108 pub agy: super::agy::AgyCommand,
112 pub ide_language_server: bool,
116}
117
118impl Default for RemoteOverride<'_> {
119 fn default() -> Self {
120 Self {
121 credential: SavedCredential::default(),
122 endpoints: None,
123 local_bases: None,
124 agy: super::agy::AgyCommand::default(),
125 ide_language_server: true,
126 }
127 }
128}
129
130#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct Candidate {
136 pub base: String,
138 pub pid: Option<u32>,
141 pub ide: bool,
148}
149
150impl From<String> for Candidate {
151 fn from(base: String) -> Self {
152 Self {
153 base,
154 pid: None,
155 ide: false,
156 }
157 }
158}
159
160pub async fn fetch_snapshot(
163 client: &reqwest::Client,
164 cache: &Cache,
165 cache_ttl: Duration,
166 oauth: Option<&cloud::OauthClient>,
167 ide_language_server: bool,
168) -> Result<FetchOutcome> {
169 fetch_snapshot_at(
170 client,
171 cache,
172 cache_ttl,
173 oauth,
174 RemoteOverride {
175 agy: super::agy::AgyCommand::Discover,
176 ide_language_server,
177 ..Default::default()
178 },
179 Utc::now(),
180 )
181 .await
182}
183
184pub async fn fetch_snapshot_at(
188 client: &reqwest::Client,
189 cache: &Cache,
190 cache_ttl: Duration,
191 oauth: Option<&cloud::OauthClient>,
192 remote: RemoteOverride<'_>,
193 now: DateTime<Utc>,
194) -> Result<FetchOutcome> {
195 cache.ensure_dir()?;
196 let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
197
198 let origin = match open_session(
208 client,
209 remote.local_bases.as_deref(),
210 remote.ide_language_server,
211 )
212 .await
213 {
214 Ok(session) => Origin::Local(Ok(session)),
215 Err(error) => match remote_fallback_reason(&error) {
216 Some(reason) => Origin::Remote(saved_session(remote.credential, reason)),
217 None => Origin::Local(Err(error)),
218 },
219 };
220 let account = origin.account();
221
222 if let Some(bytes) = cache.fresh_payload(cache_ttl)?
223 && let Ok(outcome) = reuse_cache(bytes, cache, false, account.as_deref(), now)
224 {
225 return Ok(outcome);
226 }
227
228 let default_endpoints = cloud::Endpoints::default();
229 let endpoints = remote.endpoints.unwrap_or(&default_endpoints);
230 let live = match origin {
231 Origin::Local(session) => fetch_live(client, session).await,
232 Origin::Remote(token) => {
233 let renewal = Renewal {
234 agy: &remote.agy,
235 credential: remote.credential,
236 };
237 fetch_remote(client, cache, oauth, endpoints, token, renewal, now).await
238 }
239 };
240
241 match live {
242 Ok(snap) => {
243 let bytes = serde_json::to_vec(&snap_to_json(&snap))?;
244 cache.write_payload(&bytes)?;
245 Ok(crate::outcome::Outcome::fresh(snap))
246 }
247 Err(e) if e.is_transient() => fallback_silent(cache, now, e),
248 Err(AppError::Http { status, body }) => {
249 cache.mark_stale();
250 let last_error = Some(cache.write_last_error(status, &body));
251 let reason = AppError::Http { status, body };
252 fallback_with_error(cache, last_error, reason, now)
253 }
254 Err(e) => {
255 cache.mark_stale();
256 let last_error = Some(cache.write_last_error(0, &e.to_string()));
257 fallback_with_error(cache, last_error, e, now)
258 }
259 }
260}
261
262struct Session {
265 base: String,
266 csrf: Option<String>,
267 plan: String,
268 account: String,
269}
270
271enum Origin {
274 Local(Result<Session>),
275 Remote(Result<StoredToken>),
276}
277
278impl Origin {
279 fn account(&self) -> Option<String> {
281 match self {
282 Origin::Local(Ok(session)) => Some(session.account.clone()),
283 Origin::Remote(Ok(token)) => Some(remote_account(&token.fingerprint)),
284 Origin::Local(Err(_)) | Origin::Remote(Err(_)) => None,
285 }
286 }
287}
288
289fn no_local_server() -> AppError {
290 AppError::Credentials(NO_LOCAL_SERVER.into())
291}
292
293#[derive(Clone, Copy)]
294enum RemoteFallbackReason {
295 NoLocalServer,
296 AgyMissingCsrf,
297}
298
299impl RemoteFallbackReason {
300 fn message(self) -> &'static str {
301 match self {
302 Self::NoLocalServer => NO_LOCAL_SERVER,
303 Self::AgyMissingCsrf => AGY_CSRF_UNAVAILABLE,
304 }
305 }
306}
307
308fn remote_fallback_reason(error: &AppError) -> Option<RemoteFallbackReason> {
314 if matches!(error, AppError::Credentials(message) if message == NO_LOCAL_SERVER) {
315 Some(RemoteFallbackReason::NoLocalServer)
316 } else if is_missing_csrf(error) {
317 Some(RemoteFallbackReason::AgyMissingCsrf)
318 } else {
319 None
320 }
321}
322
323async fn open_session(
334 client: &reqwest::Client,
335 bases: Option<&[Candidate]>,
336 ide_language_server: bool,
337) -> Result<Session> {
338 let candidates = bases.map_or_else(
339 || candidate_bases(ide_language_server),
340 <[Candidate]>::to_vec,
341 );
342 let candidates: Vec<Candidate> = candidates.into_iter().filter(|c| !c.ide).collect();
343 if candidates.is_empty() {
344 return Err(no_local_server());
345 }
346
347 let mut errors = Vec::new();
348 let mut blocked_pids = std::collections::HashSet::new();
353 for candidate in candidates {
354 if candidate.pid.is_some_and(|pid| blocked_pids.contains(&pid)) {
355 continue;
356 }
357 let csrf = fetch_csrf(client, &candidate.base).await;
358 match post_rpc(client, &candidate.base, csrf.as_deref(), STATUS_RPC).await {
359 Ok(v) => {
360 return Ok(Session {
361 base: candidate.base,
362 csrf,
363 plan: plan_from_status(&v),
364 account: account_key(&v),
365 });
366 }
367 Err(e) => {
368 if is_missing_csrf(&e)
372 && let Some(pid) = candidate.pid
373 {
374 blocked_pids.insert(pid);
375 }
376 errors.push(e);
377 }
378 }
379 }
380 Err(select_probe_error(errors))
381}
382
383fn select_probe_error(errors: Vec<AppError>) -> AppError {
400 let mut actionable = None;
401 let mut missing_csrf = None;
402 let mut last = None;
403 let mut echo = None;
404 for e in errors {
405 if missing_csrf.is_none() && is_missing_csrf(&e) {
406 missing_csrf = Some(e);
407 } else if actionable.is_none() && is_actionable(&e) {
408 actionable = Some(e);
409 } else if is_tls_echo(&e) {
410 echo = Some(e);
411 } else {
412 last = Some(e);
413 }
414 }
415 actionable
416 .or(missing_csrf)
417 .or(last)
418 .or(echo)
419 .unwrap_or_else(|| {
420 AppError::Other("antigravity: no local server answered GetUserStatus".into())
421 })
422}
423
424fn is_missing_csrf(error: &AppError) -> bool {
430 let AppError::Http { status: 401, body } = error else {
431 return false;
432 };
433 let Ok(body) = serde_json::from_str::<serde_json::Value>(body) else {
434 return false;
435 };
436 matches!(
437 (body["code"].as_str(), body["message"].as_str()),
438 (Some(code), Some(message))
439 if code.eq_ignore_ascii_case("unauthenticated")
440 && is_missing_csrf_message(message)
441 )
442}
443
444fn is_missing_csrf_message(message: &str) -> bool {
447 message
448 .rsplit(':')
449 .next()
450 .is_some_and(|part| part.trim().eq_ignore_ascii_case("missing CSRF token"))
451}
452
453fn is_actionable(e: &AppError) -> bool {
458 matches!(e, AppError::Http { status, .. } if *status == 401 || *status == 403)
459 && !is_missing_csrf(e)
460}
461
462fn is_tls_echo(e: &AppError) -> bool {
475 matches!(
476 e,
477 AppError::Http { status: 400, body } if body.contains("HTTP request to an HTTPS server")
478 )
479}
480
481async fn fetch_live(
482 client: &reqwest::Client,
483 session: Result<Session>,
484) -> Result<AntigravitySnapshot> {
485 let session = session?;
486 let quota = post_rpc(client, &session.base, session.csrf.as_deref(), QUOTA_RPC).await?;
487 let mut snap = parse_quota_summary("a, session.plan)?;
488 snap.account = session.account;
489 Ok(snap)
490}
491
492fn saved_session(
502 credential: SavedCredential<'_>,
503 reason: RemoteFallbackReason,
504) -> Result<StoredToken> {
505 let raw = match credential {
506 SavedCredential::Keyring => credential::read()?,
507 SavedCredential::Blob(blob) => Some(blob.to_string()),
508 SavedCredential::File(path) => std::fs::read_to_string(path).ok(),
509 SavedCredential::Absent => None,
510 };
511 let Some(raw) = raw else {
512 return Err(AppError::Credentials(format!(
513 "{} {NO_SAVED_SESSION}",
514 reason.message()
515 )));
516 };
517 credential::parse_keyring_blob(&raw)
518}
519
520fn remote_account(fingerprint: &str) -> String {
524 format!("acct:{fingerprint}")
525}
526
527fn session_expired() -> AppError {
528 AppError::Credentials(SESSION_EXPIRED.into())
529}
530
531fn refresh_unconfigured() -> AppError {
535 AppError::Credentials(
536 "Antigravity's saved Google session expired and ai-usagebar has no OAuth client to \
537 refresh it; open Antigravity (or run `agy`) to sign in again, or set [antigravity] oauth_client_id \
538 and oauth_client_secret in config.toml"
539 .into(),
540 )
541}
542
543fn is_auth_rejection(e: &AppError) -> bool {
544 matches!(
545 e,
546 AppError::Http {
547 status: 401 | 403,
548 ..
549 }
550 )
551}
552
553struct AccessToken {
557 value: String,
558 just_refreshed: bool,
559}
560
561async fn resolve_access_token(
566 client: &reqwest::Client,
567 oauth: Option<&cloud::OauthClient>,
568 endpoints: &cloud::Endpoints,
569 oauth_path: &std::path::Path,
570 token: &StoredToken,
571 now: DateTime<Utc>,
572) -> Result<AccessToken> {
573 if let Some(persisted) = cloud::read_persisted(oauth_path, &token.fingerprint)
574 && token
575 .expires_at
576 .is_none_or(|keyring| persisted.expires_at > keyring)
577 && !cloud::needs_refresh(Some(persisted.expires_at), now)
578 {
579 return Ok(AccessToken {
580 value: persisted.access_token,
581 just_refreshed: false,
582 });
583 }
584 if !cloud::needs_refresh(token.expires_at, now) {
585 return Ok(AccessToken {
586 value: token.access_token.clone(),
587 just_refreshed: false,
588 });
589 }
590 refresh_and_persist(client, oauth, endpoints, oauth_path, token).await
591}
592
593async fn refresh_and_persist(
597 client: &reqwest::Client,
598 oauth: Option<&cloud::OauthClient>,
599 endpoints: &cloud::Endpoints,
600 oauth_path: &std::path::Path,
601 token: &StoredToken,
602) -> Result<AccessToken> {
603 let Some(refresh_token) = token.refresh_token.as_deref() else {
604 return Err(session_expired());
605 };
606 let Some(oauth) = oauth else {
607 return Err(refresh_unconfigured());
608 };
609 let refreshed = cloud::refresh(client, &endpoints.token, oauth, refresh_token).await?;
610 cloud::write_persisted(
611 oauth_path,
612 &cloud::PersistedOAuth {
613 fingerprint: token.fingerprint.clone(),
614 access_token: refreshed.access_token.clone(),
615 expires_at: refreshed.expires_at,
616 },
617 )?;
618 Ok(AccessToken {
619 value: refreshed.access_token,
620 just_refreshed: true,
621 })
622}
623
624struct Renewal<'a> {
627 agy: &'a super::agy::AgyCommand,
628 credential: SavedCredential<'a>,
629}
630
631impl Renewal<'_> {
632 async fn renewed(&self, cache: &Cache, now: DateTime<Utc>) -> Option<StoredToken> {
636 if !super::agy::renew_session(self.agy, cache, now).await {
637 return None;
638 }
639 let raw = match self.credential {
640 SavedCredential::Keyring => credential::read().ok().flatten()?,
641 SavedCredential::Blob(blob) => blob.to_string(),
642 SavedCredential::File(path) => std::fs::read_to_string(path).ok()?,
643 SavedCredential::Absent => return None,
644 };
645 credential::parse_keyring_blob(&raw).ok()
646 }
647}
648
649async fn fetch_remote(
658 client: &reqwest::Client,
659 cache: &Cache,
660 oauth: Option<&cloud::OauthClient>,
661 endpoints: &cloud::Endpoints,
662 token: Result<StoredToken>,
663 renewal: Renewal<'_>,
664 now: DateTime<Utc>,
665) -> Result<AntigravitySnapshot> {
666 let mut token = token?;
667 let oauth_path = cloud::oauth_cache_path(cache);
668 let mut access =
669 match resolve_access_token(client, oauth, endpoints, &oauth_path, &token, now).await {
670 Ok(access) => access,
671 Err(e) if oauth.is_none() => {
674 let Some(renewed) = renewal.renewed(cache, now).await else {
675 return Err(e);
676 };
677 token = renewed;
678 resolve_access_token(client, oauth, endpoints, &oauth_path, &token, now).await?
679 }
680 Err(e) => return Err(e),
681 };
682
683 let quota = match cloud::fetch_quota(client, endpoints, &access.value).await {
684 Err(e) if cloud::is_no_plan_quota(&e) => return Err(e),
687 Err(e) if is_auth_rejection(&e) && !access.just_refreshed => {
688 access = if oauth.is_none() {
689 let Some(renewed) = renewal.renewed(cache, now).await else {
690 return Err(refresh_unconfigured());
691 };
692 token = renewed;
693 resolve_access_token(client, oauth, endpoints, &oauth_path, &token, now).await?
694 } else {
695 refresh_and_persist(client, oauth, endpoints, &oauth_path, &token).await?
696 };
697 cloud::fetch_quota(client, endpoints, &access.value)
698 .await
699 .map_err(|e| {
700 if cloud::is_no_plan_quota(&e) {
701 e
702 } else if is_auth_rejection(&e) {
703 session_expired()
704 } else {
705 e
706 }
707 })?
708 }
709 Err(e) if is_auth_rejection(&e) => return Err(session_expired()),
710 other => other?,
711 };
712
713 let plan = cloud::fetch_plan(client, endpoints, &access.value)
714 .await
715 .unwrap_or_else(|| DEFAULT_PLAN.to_string());
716 let mut snap = parse_quota_summary("a, plan)?;
717 snap.account = remote_account(&token.fingerprint);
718 snap.source = AntigravitySource::Remote;
719 Ok(snap)
720}
721
722fn account_key(user_status: &serde_json::Value) -> String {
727 let email = user_status["userStatus"]["email"]
728 .as_str()
729 .filter(|s| !s.is_empty());
730 match email {
731 Some(e) => {
732 use std::hash::{Hash, Hasher};
733 let mut h = std::collections::hash_map::DefaultHasher::new();
734 e.hash(&mut h);
735 format!("acct:{:016x}", h.finish())
736 }
737 None => "acct:unknown".to_string(),
738 }
739}
740
741async fn fetch_csrf(client: &reqwest::Client, base: &str) -> Option<String> {
745 let resp = client.get(base).timeout(HTTP_TIMEOUT).send().await.ok()?;
746 let bytes = crate::vendor::read_body_capped(resp, crate::vendor::MAX_BODY_BYTES)
749 .await
750 .ok()?;
751 let html = String::from_utf8_lossy(&bytes);
752 html.split("csrfToken\":\"")
753 .nth(1)
754 .and_then(|s| s.split('"').next())
755 .filter(|t| !t.is_empty())
756 .map(|t| t.to_string())
757}
758
759async fn post_rpc(
760 client: &reqwest::Client,
761 base: &str,
762 csrf: Option<&str>,
763 rpc: &str,
764) -> Result<serde_json::Value> {
765 let mut req = client
766 .post(format!("{base}/{rpc}"))
767 .header("Content-Type", "application/json")
768 .body("{}")
769 .timeout(HTTP_TIMEOUT);
770 if let Some(token) = csrf {
771 req = req.header("x-codeium-csrf-token", token);
772 }
773 let resp = req.send().await?;
774
775 let status = resp.status();
776 let bytes = crate::vendor::read_body_capped(resp, crate::vendor::MAX_BODY_BYTES).await?;
780 if !status.is_success() {
781 let body = String::from_utf8_lossy(&bytes).into_owned();
782 return Err(AppError::Http {
783 status: status.as_u16(),
784 body,
785 });
786 }
787
788 Ok(serde_json::from_slice(&bytes)?)
789}
790
791pub fn plan_from_status(v: &serde_json::Value) -> String {
792 v["userStatus"]["userTier"]["name"]
793 .as_str()
794 .or_else(|| v["userStatus"]["userTier"]["description"].as_str())
795 .or_else(|| v["userStatus"]["planStatus"]["planInfo"]["planName"].as_str())
796 .filter(|s| !s.is_empty())
797 .unwrap_or(DEFAULT_PLAN)
798 .to_string()
799}
800
801fn describe_bucket(group_name: &str, id: &str, window: Option<&str>) -> String {
812 let id = if id.is_empty() { "<unnamed>" } else { id };
813 let group = if group_name.is_empty() {
814 String::new()
815 } else {
816 format!(" in {group_name:?}")
817 };
818 match window {
819 Some(window) if !window.is_empty() => format!("{id} (window {window}){group}"),
820 _ => format!("{id}{group}"),
821 }
822}
823
824fn no_usable_bucket(seen: &[String]) -> AppError {
830 if seen.is_empty() {
831 return AppError::Other(
832 "antigravity: quota summary has no buckets at all — the running product may \
833 not have a quota for this account yet"
834 .into(),
835 );
836 }
837 AppError::Other(format!(
838 "antigravity: quota summary has no bucket in a window we recognise (5h or \
839 weekly, Gemini or Claude/GPT); it offered: {}",
840 seen.join(", ")
841 ))
842}
843
844pub fn parse_quota_summary(v: &serde_json::Value, plan: String) -> Result<AntigravitySnapshot> {
845 let groups = v["response"]["groups"]
846 .as_array()
847 .or_else(|| v["groups"].as_array())
848 .ok_or_else(|| AppError::Other("antigravity: quota summary has no groups".into()))?;
849
850 let mut gemini_5h = None;
851 let mut gemini_weekly = None;
852 let mut tp_5h = None;
853 let mut tp_weekly = None;
854 let mut seen: Vec<String> = Vec::new();
858
859 for group in groups {
860 let group_name = group["displayName"].as_str().unwrap_or_default();
861 let Some(buckets) = group["buckets"].as_array() else {
862 continue;
863 };
864 for bucket in buckets {
865 let id = bucket["bucketId"].as_str().unwrap_or_default();
866 seen.push(describe_bucket(group_name, id, bucket["window"].as_str()));
867 let window = bucket["window"].as_str().unwrap_or_default();
868 let is_weekly = if id.ends_with("weekly") || window == "weekly" {
869 true
870 } else if id.ends_with("5h") || window == "5h" {
871 false
872 } else {
873 continue;
876 };
877 let is_gemini = if id.starts_with("gemini") {
878 true
879 } else if id.starts_with("3p") {
880 false
881 } else if group_name.contains("Gemini") {
882 true
883 } else if group_name.contains("Claude") || group_name.contains("GPT") {
884 false
885 } else {
886 continue;
889 };
890
891 let (slot, slot_name) = match (is_gemini, is_weekly) {
892 (true, false) => (&mut gemini_5h, "Gemini 5h"),
893 (true, true) => (&mut gemini_weekly, "Gemini weekly"),
894 (false, false) => (&mut tp_5h, "Claude/GPT 5h"),
895 (false, true) => (&mut tp_weekly, "Claude/GPT weekly"),
896 };
897 let parsed = usage_window(bucket, is_weekly)?;
898 if slot.replace(parsed).is_some() {
899 return Err(AppError::Schema(format!(
900 "antigravity: duplicate {slot_name} bucket"
901 )));
902 }
903 }
904 }
905
906 if gemini_5h.is_none() && gemini_weekly.is_none() && tp_5h.is_none() && tp_weekly.is_none() {
910 return Err(no_usable_bucket(&seen));
911 }
912
913 Ok(AntigravitySnapshot {
914 plan,
915 account: String::new(),
918 source: AntigravitySource::Local,
919 session: gemini_5h,
920 weekly: gemini_weekly,
921 third_party_session: tp_5h,
922 third_party_weekly: tp_weekly,
923 })
924}
925
926fn usage_window(bucket: &serde_json::Value, is_weekly: bool) -> Result<UsageWindow> {
930 let remaining = bucket["remainingFraction"]
931 .as_f64()
932 .filter(|f| f.is_finite() && (0.0..=1.0).contains(f))
933 .ok_or_else(|| {
934 AppError::Schema(format!(
935 "antigravity: bucket {} has no valid remainingFraction in 0..=1",
936 bucket["bucketId"].as_str().unwrap_or("<unnamed>")
937 ))
938 })?;
939 Ok(UsageWindow {
940 utilization_pct: pct_used(remaining),
941 resets_at: parse_reset(&bucket["resetTime"], "quota resetTime")?,
942 window_duration: if is_weekly {
943 chrono::Duration::days(7)
944 } else {
945 chrono::Duration::hours(5)
946 },
947 })
948}
949
950fn pct_used(remaining_fraction: f64) -> i32 {
953 let used = (1.0 - remaining_fraction) * 100.0;
954 used.round() as i32
955}
956
957fn parse_reset(value: &serde_json::Value, field: &str) -> Result<Option<DateTime<Utc>>> {
958 match value {
959 serde_json::Value::Null => Ok(None),
960 serde_json::Value::String(s) => DateTime::parse_from_rfc3339(s)
961 .map(|dt| Some(dt.with_timezone(&Utc)))
962 .map_err(|_| AppError::Schema(format!("antigravity: invalid {field}"))),
963 _ => Err(AppError::Schema(format!(
964 "antigravity: {field} must be a timestamp or null"
965 ))),
966 }
967}
968
969fn candidate_bases(ide_language_server: bool) -> Vec<Candidate> {
977 let discovered = discover_ls_ports();
978 let ide_pids = if ide_language_server {
981 std::collections::HashSet::new()
982 } else {
983 ide_ls_pids(discovered.iter().map(|&(pid, _)| pid).collect())
984 };
985 candidate_bases_with(
986 std::env::var("ANTIGRAVITY_LS_ADDRESS").ok().as_deref(),
987 discovered,
988 &ide_pids,
989 )
990}
991
992fn candidate_bases_with(
996 override_addr: Option<&str>,
997 discovered: Vec<(u32, u16)>,
998 ide_pids: &std::collections::HashSet<u32>,
999) -> Vec<Candidate> {
1000 let mut candidates = Vec::new();
1001 if let Some(base) = override_addr.and_then(normalize_base) {
1002 let pid = base_port(&base).and_then(|port| unique_owner(&discovered, port));
1007 candidates.push(Candidate {
1008 base,
1009 pid,
1010 ide: false,
1011 });
1012 }
1013
1014 for (pid, port) in discovered {
1020 let base = format!("http://127.0.0.1:{port}");
1021 if !candidates.iter().any(|c| c.base == base) {
1022 candidates.push(Candidate {
1023 base,
1024 pid: Some(pid),
1025 ide: ide_pids.contains(&pid),
1026 });
1027 }
1028 }
1029
1030 candidates
1031}
1032
1033#[cfg(any(test, target_os = "linux", target_os = "macos"))]
1038fn is_ide_install(exe: &str) -> bool {
1039 let exe = exe.to_lowercase().replace('\\', "/");
1040 exe.contains("antigravity-ide") || exe.contains("antigravity ide")
1041}
1042
1043#[cfg(target_os = "linux")]
1045fn ide_ls_pids(pids: std::collections::HashSet<u32>) -> std::collections::HashSet<u32> {
1046 pids.into_iter()
1047 .filter(|pid| {
1048 std::fs::read_link(format!("/proc/{pid}/exe"))
1049 .map(|path| path.to_string_lossy().into_owned())
1050 .is_ok_and(|exe| is_ide_install(&exe))
1051 })
1052 .collect()
1053}
1054
1055#[cfg(target_os = "macos")]
1058fn ide_ls_pids(pids: std::collections::HashSet<u32>) -> std::collections::HashSet<u32> {
1059 if pids.is_empty() {
1060 return std::collections::HashSet::new();
1061 }
1062 let list = pids
1063 .iter()
1064 .map(u32::to_string)
1065 .collect::<Vec<_>>()
1066 .join(",");
1067 let Ok(output) = std::process::Command::new("ps")
1068 .args(["-o", "pid=,comm=", "-p", &list])
1069 .output()
1070 else {
1071 return std::collections::HashSet::new();
1072 };
1073 parse_ps_comm(&String::from_utf8_lossy(&output.stdout))
1074 .into_iter()
1075 .filter(|(pid, exe)| pids.contains(pid) && is_ide_install(exe))
1076 .map(|(pid, _)| pid)
1077 .collect()
1078}
1079
1080#[cfg(any(test, target_os = "macos"))]
1083fn parse_ps_comm(output: &str) -> Vec<(u32, String)> {
1084 output
1085 .lines()
1086 .filter_map(|line| {
1087 let (pid, exe) = line.trim().split_once(char::is_whitespace)?;
1088 Some((pid.parse().ok()?, exe.trim().to_string()))
1089 })
1090 .collect()
1091}
1092
1093#[cfg(target_os = "windows")]
1097fn ide_ls_pids(_: std::collections::HashSet<u32>) -> std::collections::HashSet<u32> {
1098 std::collections::HashSet::new()
1099}
1100
1101#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
1102fn ide_ls_pids(_: std::collections::HashSet<u32>) -> std::collections::HashSet<u32> {
1103 std::collections::HashSet::new()
1104}
1105
1106fn base_port(base: &str) -> Option<u16> {
1108 let (_, authority) = base.split_once("://")?;
1109 authority.rsplit_once(':')?.1.parse().ok()
1110}
1111
1112fn unique_owner(discovered: &[(u32, u16)], port: u16) -> Option<u32> {
1115 let mut owners = discovered
1116 .iter()
1117 .filter(|(_, p)| *p == port)
1118 .map(|(pid, _)| *pid);
1119 let first = owners.next()?;
1120 owners.all(|pid| pid == first).then_some(first)
1121}
1122
1123fn normalize_base(addr: &str) -> Option<String> {
1132 let trimmed = addr.trim();
1133 let (scheme, authority) = match trimmed.split_once("://") {
1134 Some((scheme @ ("http" | "https"), rest)) => (scheme, rest),
1135 _ => ("http", trimmed),
1136 };
1137 let authority = authority.trim_end_matches('/');
1138 (!authority.is_empty()).then(|| format!("{scheme}://{authority}"))
1139}
1140
1141fn is_antigravity_process(comm: &str, exe: Option<&str>) -> bool {
1148 let comm = comm.trim().to_lowercase();
1149 let comm = comm.strip_suffix(".exe").unwrap_or(&comm);
1150 if comm.contains("language_server") || comm == "agy" || comm == "antigravity" {
1151 return true;
1152 }
1153 exe.is_some_and(|p| {
1154 let p = p.to_lowercase().replace('\\', "/");
1155 let p = p.strip_suffix(".exe").unwrap_or(&p);
1156 p.contains("antigravity") || p.ends_with("/agy")
1157 })
1158}
1159
1160#[cfg(any(test, target_os = "linux", target_os = "macos", target_os = "windows"))]
1192fn probe_order(per_pid: std::collections::BTreeMap<u32, Vec<u16>>) -> Vec<(u32, u16)> {
1193 let groups: Vec<(u32, Vec<u16>)> = per_pid
1194 .into_iter()
1195 .map(|(pid, mut group)| {
1196 group.sort_unstable_by(|a, b| b.cmp(a));
1197 group.dedup();
1202 (pid, group)
1203 })
1204 .collect();
1205
1206 let mut ports: Vec<(u32, u16)> = Vec::new();
1207 for rank in 0..groups.iter().map(|(_, g)| g.len()).max().unwrap_or(0) {
1208 for (pid, port) in groups
1209 .iter()
1210 .filter_map(|(pid, group)| group.get(rank).map(|port| (*pid, *port)))
1211 {
1212 if !ports.iter().any(|(_, p)| *p == port) {
1215 ports.push((pid, port));
1216 }
1217 }
1218 }
1219 ports
1220}
1221
1222#[cfg(target_os = "linux")]
1230pub(crate) fn discover_ls_ports() -> Vec<(u32, u16)> {
1231 use std::collections::{BTreeMap, HashMap};
1232
1233 let mut owners: HashMap<u64, u32> = HashMap::new();
1236 let Ok(entries) = std::fs::read_dir("/proc") else {
1237 return Vec::new();
1238 };
1239 for entry in entries.flatten() {
1240 let pid_dir = entry.path();
1241 let Some(pid) = pid_dir
1242 .file_name()
1243 .and_then(|name| name.to_str())
1244 .and_then(|name| name.parse::<u32>().ok())
1245 else {
1246 continue;
1247 };
1248 let Ok(comm) = std::fs::read_to_string(pid_dir.join("comm")) else {
1249 continue;
1250 };
1251 let exe = std::fs::read_link(pid_dir.join("exe")).ok();
1252 if !is_antigravity_process(&comm, exe.as_deref().and_then(|p| p.to_str())) {
1253 continue;
1254 }
1255 let Ok(fds) = std::fs::read_dir(pid_dir.join("fd")) else {
1256 continue;
1257 };
1258 for fd in fds.flatten() {
1259 let Ok(target) = std::fs::read_link(fd.path()) else {
1260 continue;
1261 };
1262 if let Some(ino) = target
1263 .to_str()
1264 .and_then(|s| s.strip_prefix("socket:["))
1265 .and_then(|s| s.strip_suffix(']'))
1266 .and_then(|s| s.parse::<u64>().ok())
1267 {
1268 owners.insert(ino, pid);
1269 }
1270 }
1271 }
1272
1273 if owners.is_empty() {
1274 return Vec::new();
1275 }
1276
1277 let mut per_pid: BTreeMap<u32, Vec<u16>> = BTreeMap::new();
1278 for table in ["/proc/net/tcp", "/proc/net/tcp6"] {
1279 let Ok(contents) = std::fs::read_to_string(table) else {
1280 continue;
1281 };
1282 for line in contents.lines().skip(1) {
1283 if let Some((port, ino)) = parse_proc_net_line(line)
1284 && let Some(&pid) = owners.get(&ino)
1285 {
1286 per_pid.entry(pid).or_default().push(port);
1287 }
1288 }
1289 }
1290 probe_order(per_pid)
1291}
1292
1293#[cfg(target_os = "macos")]
1300pub(crate) fn discover_ls_ports() -> Vec<(u32, u16)> {
1301 let Ok(output) = std::process::Command::new("lsof")
1302 .args(["-nP", "-iTCP", "-sTCP:LISTEN", "-F", "pcn"])
1303 .output()
1304 else {
1305 return Vec::new();
1306 };
1307 parse_lsof_pcn(&String::from_utf8_lossy(&output.stdout))
1311}
1312
1313#[cfg(any(test, target_os = "macos"))]
1318fn parse_lsof_pcn(output: &str) -> Vec<(u32, u16)> {
1319 let mut per_pid: std::collections::BTreeMap<u32, Vec<u16>> = std::collections::BTreeMap::new();
1320 let mut pid = None;
1323 let mut owner = None;
1324 for line in output.lines() {
1325 let Some(rest) = line.get(1..) else { continue };
1326 match line.as_bytes().first() {
1327 Some(b'p') => {
1328 pid = rest.parse::<u32>().ok();
1329 owner = None;
1330 }
1331 Some(b'c') => owner = pid.filter(|_| is_antigravity_process(rest, None)),
1332 Some(b'n') => {
1333 if let Some(pid) = owner
1334 && let Some(port) = rest.rsplit(':').next().and_then(|p| p.parse::<u16>().ok())
1335 {
1336 per_pid.entry(pid).or_default().push(port);
1337 }
1338 }
1339 _ => {}
1340 }
1341 }
1342 probe_order(per_pid)
1343}
1344
1345#[cfg(any(test, target_os = "windows"))]
1346#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1347struct WindowsTcpRow {
1348 local_addr: [u8; 4],
1349 local_port: u32,
1350 pid: u32,
1351}
1352
1353#[cfg(any(test, target_os = "windows"))]
1354fn decode_windows_process_name(raw: &[u16]) -> String {
1355 let end = raw.iter().position(|&unit| unit == 0).unwrap_or(raw.len());
1356 String::from_utf16_lossy(&raw[..end])
1357}
1358
1359#[cfg(any(test, target_os = "windows"))]
1360fn matching_windows_process_ids(processes: &[(u32, String)]) -> std::collections::HashSet<u32> {
1361 processes
1362 .iter()
1363 .filter(|(_, name)| is_antigravity_process(name, None))
1364 .map(|(pid, _)| *pid)
1365 .collect()
1366}
1367
1368#[cfg(any(test, target_os = "windows"))]
1371fn matching_windows_ports(
1372 pids: &std::collections::HashSet<u32>,
1373 rows: &[WindowsTcpRow],
1374) -> Vec<(u32, u16)> {
1375 let mut per_pid: std::collections::BTreeMap<u32, Vec<u16>> = std::collections::BTreeMap::new();
1376 for row in rows {
1377 if !pids.contains(&row.pid) || row.local_addr != [127, 0, 0, 1] {
1378 continue;
1379 }
1380 let port = u16::from_be((row.local_port & u32::from(u16::MAX)) as u16);
1381 if port != 0 {
1382 per_pid.entry(row.pid).or_default().push(port);
1383 }
1384 }
1385 probe_order(per_pid)
1386}
1387
1388#[cfg(any(test, target_os = "windows"))]
1389fn checked_windows_row_count(
1390 buffer_len: usize,
1391 rows_offset: usize,
1392 row_size: usize,
1393 declared: usize,
1394) -> Option<usize> {
1395 let rows_len = row_size.checked_mul(declared)?;
1396 let end = rows_offset.checked_add(rows_len)?;
1397 (row_size != 0 && end <= buffer_len).then_some(declared)
1398}
1399
1400#[cfg(target_os = "windows")]
1401struct WindowsHandle(windows_sys::Win32::Foundation::HANDLE);
1402
1403#[cfg(target_os = "windows")]
1404impl Drop for WindowsHandle {
1405 fn drop(&mut self) {
1406 unsafe {
1407 windows_sys::Win32::Foundation::CloseHandle(self.0);
1408 }
1409 }
1410}
1411
1412#[cfg(target_os = "windows")]
1413fn windows_processes() -> Vec<(u32, String)> {
1414 use std::mem::size_of;
1415 use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE;
1416 use windows_sys::Win32::System::Diagnostics::ToolHelp::{
1417 CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW,
1418 TH32CS_SNAPPROCESS,
1419 };
1420
1421 let handle = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
1422 if handle == INVALID_HANDLE_VALUE {
1423 return Vec::new();
1424 }
1425 let snapshot = WindowsHandle(handle);
1426 let mut entry = PROCESSENTRY32W::default();
1427 let Ok(entry_size) = u32::try_from(size_of::<PROCESSENTRY32W>()) else {
1428 return Vec::new();
1429 };
1430 entry.dwSize = entry_size;
1431 if unsafe { Process32FirstW(snapshot.0, &mut entry) } == 0 {
1432 return Vec::new();
1433 }
1434
1435 let mut processes = Vec::new();
1436 loop {
1437 processes.push((
1438 entry.th32ProcessID,
1439 decode_windows_process_name(&entry.szExeFile),
1440 ));
1441 if unsafe { Process32NextW(snapshot.0, &mut entry) } == 0 {
1442 break;
1443 }
1444 }
1445 processes
1446}
1447
1448#[cfg(target_os = "windows")]
1449fn parse_windows_tcp_rows(buffer: &[u32], used_bytes: usize) -> Vec<WindowsTcpRow> {
1450 use std::mem::{offset_of, size_of, size_of_val};
1451 use windows_sys::Win32::NetworkManagement::IpHelper::{
1452 MIB_TCPROW_OWNER_PID, MIB_TCPTABLE_OWNER_PID,
1453 };
1454
1455 let available = used_bytes.min(size_of_val(buffer));
1456 let rows_offset = offset_of!(MIB_TCPTABLE_OWNER_PID, table);
1457 if available < size_of::<u32>() || available < rows_offset {
1458 return Vec::new();
1459 }
1460 let base = buffer.as_ptr().cast::<u8>();
1461 let declared = unsafe { base.cast::<u32>().read_unaligned() } as usize;
1462 if checked_windows_row_count(
1463 available,
1464 rows_offset,
1465 size_of::<MIB_TCPROW_OWNER_PID>(),
1466 declared,
1467 )
1468 .is_none()
1469 {
1470 return Vec::new();
1471 }
1472
1473 let rows = unsafe { base.add(rows_offset).cast::<MIB_TCPROW_OWNER_PID>() };
1474 (0..declared)
1475 .map(|index| unsafe { rows.add(index).read_unaligned() })
1476 .map(|row| WindowsTcpRow {
1477 local_addr: row.dwLocalAddr.to_ne_bytes(),
1478 local_port: row.dwLocalPort,
1479 pid: row.dwOwningPid,
1480 })
1481 .collect()
1482}
1483
1484#[cfg(target_os = "windows")]
1485fn windows_tcp_rows() -> Vec<WindowsTcpRow> {
1486 use std::mem::size_of;
1487 use std::ptr::null_mut;
1488 use windows_sys::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER;
1489 use windows_sys::Win32::NetworkManagement::IpHelper::{
1490 GetExtendedTcpTable, TCP_TABLE_OWNER_PID_LISTENER,
1491 };
1492 use windows_sys::Win32::Networking::WinSock::AF_INET;
1493
1494 let mut size = 0u32;
1495 let status = unsafe {
1496 GetExtendedTcpTable(
1497 null_mut(),
1498 &mut size,
1499 0,
1500 u32::from(AF_INET),
1501 TCP_TABLE_OWNER_PID_LISTENER,
1502 0,
1503 )
1504 };
1505 if status != ERROR_INSUFFICIENT_BUFFER {
1506 return Vec::new();
1507 }
1508
1509 for _ in 0..3 {
1510 let Some(words) = (size as usize)
1511 .checked_add(size_of::<u32>() - 1)
1512 .map(|bytes| bytes / size_of::<u32>())
1513 else {
1514 return Vec::new();
1515 };
1516 if words == 0 {
1517 return Vec::new();
1518 }
1519 let mut buffer = Vec::<u32>::new();
1520 if buffer.try_reserve_exact(words).is_err() {
1521 return Vec::new();
1522 }
1523 buffer.resize(words, 0);
1524 let mut used = size;
1525 let status = unsafe {
1526 GetExtendedTcpTable(
1527 buffer.as_mut_ptr().cast(),
1528 &mut used,
1529 0,
1530 u32::from(AF_INET),
1531 TCP_TABLE_OWNER_PID_LISTENER,
1532 0,
1533 )
1534 };
1535 if status == ERROR_INSUFFICIENT_BUFFER {
1536 size = used;
1537 continue;
1538 }
1539 if status != 0 {
1540 return Vec::new();
1541 }
1542 return parse_windows_tcp_rows(&buffer, used as usize);
1543 }
1544 Vec::new()
1545}
1546
1547#[cfg(target_os = "windows")]
1548pub(crate) fn discover_ls_ports() -> Vec<(u32, u16)> {
1549 let pids = matching_windows_process_ids(&windows_processes());
1550 if pids.is_empty() {
1551 return Vec::new();
1552 }
1553 matching_windows_ports(&pids, &windows_tcp_rows())
1554}
1555
1556#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
1557pub(crate) fn discover_ls_ports() -> Vec<(u32, u16)> {
1558 Vec::new()
1559}
1560
1561#[cfg(target_os = "linux")]
1564fn parse_proc_net_line(line: &str) -> Option<(u16, u64)> {
1565 let cols: Vec<&str> = line.split_whitespace().collect();
1566 if cols.len() < 10 {
1567 return None;
1568 }
1569 if cols[3] != "0A" {
1571 return None;
1572 }
1573 let port = u16::from_str_radix(cols[1].split(':').nth(1)?, 16).ok()?;
1574 let inode = cols[9].parse::<u64>().ok()?;
1575 Some((port, inode))
1576}
1577
1578fn fallback_silent(cache: &Cache, now: DateTime<Utc>, original: AppError) -> Result<FetchOutcome> {
1583 crate::outcome::fallback(cache, None, original, |bytes| {
1584 parse_cache_at(bytes, None, now)
1585 })
1586}
1587
1588fn fallback_with_error(
1593 cache: &Cache,
1594 last_error: Option<(u16, String)>,
1595 reason: AppError,
1596 now: DateTime<Utc>,
1597) -> Result<FetchOutcome> {
1598 crate::outcome::fallback(cache, last_error, reason, |bytes| {
1599 parse_cache_at(bytes, None, now)
1600 })
1601}
1602
1603fn reuse_cache(
1604 bytes: Vec<u8>,
1605 cache: &Cache,
1606 stale: bool,
1607 account: Option<&str>,
1608 now: DateTime<Utc>,
1609) -> Result<FetchOutcome> {
1610 let snap = parse_cache_at(&bytes, account, now)?;
1611 Ok(crate::outcome::Outcome::cached(snap, cache, stale))
1612}
1613
1614pub fn parse_cache(bytes: &[u8], account: Option<&str>) -> Result<AntigravitySnapshot> {
1620 parse_cache_at(bytes, account, Utc::now())
1621}
1622
1623fn expired_window(snap: &AntigravitySnapshot, now: DateTime<Utc>) -> Option<&'static str> {
1633 [
1634 ("Gemini 5h", snap.session.as_ref()),
1635 ("Gemini weekly", snap.weekly.as_ref()),
1636 ("Claude & GPT OSS 5h", snap.third_party_session.as_ref()),
1637 ("Claude & GPT OSS weekly", snap.third_party_weekly.as_ref()),
1638 ]
1639 .into_iter()
1640 .find(|(_, w)| w.and_then(|w| w.resets_at).is_some_and(|r| r <= now))
1641 .map(|(name, _)| name)
1642}
1643
1644pub fn parse_cache_at(
1645 bytes: &[u8],
1646 account: Option<&str>,
1647 now: DateTime<Utc>,
1648) -> Result<AntigravitySnapshot> {
1649 let v: serde_json::Value = serde_json::from_slice(bytes)?;
1650
1651 let cached_account = v.get("account").and_then(serde_json::Value::as_str);
1652 if let Some(expected) = account
1653 && cached_account != Some(expected)
1654 {
1655 return Err(AppError::Schema(
1656 "antigravity cache belongs to a different account; refetching".into(),
1657 ));
1658 }
1659
1660 let cached_pct = |pct_key: &'static str| -> Result<Option<i32>> {
1670 match v.get(pct_key) {
1671 None => Err(AppError::Schema(format!(
1672 "antigravity: cached payload is missing {pct_key}"
1673 ))),
1674 Some(serde_json::Value::Null) => Ok(None),
1675 Some(value) => value
1676 .as_i64()
1677 .filter(|pct| (0..=100).contains(pct))
1678 .map(|pct| Some(pct as i32))
1679 .ok_or_else(|| {
1680 AppError::Schema(format!(
1681 "antigravity: cached {pct_key} must be an integer in 0..=100"
1682 ))
1683 }),
1684 }
1685 };
1686
1687 let optional = |pct_key: &'static str, reset_key: &str, weekly: bool| {
1688 let Some(pct) = cached_pct(pct_key)? else {
1689 return Ok(None);
1690 };
1691 Ok::<_, AppError>(Some(UsageWindow {
1692 utilization_pct: pct,
1693 resets_at: parse_reset(&v[reset_key], reset_key)?,
1694 window_duration: if weekly {
1695 chrono::Duration::days(7)
1696 } else {
1697 chrono::Duration::hours(5)
1698 },
1699 }))
1700 };
1701
1702 let snap = AntigravitySnapshot {
1703 plan: v["plan"].as_str().unwrap_or(DEFAULT_PLAN).to_string(),
1704 account: cached_account.unwrap_or_default().to_string(),
1705 source: v["source"]
1707 .as_str()
1708 .and_then(AntigravitySource::parse)
1709 .unwrap_or_default(),
1710 session: optional("session_pct", "session_reset", false)?,
1711 weekly: optional("weekly_pct", "weekly_reset", true)?,
1712 third_party_session: optional("tp_session_pct", "tp_session_reset", false)?,
1713 third_party_weekly: optional("tp_weekly_pct", "tp_weekly_reset", true)?,
1714 };
1715
1716 if snap.session.is_none()
1719 && snap.weekly.is_none()
1720 && snap.third_party_session.is_none()
1721 && snap.third_party_weekly.is_none()
1722 {
1723 return Err(AppError::Schema(
1724 "antigravity cache holds no usable window; refetching".into(),
1725 ));
1726 }
1727
1728 if let Some(window) = expired_window(&snap, now) {
1729 return Err(AppError::Schema(format!(
1730 "antigravity cache is past its {window} reset; refetching"
1731 )));
1732 }
1733 Ok(snap)
1734}
1735
1736pub fn snap_to_json(snap: &AntigravitySnapshot) -> serde_json::Value {
1737 serde_json::json!({
1738 "plan": snap.plan,
1739 "account": snap.account,
1740 "source": snap.source.as_str(),
1741 "session_pct": snap.session.as_ref().map(|w| w.utilization_pct),
1742 "session_reset": snap.session.as_ref().and_then(|w| w.resets_at.map(|dt| dt.to_rfc3339())),
1743 "weekly_pct": snap.weekly.as_ref().map(|w| w.utilization_pct),
1744 "weekly_reset": snap.weekly.as_ref().and_then(|w| w.resets_at.map(|dt| dt.to_rfc3339())),
1745 "tp_session_pct": snap.third_party_session.as_ref().map(|w| w.utilization_pct),
1746 "tp_session_reset": snap.third_party_session.as_ref().and_then(|w| w.resets_at.map(|dt| dt.to_rfc3339())),
1747 "tp_weekly_pct": snap.third_party_weekly.as_ref().map(|w| w.utilization_pct),
1748 "tp_weekly_reset": snap.third_party_weekly.as_ref().and_then(|w| w.resets_at.map(|dt| dt.to_rfc3339())),
1749 })
1750}
1751
1752#[cfg(test)]
1753mod tests {
1754 use super::*;
1755
1756 const QUOTA_JSON: &str = r#"{
1760 "response": {
1761 "groups": [
1762 {
1763 "displayName": "Gemini Models",
1764 "buckets": [
1765 {"bucketId": "gemini-weekly", "displayName": "Weekly Limit",
1766 "window": "weekly", "remainingFraction": 0.9191212,
1767 "resetTime": "2026-07-28T17:39:58Z"},
1768 {"bucketId": "gemini-5h", "displayName": "Five Hour Limit",
1769 "window": "5h", "remainingFraction": 0.5672253,
1770 "resetTime": "2026-07-22T17:47:00Z"}
1771 ]
1772 },
1773 {
1774 "displayName": "Claude and GPT models",
1775 "buckets": [
1776 {"bucketId": "3p-weekly", "window": "weekly",
1777 "remainingFraction": 1, "resetTime": "2026-07-29T12:47:00Z"},
1778 {"bucketId": "3p-5h", "window": "5h",
1779 "remainingFraction": 0.25, "resetTime": "2026-07-22T17:47:00Z"}
1780 ]
1781 }
1782 ]
1783 }
1784 }"#;
1785
1786 fn now() -> DateTime<Utc> {
1789 DateTime::parse_from_rfc3339("2026-07-22T12:00:00Z")
1790 .unwrap()
1791 .with_timezone(&Utc)
1792 }
1793
1794 fn parsed() -> AntigravitySnapshot {
1795 let v: serde_json::Value = serde_json::from_str(QUOTA_JSON).unwrap();
1796 parse_quota_summary(&v, "Google AI Pro".into()).unwrap()
1797 }
1798
1799 #[test]
1800 fn quota_summary_maps_four_distinct_windows() {
1801 let snap = parsed();
1802 assert_eq!(snap.plan, "Google AI Pro");
1803 assert_eq!(snap.session.as_ref().unwrap().utilization_pct, 43);
1805 assert_eq!(snap.weekly.as_ref().unwrap().utilization_pct, 8);
1806 assert_eq!(
1807 snap.third_party_session.as_ref().unwrap().utilization_pct,
1808 75
1809 );
1810 assert_eq!(snap.third_party_weekly.as_ref().unwrap().utilization_pct, 0);
1811 }
1812
1813 #[test]
1814 fn each_window_keeps_its_own_reset_time() {
1815 let snap = parsed();
1816 let at = |s: &str| Some(DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc));
1817 assert_eq!(
1818 snap.session.as_ref().unwrap().resets_at,
1819 at("2026-07-22T17:47:00Z")
1820 );
1821 assert_eq!(
1822 snap.weekly.as_ref().unwrap().resets_at,
1823 at("2026-07-28T17:39:58Z")
1824 );
1825 assert_eq!(
1826 snap.third_party_weekly.as_ref().unwrap().resets_at,
1827 at("2026-07-29T12:47:00Z")
1828 );
1829 assert_ne!(
1831 snap.session.as_ref().unwrap().resets_at,
1832 snap.weekly.as_ref().unwrap().resets_at
1833 );
1834 }
1835
1836 #[test]
1837 fn window_durations_match_their_bucket() {
1838 let snap = parsed();
1839 assert_eq!(
1840 snap.session.as_ref().unwrap().window_duration,
1841 chrono::Duration::hours(5)
1842 );
1843 assert_eq!(
1844 snap.weekly.as_ref().unwrap().window_duration,
1845 chrono::Duration::days(7)
1846 );
1847 assert_eq!(
1848 snap.third_party_weekly.as_ref().unwrap().window_duration,
1849 chrono::Duration::days(7)
1850 );
1851 }
1852
1853 #[test]
1854 fn groups_are_matched_by_display_name_when_bucket_ids_change() {
1855 let v: serde_json::Value = serde_json::from_str(
1856 r#"{"response":{"groups":[
1857 {"displayName":"Gemini Models","buckets":[
1858 {"bucketId":"x1","window":"5h","remainingFraction":0.5,"resetTime":"2026-07-22T17:47:00Z"},
1859 {"bucketId":"x2","window":"weekly","remainingFraction":0.9,"resetTime":"2026-07-28T17:39:58Z"}]},
1860 {"displayName":"Claude and GPT models","buckets":[
1861 {"bucketId":"y1","window":"5h","remainingFraction":0.0,"resetTime":"2026-07-22T17:47:00Z"}]}
1862 ]}}"#,
1863 )
1864 .unwrap();
1865 let snap = parse_quota_summary(&v, "Pro".into()).unwrap();
1866 assert_eq!(snap.session.as_ref().unwrap().utilization_pct, 50);
1867 assert_eq!(snap.weekly.as_ref().unwrap().utilization_pct, 10);
1868 assert_eq!(snap.third_party_session.unwrap().utilization_pct, 100);
1869 assert!(snap.third_party_weekly.is_none());
1870 }
1871
1872 #[test]
1873 fn duplicate_or_unclassified_buckets_cannot_overwrite_a_slot() {
1874 let duplicate: serde_json::Value = serde_json::from_str(
1875 r#"{"response":{"groups":[{"displayName":"Gemini Models","buckets":[
1876 {"bucketId":"gemini-5h","window":"5h","remainingFraction":0.9},
1877 {"bucketId":"gemini-5h-copy","window":"5h","remainingFraction":0.1},
1878 {"bucketId":"gemini-weekly","window":"weekly","remainingFraction":0.8}
1879 ]}]}}"#,
1880 )
1881 .unwrap();
1882 let err = parse_quota_summary(&duplicate, "Pro".into()).unwrap_err();
1883 assert!(err.to_string().contains("duplicate Gemini 5h"), "{err}");
1884
1885 let unrelated: serde_json::Value = serde_json::from_str(
1888 r#"{"response":{"groups":[
1889 {"displayName":"Gemini Models","buckets":[
1890 {"bucketId":"gemini-5h","window":"5h","remainingFraction":0.9},
1891 {"bucketId":"gemini-weekly","window":"weekly","remainingFraction":0.8},
1892 {"bucketId":"gemini-monthly","window":"monthly","remainingFraction":0.7}
1893 ]},
1894 {"displayName":"Future Models","buckets":[
1895 {"bucketId":"future-5h","window":"5h","remainingFraction":0.1}
1896 ]}
1897 ]}}"#,
1898 )
1899 .unwrap();
1900 let snap = parse_quota_summary(&unrelated, "Pro".into()).unwrap();
1901 assert!(snap.third_party_session.is_none());
1902 assert!(snap.third_party_weekly.is_none());
1903 }
1904
1905 #[test]
1908 fn a_bucket_without_a_usable_fraction_is_rejected() {
1909 for bad in [r#""oops""#, "null", "-0.01", "1.01"] {
1910 let v: serde_json::Value = serde_json::from_str(&format!(
1911 r#"{{"response":{{"groups":[{{"displayName":"Gemini Models","buckets":[
1912 {{"bucketId":"gemini-5h","window":"5h","remainingFraction":{bad}}},
1913 {{"bucketId":"gemini-weekly","window":"weekly","remainingFraction":0.9}}]}}]}}}}"#
1914 ))
1915 .unwrap();
1916 let err = parse_quota_summary(&v, "Pro".into()).unwrap_err();
1917 assert!(err.to_string().contains("gemini-5h"), "{bad}: {err}");
1918 }
1919 }
1920
1921 #[test]
1922 fn malformed_present_reset_is_rejected_instead_of_disabling_expiry() {
1923 for bad in [serde_json::json!("not-a-time"), serde_json::json!(42)] {
1924 let mut v: serde_json::Value = serde_json::from_str(QUOTA_JSON).unwrap();
1925 v["response"]["groups"][0]["buckets"][0]["resetTime"] = bad;
1926 let err = parse_quota_summary(&v, "Pro".into()).unwrap_err();
1927 assert!(err.to_string().contains("resetTime"), "{err}");
1928 }
1929 }
1930
1931 #[test]
1935 fn a_weekly_only_snapshot_round_trips_through_the_cache() {
1936 let mut snap = parsed();
1937 snap.session = None;
1938 snap.third_party_session = None;
1939
1940 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1941 let back = parse_cache_at(&bytes, None, now()).expect("weekly-only cache is usable");
1942
1943 assert!(back.session.is_none());
1944 assert_eq!(
1945 back.weekly.as_ref().unwrap().utilization_pct,
1946 snap.weekly.as_ref().unwrap().utilization_pct
1947 );
1948 }
1949
1950 #[test]
1955 fn a_product_reporting_only_weekly_buckets_still_renders_them() {
1956 let summary = serde_json::json!({
1957 "groups": [
1958 {
1959 "displayName": "Gemini Models",
1960 "buckets": [{
1961 "bucketId": "gemini-weekly", "window": "weekly",
1962 "remainingFraction": 0.42,
1963 }],
1964 },
1965 {
1966 "displayName": "Claude and GPT models",
1967 "buckets": [{
1968 "bucketId": "3p-weekly", "window": "weekly",
1969 "remainingFraction": 0.9,
1970 }],
1971 },
1972 ],
1973 });
1974
1975 let snap = parse_quota_summary(&summary, "Pro".into()).expect("weekly-only is usable");
1976
1977 assert!(snap.session.is_none(), "no 5h bucket arrived");
1978 assert!(snap.third_party_session.is_none());
1979 assert_eq!(snap.weekly.as_ref().unwrap().utilization_pct, 58);
1980 assert_eq!(
1981 snap.third_party_weekly.as_ref().unwrap().utilization_pct,
1982 10
1983 );
1984 }
1985
1986 #[test]
1989 fn a_product_reporting_only_five_hour_buckets_still_renders_them() {
1990 let summary = serde_json::json!({
1991 "groups": [{
1992 "displayName": "Gemini Models",
1993 "buckets": [{
1994 "bucketId": "gemini-5h", "window": "5h", "remainingFraction": 0.25,
1995 }],
1996 }],
1997 });
1998
1999 let snap = parse_quota_summary(&summary, "Pro".into()).expect("5h-only is usable");
2000
2001 assert_eq!(snap.session.as_ref().unwrap().utilization_pct, 75);
2002 assert!(snap.weekly.is_none());
2003 }
2004
2005 #[test]
2008 fn a_summary_with_no_recognisable_bucket_errors_and_names_what_it_had() {
2009 let summary = serde_json::json!({
2010 "groups": [{
2011 "displayName": "Gemini Models",
2012 "buckets": [{
2013 "bucketId": "gemini-daily", "window": "daily", "remainingFraction": 0.9,
2014 }],
2015 }],
2016 });
2017
2018 let rendered = parse_quota_summary(&summary, "Pro".into())
2019 .expect_err("an unrecognised cadence alone is not a snapshot")
2020 .to_string();
2021
2022 assert!(
2023 rendered.contains("no bucket in a window we recognise"),
2024 "{rendered}"
2025 );
2026 assert!(rendered.contains("gemini-daily"), "{rendered}");
2027 assert!(rendered.contains("window daily"), "{rendered}");
2028 }
2029
2030 #[test]
2033 fn a_summary_with_no_buckets_at_all_says_that_rather_than_listing_nothing() {
2034 let summary = serde_json::json!({
2035 "groups": [{"displayName": "Gemini", "buckets": []}],
2036 });
2037
2038 let rendered = parse_quota_summary(&summary, "Pro".into())
2039 .expect_err("no buckets is an error")
2040 .to_string();
2041
2042 assert!(rendered.contains("no buckets at all"), "{rendered}");
2043 assert!(!rendered.contains("it offered:"), "{rendered}");
2044 }
2045
2046 #[test]
2049 fn buckets_without_an_id_are_still_named_in_the_error() {
2050 let summary = serde_json::json!({
2051 "groups": [{"displayName": "", "buckets": [{"remainingFraction": 0.5}]}],
2052 });
2053
2054 let rendered = parse_quota_summary(&summary, "Pro".into())
2055 .expect_err("an unusable summary is an error")
2056 .to_string();
2057
2058 assert!(rendered.contains("<unnamed>"), "{rendered}");
2059 }
2060
2061 #[test]
2062 fn missing_gemini_buckets_is_an_error_not_a_zero_bar() {
2063 let v: serde_json::Value = serde_json::from_str(r#"{"response":{"groups":[]}}"#).unwrap();
2064 assert!(parse_quota_summary(&v, "Pro".into()).is_err());
2065 }
2066
2067 #[test]
2068 fn cache_round_trip_preserves_every_window() {
2069 let snap = parsed();
2070 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
2071 assert_eq!(parse_cache_at(&bytes, None, now()).unwrap(), snap);
2072 }
2073
2074 #[test]
2078 fn a_truncated_cached_payload_is_rejected_not_zeroed() {
2079 let full = snap_to_json(&parsed());
2080 for missing in ["session_pct", "weekly_pct"] {
2081 let mut v = full.clone();
2082 v.as_object_mut().unwrap().remove(missing);
2083 let bytes = serde_json::to_vec(&v).unwrap();
2084 let err = parse_cache_at(&bytes, None, now()).unwrap_err();
2085 assert!(err.to_string().contains(missing), "{missing}: {err}");
2086 }
2087 assert!(parse_cache_at(b"{}", None, now()).is_err());
2089 }
2090
2091 #[test]
2092 fn cached_percentages_are_range_checked_before_narrowing() {
2093 let full = snap_to_json(&parsed());
2094 for (key, bad) in [
2095 ("session_pct", serde_json::json!(-1)),
2096 ("weekly_pct", serde_json::json!(101)),
2097 ("session_pct", serde_json::json!(i64::MAX)),
2098 ("tp_session_pct", serde_json::json!("75")),
2099 ] {
2100 let mut v = full.clone();
2101 v[key] = bad;
2102 let bytes = serde_json::to_vec(&v).unwrap();
2103 let err = parse_cache_at(&bytes, None, now()).unwrap_err();
2104 assert!(err.to_string().contains(key), "{key}: {err}");
2105 }
2106 }
2107
2108 #[test]
2109 fn malformed_cached_reset_is_rejected_instead_of_served_for_a_week() {
2110 let mut v = snap_to_json(&parsed());
2111 v["session_reset"] = serde_json::json!("not-a-time");
2112 let bytes = serde_json::to_vec(&v).unwrap();
2113 let err = parse_cache_at(&bytes, None, now()).unwrap_err();
2114 assert!(err.to_string().contains("session_reset"), "{err}");
2115 }
2116
2117 #[test]
2122 fn a_cache_past_its_reset_is_refused() {
2123 let bytes = serde_json::to_vec(&snap_to_json(&parsed())).unwrap();
2124 let at = |s: &str| DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc);
2125
2126 assert!(parse_cache_at(&bytes, None, now()).is_ok());
2128 assert!(parse_cache_at(&bytes, None, at("2026-07-22T17:46:59Z")).is_ok());
2130
2131 let err = parse_cache_at(&bytes, None, at("2026-07-22T17:47:00Z")).unwrap_err();
2133 assert!(err.to_string().contains("5h"), "{err}");
2134
2135 assert!(parse_cache_at(&bytes, None, at("2026-07-23T09:00:00Z")).is_err());
2137 }
2138
2139 #[test]
2142 fn expiry_names_the_window_that_rolled_over() {
2143 let mut snap = parsed();
2144 snap.session.as_mut().unwrap().resets_at = None;
2146 snap.third_party_session = None;
2147 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
2148 let at = |s: &str| DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc);
2149
2150 assert!(parse_cache_at(&bytes, None, at("2026-07-23T09:00:00Z")).is_ok());
2152
2153 let err = parse_cache_at(&bytes, None, at("2026-07-28T18:00:00Z")).unwrap_err();
2155 assert!(err.to_string().contains("Gemini weekly"), "{err}");
2156 }
2157
2158 #[test]
2160 fn a_window_without_a_reset_never_expires() {
2161 let mut snap = parsed();
2162 for w in [&mut snap.session, &mut snap.weekly].into_iter().flatten() {
2163 w.resets_at = None;
2164 }
2165 snap.third_party_session = None;
2166 snap.third_party_weekly = None;
2167 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
2168 let far_future = DateTime::parse_from_rfc3339("2030-01-01T00:00:00Z")
2169 .unwrap()
2170 .with_timezone(&Utc);
2171 assert!(parse_cache_at(&bytes, None, far_future).is_ok());
2172 }
2173
2174 #[test]
2176 fn a_cache_from_another_account_is_rejected() {
2177 let mut snap = parsed();
2178 snap.account = "acct:aaaa".into();
2179 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
2180
2181 assert!(parse_cache_at(&bytes, Some("acct:bbbb"), now()).is_err());
2182 assert_eq!(
2183 parse_cache_at(&bytes, Some("acct:aaaa"), now()).unwrap(),
2184 snap
2185 );
2186
2187 let mut legacy = snap_to_json(&snap);
2189 legacy.as_object_mut().unwrap().remove("account");
2190 let legacy = serde_json::to_vec(&legacy).unwrap();
2191 assert!(parse_cache_at(&legacy, Some("acct:aaaa"), now()).is_err());
2192 }
2193
2194 #[test]
2197 fn an_unverifiable_cache_is_served_rather_than_discarded() {
2198 let mut snap = parsed();
2199 snap.account = "acct:aaaa".into();
2200 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
2201 assert_eq!(parse_cache_at(&bytes, None, now()).unwrap(), snap);
2202 }
2203
2204 #[test]
2205 fn account_key_fingerprints_rather_than_storing_the_address() {
2206 let with = |email: &str| account_key(&serde_json::json!({"userStatus": {"email": email}}));
2207 let a = with("someone@example.com");
2208 assert!(!a.contains("someone"), "{a}");
2209 assert!(!a.contains('@'), "{a}");
2210 assert_eq!(a, with("someone@example.com"), "must be stable");
2211 assert_ne!(a, with("other@example.com"));
2212 let unknown = account_key(&serde_json::json!({}));
2214 assert_eq!(unknown, account_key(&serde_json::json!({"userStatus": {}})));
2215 assert_ne!(unknown, a);
2216 }
2217
2218 #[test]
2221 fn absent_third_party_windows_are_not_treated_as_corruption() {
2222 let mut snap = parsed();
2223 snap.third_party_session = None;
2224 snap.third_party_weekly = None;
2225 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
2226 assert_eq!(parse_cache_at(&bytes, None, now()).unwrap(), snap);
2227 }
2228
2229 #[test]
2230 fn cache_round_trip_preserves_absent_third_party_windows() {
2231 let mut snap = parsed();
2232 snap.third_party_session = None;
2233 snap.third_party_weekly = None;
2234 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
2235 assert_eq!(parse_cache_at(&bytes, None, now()).unwrap(), snap);
2236 }
2237
2238 #[test]
2239 fn pct_used_inverts_valid_fractions() {
2240 assert_eq!(pct_used(1.0), 0);
2241 assert_eq!(pct_used(0.0), 100);
2242 assert_eq!(pct_used(0.5), 50);
2243 }
2244
2245 #[test]
2246 fn plan_falls_back_through_the_status_payload() {
2247 let tier: serde_json::Value =
2248 serde_json::from_str(r#"{"userStatus":{"userTier":{"name":"Google AI Pro"}}}"#)
2249 .unwrap();
2250 assert_eq!(plan_from_status(&tier), "Google AI Pro");
2251
2252 let plan_only: serde_json::Value = serde_json::from_str(
2253 r#"{"userStatus":{"planStatus":{"planInfo":{"planName":"Pro"}}}}"#,
2254 )
2255 .unwrap();
2256 assert_eq!(plan_from_status(&plan_only), "Pro");
2257
2258 let empty: serde_json::Value = serde_json::from_str("{}").unwrap();
2259 assert_eq!(plan_from_status(&empty), DEFAULT_PLAN);
2260 }
2261
2262 #[cfg(target_os = "linux")]
2263 #[test]
2264 fn proc_net_parser_keeps_only_listening_rows() {
2265 let listen = " 0: 0100007F:975B 00000000:0000 0A 00000000:00000000 \
2266 00:00000000 00000000 1000 0 123456 1 0000 100 0";
2267 assert_eq!(parse_proc_net_line(listen), Some((38747, 123456)));
2268
2269 let established = " 1: 0100007F:975B 0100007F:A1B2 01 00000000:00000000 \
2270 00:00000000 00000000 1000 0 123457 1 0000 100 0";
2271 assert_eq!(parse_proc_net_line(established), None);
2272
2273 assert_eq!(parse_proc_net_line("garbage"), None);
2274 }
2275
2276 #[test]
2277 fn explicit_address_comes_first_and_gets_a_scheme() {
2278 let candidate = |base: &str, pid| Candidate {
2279 base: base.to_string(),
2280 pid,
2281 ide: false,
2282 };
2283 assert_eq!(
2284 candidate_bases_with(Some("127.0.0.1:1234"), vec![(10, 5678)], &no_ide()),
2285 vec![
2286 candidate("http://127.0.0.1:1234", None),
2287 candidate("http://127.0.0.1:5678", Some(10)),
2288 ]
2289 );
2290 assert_eq!(
2292 candidate_bases_with(Some("127.0.0.1:1234/"), vec![(10, 5678)], &no_ide()),
2293 vec![
2294 candidate("http://127.0.0.1:1234", None),
2295 candidate("http://127.0.0.1:5678", Some(10)),
2296 ]
2297 );
2298 assert_eq!(
2301 candidate_bases_with(Some("127.0.0.1:5678"), vec![(10, 5678)], &no_ide()),
2302 vec![candidate("http://127.0.0.1:5678", Some(10))]
2303 );
2304 assert_eq!(
2306 candidate_bases_with(None, vec![(10, 5678), (10, 5678)], &no_ide()),
2307 vec![candidate("http://127.0.0.1:5678", Some(10))]
2308 );
2309 assert_eq!(
2311 candidate_bases_with(Some("https://host:9"), vec![], &no_ide()),
2312 vec![candidate("https://host:9", None)]
2313 );
2314 }
2315
2316 fn no_ide() -> std::collections::HashSet<u32> {
2319 std::collections::HashSet::new()
2320 }
2321
2322 fn http(status: u16) -> AppError {
2323 AppError::Http {
2324 status,
2325 body: String::new(),
2326 }
2327 }
2328
2329 fn missing_csrf() -> AppError {
2330 missing_csrf_with("missing CSRF token")
2331 }
2332
2333 fn missing_csrf_with(message: &str) -> AppError {
2334 AppError::Http {
2335 status: 401,
2336 body: serde_json::json!({
2337 "code": "unauthenticated",
2338 "message": message,
2339 })
2340 .to_string(),
2341 }
2342 }
2343
2344 #[test]
2345 fn only_agys_exact_missing_csrf_response_enables_remote_fallback() {
2346 assert!(is_missing_csrf(&missing_csrf()));
2347 assert!(is_missing_csrf(&missing_csrf_with(
2348 "unauthenticated: missing CSRF token"
2349 )));
2350 assert!(is_missing_csrf(&missing_csrf_with(
2351 " unauthenticated: missing CSRF token "
2352 )));
2353 assert!(!is_missing_csrf(&missing_csrf_with(
2354 "unauthenticated: invalid token"
2355 )));
2356 assert!(!is_missing_csrf(&AppError::Http {
2357 status: 403,
2358 body: r#"{"code":"unauthenticated","message":"missing CSRF token"}"#.into(),
2359 }));
2360 assert!(!is_missing_csrf(&AppError::Http {
2361 status: 401,
2362 body: r#"{"code":"other","message":"missing CSRF token"}"#.into(),
2363 }));
2364 assert!(!is_missing_csrf(&AppError::Http {
2365 status: 401,
2366 body: "prefix: missing CSRF token".into(),
2367 }));
2368 }
2369
2370 #[test]
2371 fn a_real_auth_failure_outranks_agys_missing_csrf_response() {
2372 for errors in [
2373 vec![missing_csrf(), http(403)],
2374 vec![http(401), missing_csrf()],
2375 ] {
2376 let err = select_probe_error(errors);
2377 assert!(is_actionable(&err), "{err}");
2378 assert!(!is_missing_csrf(&err), "{err}");
2379 }
2380 }
2381
2382 #[test]
2383 fn several_agy_sessions_still_select_the_remote_fallback_reason() {
2384 let err = select_probe_error(vec![missing_csrf(), missing_csrf()]);
2385 assert!(is_missing_csrf(&err), "{err}");
2386 assert!(remote_fallback_reason(&err).is_some(), "{err}");
2387 }
2388
2389 #[test]
2393 fn an_auth_failure_outranks_later_transport_noise() {
2394 let err = select_probe_error(vec![
2395 http(401),
2396 AppError::Transport("connection refused".into()),
2397 ]);
2398 assert!(matches!(err, AppError::Http { status: 401, .. }), "{err}");
2399
2400 let err = select_probe_error(vec![
2401 AppError::Transport("connection refused".into()),
2402 http(403),
2403 ]);
2404 assert!(matches!(err, AppError::Http { status: 403, .. }), "{err}");
2405 }
2406
2407 #[test]
2410 fn the_first_auth_failure_wins() {
2411 let err = select_probe_error(vec![http(401), http(403)]);
2412 assert!(matches!(err, AppError::Http { status: 401, .. }), "{err}");
2413 }
2414
2415 #[test]
2419 fn without_an_auth_failure_the_last_error_stands() {
2420 let err = select_probe_error(vec![
2421 AppError::Transport("first".into()),
2422 http(500),
2423 AppError::Transport("last".into()),
2424 ]);
2425 assert!(
2426 matches!(&err, AppError::Transport(m) if m == "last"),
2427 "{err}"
2428 );
2429 assert!(err.is_transient());
2430 }
2431
2432 #[test]
2435 fn a_server_error_is_not_treated_as_actionable() {
2436 let err = select_probe_error(vec![http(500), http(401)]);
2437 assert!(matches!(err, AppError::Http { status: 401, .. }), "{err}");
2438 }
2439
2440 #[test]
2441 fn no_candidates_at_all_yields_a_generic_error() {
2442 let err = select_probe_error(Vec::new());
2443 assert!(
2444 err.to_string().contains("no local server answered"),
2445 "{err}"
2446 );
2447 }
2448
2449 fn tls_echo() -> AppError {
2453 AppError::Http {
2454 status: 400,
2455 body: "Client sent an HTTP request to an HTTPS server.\n".into(),
2456 }
2457 }
2458
2459 #[test]
2464 fn a_tls_echo_does_not_mask_what_the_rpc_listener_said() {
2465 let err = select_probe_error(vec![
2466 AppError::Http {
2467 status: 500,
2468 body: "GetUserStatus: internal".into(),
2469 },
2470 tls_echo(),
2471 ]);
2472 assert!(
2473 matches!(&err, AppError::Http { status: 500, body } if body.contains("internal")),
2474 "{err}"
2475 );
2476 }
2477
2478 #[test]
2483 fn a_tls_echo_does_not_cost_the_silent_fallback() {
2484 let err = select_probe_error(vec![
2485 AppError::Transport("connection refused".into()),
2486 tls_echo(),
2487 ]);
2488 assert!(
2489 matches!(&err, AppError::Transport(m) if m == "connection refused"),
2490 "{err}"
2491 );
2492 assert!(
2493 err.is_transient(),
2494 "the echo must not make the run non-transient: {err}"
2495 );
2496 }
2497
2498 #[test]
2501 fn a_tls_echo_still_stands_when_it_is_the_only_thing_that_answered() {
2502 let err = select_probe_error(vec![tls_echo()]);
2503 assert!(matches!(err, AppError::Http { status: 400, .. }), "{err}");
2504 }
2505
2506 #[test]
2509 fn a_genuine_bad_request_is_not_mistaken_for_a_tls_echo() {
2510 let err = select_probe_error(vec![
2511 AppError::Http {
2512 status: 400,
2513 body: "unknown method GetUserStatus".into(),
2514 },
2515 tls_echo(),
2516 ]);
2517 assert!(
2518 matches!(&err, AppError::Http { status: 400, body } if body.contains("unknown method")),
2519 "{err}"
2520 );
2521 }
2522
2523 #[test]
2525 fn an_auth_failure_still_outranks_a_tls_echo() {
2526 let err = select_probe_error(vec![tls_echo(), http(401)]);
2527 assert!(matches!(err, AppError::Http { status: 401, .. }), "{err}");
2528 }
2529
2530 #[test]
2531 fn every_discovered_port_is_probed_in_order() {
2532 assert_eq!(
2533 candidate_bases_with(None, vec![(10, 33875), (10, 37435)], &no_ide()),
2534 vec![
2535 Candidate {
2536 base: "http://127.0.0.1:33875".to_string(),
2537 pid: Some(10),
2538 ide: false,
2539 },
2540 Candidate {
2541 base: "http://127.0.0.1:37435".to_string(),
2542 pid: Some(10),
2543 ide: false,
2544 },
2545 ]
2546 );
2547 }
2548
2549 #[test]
2554 fn empty_discovery_yields_no_candidates() {
2555 assert!(candidate_bases_with(None, vec![], &no_ide()).is_empty());
2556 assert!(candidate_bases_with(Some(""), vec![], &no_ide()).is_empty());
2557 }
2558
2559 #[test]
2560 fn every_antigravity_product_is_recognised() {
2561 assert!(is_antigravity_process(
2563 "language_server\n",
2564 Some("/opt/antigravity/resources/bin/language_server")
2565 ));
2566 assert!(is_antigravity_process(
2568 "agy\n",
2569 Some("/home/u/.local/bin/agy")
2570 ));
2571 assert!(is_antigravity_process(
2573 "node",
2574 Some("/opt/antigravity/bin/helper")
2575 ));
2576 assert!(is_antigravity_process("antigravity", None));
2577 assert!(is_antigravity_process("agy.exe", None));
2578 assert!(is_antigravity_process("Antigravity.exe", None));
2579 assert!(is_antigravity_process("language_server.exe", None));
2580 assert!(is_antigravity_process(
2581 "language_server_windows_x64.exe",
2582 None
2583 ));
2584 assert!(is_antigravity_process(
2585 "node.exe",
2586 Some(r"C:\Users\u\AppData\Local\agy.exe")
2587 ));
2588 }
2589
2590 #[test]
2591 fn unrelated_processes_are_not_probed() {
2592 assert!(!is_antigravity_process("sshd", Some("/usr/sbin/sshd")));
2593 assert!(!is_antigravity_process("node", Some("/usr/bin/node")));
2594 assert!(!is_antigravity_process("legacy", Some("/usr/bin/legacy")));
2596 assert!(!is_antigravity_process("legacy.exe", None));
2597 assert!(!is_antigravity_process("not-agy.exe", None));
2598 assert!(!is_antigravity_process("", None));
2599 }
2600
2601 #[test]
2602 fn windows_process_names_decode_until_nul_and_tolerate_invalid_utf16() {
2603 let mut raw: Vec<u16> = "agy.exe".encode_utf16().collect();
2604 raw.extend([0, b'x' as u16]);
2605 assert_eq!(decode_windows_process_name(&raw), "agy.exe");
2606 assert_eq!(decode_windows_process_name(&[0xd800]), "�");
2607 assert_eq!(decode_windows_process_name(&[]), "");
2608 }
2609
2610 #[test]
2611 fn windows_process_filter_keeps_only_antigravity_pids() {
2612 let processes = vec![
2613 (10, "agy.exe".to_string()),
2614 (20, "language_server_windows_x64.exe".to_string()),
2615 (30, "sshd.exe".to_string()),
2616 ];
2617 let pids = matching_windows_process_ids(&processes);
2618 assert_eq!(pids, std::collections::HashSet::from([10, 20]));
2619 }
2620
2621 #[test]
2622 fn windows_listener_filter_joins_pid_loopback_and_port() {
2623 let pids = std::collections::HashSet::from([10]);
2624 let rows = [
2625 WindowsTcpRow {
2626 local_addr: [127, 0, 0, 1],
2627 local_port: u32::from(59870u16.to_be()),
2628 pid: 10,
2629 },
2630 WindowsTcpRow {
2631 local_addr: [127, 0, 0, 1],
2632 local_port: u32::from(59868u16.to_be()),
2633 pid: 10,
2634 },
2635 WindowsTcpRow {
2636 local_addr: [127, 0, 0, 1],
2637 local_port: u32::from(59870u16.to_be()),
2638 pid: 10,
2639 },
2640 WindowsTcpRow {
2641 local_addr: [0, 0, 0, 0],
2642 local_port: u32::from(50000u16.to_be()),
2643 pid: 10,
2644 },
2645 WindowsTcpRow {
2646 local_addr: [127, 0, 0, 1],
2647 local_port: u32::from(50001u16.to_be()),
2648 pid: 99,
2649 },
2650 WindowsTcpRow {
2651 local_addr: [127, 0, 0, 1],
2652 local_port: 0,
2653 pid: 10,
2654 },
2655 ];
2656 assert_eq!(
2657 matching_windows_ports(&pids, &rows),
2658 vec![(10, 59870), (10, 59868)]
2659 );
2660 }
2661
2662 #[test]
2666 fn windows_ports_from_two_products_keep_tls_listeners_last() {
2667 let pids = std::collections::HashSet::from([10, 20]);
2668 let row = |port: u16, pid: u32| WindowsTcpRow {
2669 local_addr: [127, 0, 0, 1],
2670 local_port: u32::from(port.to_be()),
2671 pid,
2672 };
2673 let rows = [
2674 row(40000, 10),
2675 row(40001, 10),
2676 row(50000, 20),
2677 row(50001, 20),
2678 ];
2679 assert_eq!(
2680 matching_windows_ports(&pids, &rows),
2681 vec![(10, 40001), (20, 50001), (10, 40000), (20, 50000)]
2682 );
2683 }
2684
2685 #[test]
2689 fn probe_order_puts_every_rpc_listener_ahead_of_every_tls_listener() {
2690 use std::collections::BTreeMap;
2691
2692 assert_eq!(
2694 probe_order(BTreeMap::from([(10, vec![59868, 59870])])),
2695 vec![(10, 59870), (10, 59868)]
2696 );
2697 assert_eq!(
2702 probe_order(BTreeMap::from([
2703 (10, vec![40000, 40001]),
2704 (20, vec![50000, 50001]),
2705 ])),
2706 vec![(10, 40001), (20, 50001), (10, 40000), (20, 50000)]
2707 );
2708 assert_eq!(
2711 probe_order(BTreeMap::from([
2712 (10, vec![6000, 5000, 4000]),
2713 (20, vec![6000, 7000]),
2714 ])),
2715 vec![(10, 6000), (20, 7000), (10, 5000), (10, 4000)]
2716 );
2717 assert!(probe_order(BTreeMap::new()).is_empty());
2718 }
2719
2720 #[test]
2725 fn a_port_named_twice_by_one_product_still_occupies_one_rank() {
2726 use std::collections::BTreeMap;
2727
2728 assert_eq!(
2729 probe_order(BTreeMap::from([
2730 (10, vec![40001, 40001, 40000, 40000]),
2731 (20, vec![50001, 50000]),
2732 ])),
2733 vec![(10, 40001), (20, 50001), (10, 40000), (20, 50000)],
2734 "duplicate rows must not reorder the ranks below them"
2735 );
2736 }
2737
2738 #[test]
2743 fn a_half_started_product_is_the_documented_exception() {
2744 use std::collections::BTreeMap;
2745
2746 assert_eq!(
2747 probe_order(BTreeMap::from([
2748 (10, vec![40000]),
2749 (20, vec![50001, 50000])
2750 ])),
2751 vec![(10, 40000), (20, 50001), (20, 50000)]
2752 );
2753 }
2754
2755 #[test]
2760 fn an_override_with_no_authority_is_dropped_not_probed() {
2761 for junk in ["/", "///", "http://", "https://", " / "] {
2762 assert_eq!(
2763 candidate_bases_with(Some(junk), vec![(10, 4242)], &no_ide()),
2764 vec![Candidate {
2765 base: "http://127.0.0.1:4242".to_string(),
2766 pid: Some(10),
2767 ide: false,
2768 }],
2769 "{junk:?} should not survive as a candidate"
2770 );
2771 }
2772 assert!(candidate_bases_with(Some("/"), vec![], &no_ide()).is_empty());
2773 }
2774
2775 #[test]
2776 fn windows_table_bounds_reject_truncation_and_overflow() {
2777 assert_eq!(checked_windows_row_count(52, 4, 24, 2), Some(2));
2778 assert_eq!(checked_windows_row_count(51, 4, 24, 2), None);
2779 assert_eq!(checked_windows_row_count(4, 4, 24, 0), Some(0));
2780 assert_eq!(checked_windows_row_count(52, 4, 0, 2), None);
2781 assert_eq!(
2782 checked_windows_row_count(usize::MAX, 4, 24, usize::MAX),
2783 None
2784 );
2785 }
2786
2787 #[cfg(target_os = "windows")]
2788 #[test]
2789 fn windows_tcp_table_parser_copies_complete_rows_only() {
2790 use std::mem::{offset_of, size_of};
2791 use windows_sys::Win32::NetworkManagement::IpHelper::{
2792 MIB_TCPROW_OWNER_PID, MIB_TCPTABLE_OWNER_PID,
2793 };
2794
2795 let offset = offset_of!(MIB_TCPTABLE_OWNER_PID, table);
2796 let used = offset + 2 * size_of::<MIB_TCPROW_OWNER_PID>();
2797 let words = used.div_ceil(size_of::<u32>());
2798 let mut buffer = vec![0u32; words];
2799 let first = MIB_TCPROW_OWNER_PID {
2800 dwLocalAddr: u32::from_ne_bytes([127, 0, 0, 1]),
2801 dwLocalPort: u32::from(59868u16.to_be()),
2802 dwOwningPid: 10,
2803 ..Default::default()
2804 };
2805 let second = MIB_TCPROW_OWNER_PID {
2806 dwLocalAddr: u32::from_ne_bytes([127, 0, 0, 1]),
2807 dwLocalPort: u32::from(59870u16.to_be()),
2808 dwOwningPid: 10,
2809 ..Default::default()
2810 };
2811 unsafe {
2812 buffer.as_mut_ptr().write_unaligned(2);
2813 let rows = buffer
2814 .as_mut_ptr()
2815 .cast::<u8>()
2816 .add(offset)
2817 .cast::<MIB_TCPROW_OWNER_PID>();
2818 rows.write_unaligned(first);
2819 rows.add(1).write_unaligned(second);
2820 }
2821
2822 assert_eq!(
2823 parse_windows_tcp_rows(&buffer, used),
2824 vec![
2825 WindowsTcpRow {
2826 local_addr: [127, 0, 0, 1],
2827 local_port: u32::from(59868u16.to_be()),
2828 pid: 10,
2829 },
2830 WindowsTcpRow {
2831 local_addr: [127, 0, 0, 1],
2832 local_port: u32::from(59870u16.to_be()),
2833 pid: 10,
2834 },
2835 ]
2836 );
2837 assert!(parse_windows_tcp_rows(&buffer, used - 1).is_empty());
2838 }
2839
2840 #[test]
2841 fn lsof_parser_keeps_only_ports_owned_by_antigravity_processes() {
2842 let output = "p74101\ncagy\nf10\nn127.0.0.1:8829\nf11\nn127.0.0.1:61289\nf12\nn127.0.0.1:61290\np200\ncsshd\nf5\nn*:22\n";
2845 assert_eq!(
2846 parse_lsof_pcn(output),
2847 vec![(74101, 61290), (74101, 61289), (74101, 8829)]
2848 );
2849 }
2850
2851 #[test]
2855 fn lsof_parser_keeps_each_products_ports_in_its_own_group() {
2856 let output = concat!(
2857 "p100\ncagy\nf3\nn127.0.0.1:40000\nf4\nn127.0.0.1:40001\n",
2858 "p200\nclanguage_server\nf5\nn127.0.0.1:50000\nf6\nn127.0.0.1:50001\n",
2859 );
2860 assert_eq!(
2861 parse_lsof_pcn(output),
2862 vec![(100, 40001), (200, 50001), (100, 40000), (200, 50000)],
2863 "both HTTP listeners must precede both TLS listeners"
2864 );
2865 }
2866
2867 #[test]
2868 fn lsof_parser_matches_the_capitalised_macos_app_name() {
2869 let output = "p900\ncAntigravity\nf7\nn127.0.0.1:54321\n";
2870 assert_eq!(parse_lsof_pcn(output), vec![(900, 54321)]);
2871 }
2872
2873 #[test]
2874 fn lsof_parser_deduplicates_and_handles_empty_output() {
2875 let output = "p1\ncagy\nf3\nn127.0.0.1:9000\nf4\nn127.0.0.1:9000\n";
2876 assert_eq!(parse_lsof_pcn(output), vec![(1, 9000)]);
2877 assert!(parse_lsof_pcn("").is_empty());
2878 }
2879
2880 #[test]
2883 fn missing_cache_surfaces_the_diagnosis_not_a_cache_miss() {
2884 let dir = tempfile::tempdir().unwrap();
2885 let cache = Cache::at(dir.path().join("usage.json"));
2886 let reason = AppError::Credentials("Antigravity: no local language server found".into());
2887
2888 let err = fallback_with_error(&cache, None, reason, now()).unwrap_err();
2889 let msg = err.to_string();
2890 assert!(msg.contains("no local language server found"), "{msg}");
2891 assert!(!msg.contains("no usable cache"), "{msg}");
2892 }
2893
2894 #[test]
2895 fn unusable_cache_does_not_replace_the_live_diagnosis() {
2896 let dir = tempfile::tempdir().unwrap();
2897 let cache = Cache::at(dir.path().join("antigravity"));
2898 cache.write_payload(b"{}").unwrap();
2899
2900 let reason = AppError::Credentials("Antigravity must be running".into());
2901 let err = fallback_with_error(&cache, None, reason, now()).unwrap_err();
2902 assert!(err.to_string().contains("must be running"), "{err}");
2903
2904 let original = AppError::Transport("original loopback failure".into());
2905 let err = fallback_silent(&cache, now(), original).unwrap_err();
2906 assert!(
2907 err.to_string().contains("original loopback failure"),
2908 "{err}"
2909 );
2910 }
2911
2912 #[tokio::test]
2913 async fn rpc_error_bodies_are_bounded_too() {
2914 let mut server = mockito::Server::new_async().await;
2915 let path = format!("/{STATUS_RPC}");
2916 server
2917 .mock("POST", path.as_str())
2918 .with_status(500)
2919 .with_body("x".repeat(crate::vendor::MAX_BODY_BYTES + 1))
2920 .create_async()
2921 .await;
2922
2923 let err = post_rpc(&reqwest::Client::new(), &server.url(), None, STATUS_RPC)
2924 .await
2925 .unwrap_err();
2926 assert!(err.to_string().contains("exceeds"), "{err}");
2927 }
2928
2929 #[test]
2930 fn blank_override_falls_through_to_discovery() {
2931 assert_eq!(
2932 candidate_bases_with(Some(" "), vec![(10, 4242)], &no_ide()),
2933 vec![Candidate {
2934 base: "http://127.0.0.1:4242".to_string(),
2935 pid: Some(10),
2936 ide: false,
2937 }]
2938 );
2939 }
2940
2941 #[test]
2944 fn only_the_ide_install_is_recognised() {
2945 assert!(is_ide_install(
2946 "/opt/antigravity-ide/resources/app/extensions/antigravity/bin/language_server_linux_x64"
2947 ));
2948 assert!(is_ide_install(
2949 "/Applications/Antigravity IDE.app/Contents/Resources/app/extensions/antigravity/bin/language_server_darwin_arm64"
2950 ));
2951 assert!(is_ide_install(
2952 r"C:\Users\u\AppData\Local\Programs\Antigravity IDE\language_server_windows_x64.exe"
2953 ));
2954 assert!(!is_ide_install(
2955 "/opt/antigravity/resources/bin/language_server"
2956 ));
2957 assert!(!is_ide_install("/home/u/.local/bin/agy"));
2958 assert!(!is_ide_install("/usr/bin/node"));
2959 }
2960
2961 #[test]
2963 fn ps_output_pairs_pids_with_full_paths() {
2964 let output = " 101 /opt/antigravity-ide/bin/language_server_linux_x64\n\
2965 202 /Applications/Antigravity IDE.app/Contents/MacOS/Antigravity IDE\n";
2966 assert_eq!(
2967 parse_ps_comm(output),
2968 vec![
2969 (
2970 101,
2971 "/opt/antigravity-ide/bin/language_server_linux_x64".to_string()
2972 ),
2973 (
2974 202,
2975 "/Applications/Antigravity IDE.app/Contents/MacOS/Antigravity IDE".to_string()
2976 ),
2977 ]
2978 );
2979 assert!(parse_ps_comm("").is_empty());
2980 assert!(parse_ps_comm("not-a-pid /tmp/x").is_empty());
2981 }
2982
2983 #[test]
2987 fn ide_owned_listeners_are_marked_but_an_explicit_override_is_not() {
2988 let ide: std::collections::HashSet<u32> = [10].into_iter().collect();
2989
2990 let candidates = candidate_bases_with(None, vec![(10, 40001), (20, 50001)], &ide);
2991 assert_eq!(candidates.len(), 2);
2992 assert!(candidates[0].ide, "pid 10 is the IDE's language server");
2993 assert!(!candidates[1].ide);
2994
2995 let candidates = candidate_bases_with(Some("127.0.0.1:40001"), vec![(10, 40001)], &ide);
2996 assert_eq!(
2997 candidates,
2998 vec![Candidate {
2999 base: "http://127.0.0.1:40001".to_string(),
3000 pid: Some(10),
3001 ide: false,
3002 }],
3003 "ANTIGRAVITY_LS_ADDRESS is an explicit target, never skipped"
3004 );
3005 }
3006
3007 fn fixture() -> (tempfile::TempDir, Cache) {
3012 let td = tempfile::TempDir::new().unwrap();
3013 let cache = Cache::at(td.path().join("antigravity"));
3014 (td, cache)
3015 }
3016
3017 fn endpoints(server: &mockito::Server) -> cloud::Endpoints {
3018 let base = server.url();
3019 cloud::Endpoints {
3020 quota: vec![format!("{base}/daily/quota"), format!("{base}/prod/quota")],
3021 load_code_assist: vec![format!("{base}/daily/plan"), format!("{base}/prod/plan")],
3022 token: format!("{base}/token"),
3023 }
3024 }
3025
3026 fn keyring_blob(expiry: &str, with_refresh: bool) -> String {
3028 let mut token = serde_json::json!({
3029 "access_token": "KEYRING-AT",
3030 "expiry": expiry,
3031 });
3032 if with_refresh {
3033 token["refresh_token"] = serde_json::json!("KEYRING-RT");
3034 }
3035 serde_json::json!({ "token": token }).to_string()
3036 }
3037
3038 const EXPIRED: &str = "2026-07-22T11:00:00Z";
3040 const VALID: &str = "2026-07-22T13:00:00Z";
3042
3043 fn remote<'a>(blob: &'a str, eps: &'a cloud::Endpoints) -> RemoteOverride<'a> {
3045 RemoteOverride {
3046 credential: SavedCredential::Blob(blob),
3047 endpoints: Some(eps),
3048 local_bases: Some(vec![]),
3049 ..Default::default()
3050 }
3051 }
3052
3053 fn bare_summary() -> String {
3056 let v: serde_json::Value = serde_json::from_str(QUOTA_JSON).unwrap();
3057 v["response"].to_string()
3058 }
3059
3060 fn quota_mock(server: &mut mockito::Server, bearer: &str) -> mockito::Mock {
3061 server
3062 .mock("POST", "/daily/quota")
3063 .match_header("authorization", format!("Bearer {bearer}").as_str())
3064 .with_status(200)
3065 .with_body(bare_summary())
3066 }
3067
3068 fn token_mock(server: &mut mockito::Server) -> mockito::Mock {
3069 server
3070 .mock("POST", "/token")
3071 .with_status(200)
3072 .with_body(r#"{"access_token":"NEW-AT","expires_in":3600}"#)
3073 }
3074
3075 fn test_oauth() -> cloud::OauthClient {
3076 cloud::OauthClient {
3077 id: "test-client".into(),
3078 secret: "test-client-secret".into(),
3079 }
3080 }
3081
3082 async fn run(cache: &Cache, remote: RemoteOverride<'_>, ttl: Duration) -> Result<FetchOutcome> {
3083 fetch_snapshot_at(
3084 &reqwest::Client::new(),
3085 cache,
3086 ttl,
3087 Some(&test_oauth()),
3088 remote,
3089 now(),
3090 )
3091 .await
3092 }
3093
3094 #[tokio::test]
3095 async fn with_no_local_server_the_saved_session_answers_from_the_api() {
3096 let mut server = mockito::Server::new_async().await;
3097 let eps = endpoints(&server);
3098 let quota = quota_mock(&mut server, "KEYRING-AT")
3099 .expect(1)
3100 .create_async()
3101 .await;
3102 let plan = server
3103 .mock("POST", "/daily/plan")
3104 .match_header("authorization", "Bearer KEYRING-AT")
3105 .with_status(200)
3106 .with_body(r#"{"currentTier":{"name":"google_ai_pro"}}"#)
3107 .expect(1)
3108 .create_async()
3109 .await;
3110 let token = token_mock(&mut server).expect(0).create_async().await;
3112 let blob = keyring_blob(VALID, true);
3113 let (_td, cache) = fixture();
3114
3115 let outcome = run(&cache, remote(&blob, &eps), Duration::from_secs(60))
3116 .await
3117 .expect("remote path yields a snapshot");
3118
3119 quota.assert_async().await;
3120 plan.assert_async().await;
3121 token.assert_async().await;
3122 let snap = outcome.snapshot;
3123 assert!(!outcome.stale);
3124 assert_eq!(snap.source, AntigravitySource::Remote);
3125 assert_eq!(snap.plan, "Pro");
3126 assert_eq!(snap.session.as_ref().unwrap().utilization_pct, 43);
3127 assert_eq!(snap.weekly.as_ref().unwrap().utilization_pct, 8);
3128 assert_eq!(snap.third_party_session.unwrap().utilization_pct, 75);
3129 assert_eq!(snap.third_party_weekly.unwrap().utilization_pct, 0);
3130 let fingerprint = credential::parse_keyring_blob(&blob).unwrap().fingerprint;
3131 assert_eq!(snap.account, format!("acct:{fingerprint}"));
3132 assert!(!snap.account.contains("KEYRING"), "{}", snap.account);
3133
3134 let cached = parse_cache_at(
3136 &cache
3137 .fresh_payload(Duration::from_secs(60))
3138 .unwrap()
3139 .unwrap(),
3140 None,
3141 now(),
3142 )
3143 .unwrap();
3144 assert_eq!(cached.source, AntigravitySource::Remote);
3145 assert_eq!(cached.account, snap.account);
3146 }
3147
3148 #[tokio::test]
3153 async fn agys_missing_csrf_response_uses_the_saved_session() {
3154 let mut server = mockito::Server::new_async().await;
3155 let eps = endpoints(&server);
3156 let root = server
3157 .mock("GET", "/")
3158 .with_status(404)
3159 .expect(1)
3160 .create_async()
3161 .await;
3162 let status_path = format!("/{STATUS_RPC}");
3163 let status = server
3164 .mock("POST", status_path.as_str())
3165 .with_status(401)
3166 .with_body(r#"{"code":"unauthenticated","message":"missing CSRF token"}"#)
3167 .expect(1)
3168 .create_async()
3169 .await;
3170 let quota = quota_mock(&mut server, "KEYRING-AT")
3171 .expect(1)
3172 .create_async()
3173 .await;
3174 let plan = server
3175 .mock("POST", "/daily/plan")
3176 .match_header("authorization", "Bearer KEYRING-AT")
3177 .with_status(200)
3178 .with_body(r#"{"currentTier":{"name":"google_ai_pro"}}"#)
3179 .expect(1)
3180 .create_async()
3181 .await;
3182 let blob = keyring_blob(VALID, true);
3183 let (_td, cache) = fixture();
3184
3185 let outcome = run(
3186 &cache,
3187 RemoteOverride {
3188 credential: SavedCredential::Blob(&blob),
3189 endpoints: Some(&eps),
3190 local_bases: Some(vec![server.url().into()]),
3191 ..Default::default()
3192 },
3193 Duration::ZERO,
3194 )
3195 .await
3196 .expect("saved session bypasses agy's unusable local RPC");
3197
3198 root.assert_async().await;
3199 status.assert_async().await;
3200 quota.assert_async().await;
3201 plan.assert_async().await;
3202 assert_eq!(outcome.snapshot.source, AntigravitySource::Remote);
3203 }
3204
3205 #[tokio::test]
3209 async fn agys_prefixed_missing_csrf_response_uses_the_saved_session() {
3210 let mut server = mockito::Server::new_async().await;
3211 let eps = endpoints(&server);
3212 let root = server
3213 .mock("GET", "/")
3214 .with_status(404)
3215 .expect(1)
3216 .create_async()
3217 .await;
3218 let status_path = format!("/{STATUS_RPC}");
3219 let status = server
3220 .mock("POST", status_path.as_str())
3221 .with_status(401)
3222 .with_body(
3223 r#"{"code":"unauthenticated","message":"unauthenticated: missing CSRF token"}"#,
3224 )
3225 .expect(1)
3226 .create_async()
3227 .await;
3228 let quota = quota_mock(&mut server, "KEYRING-AT")
3229 .expect(1)
3230 .create_async()
3231 .await;
3232 let plan = server
3233 .mock("POST", "/daily/plan")
3234 .match_header("authorization", "Bearer KEYRING-AT")
3235 .with_status(200)
3236 .with_body(r#"{"currentTier":{"name":"google_ai_pro"}}"#)
3237 .expect(1)
3238 .create_async()
3239 .await;
3240 let blob = keyring_blob(VALID, true);
3241 let (_td, cache) = fixture();
3242
3243 let outcome = run(
3244 &cache,
3245 RemoteOverride {
3246 credential: SavedCredential::Blob(&blob),
3247 endpoints: Some(&eps),
3248 local_bases: Some(vec![server.url().into()]),
3249 ..Default::default()
3250 },
3251 Duration::ZERO,
3252 )
3253 .await
3254 .expect("saved session bypasses agy 1.3.1's prefixed missing-CSRF RPC");
3255
3256 root.assert_async().await;
3257 status.assert_async().await;
3258 quota.assert_async().await;
3259 plan.assert_async().await;
3260 assert_eq!(outcome.snapshot.source, AntigravitySource::Remote);
3261 }
3262
3263 #[tokio::test]
3264 async fn agys_missing_csrf_without_a_saved_session_explains_both_options() {
3265 let mut server = mockito::Server::new_async().await;
3266 let eps = endpoints(&server);
3267 let root = server
3268 .mock("GET", "/")
3269 .with_status(404)
3270 .expect(1)
3271 .create_async()
3272 .await;
3273 let status_path = format!("/{STATUS_RPC}");
3274 let status = server
3275 .mock("POST", status_path.as_str())
3276 .with_status(401)
3277 .with_body(r#"{"code":"unauthenticated","message":"missing CSRF token"}"#)
3278 .expect(1)
3279 .create_async()
3280 .await;
3281 let quota = quota_mock(&mut server, "KEYRING-AT")
3282 .expect(0)
3283 .create_async()
3284 .await;
3285 let (_td, cache) = fixture();
3286
3287 let error = run(
3288 &cache,
3289 RemoteOverride {
3290 credential: SavedCredential::Absent,
3291 endpoints: Some(&eps),
3292 local_bases: Some(vec![server.url().into()]),
3293 ..Default::default()
3294 },
3295 Duration::ZERO,
3296 )
3297 .await
3298 .expect_err("neither the local RPC nor a saved session is usable");
3299
3300 root.assert_async().await;
3301 status.assert_async().await;
3302 quota.assert_async().await;
3303 let message = error.to_string();
3304 assert!(message.contains("requires a CSRF token"), "{message}");
3305 assert!(message.contains("saved Google session"), "{message}");
3306 }
3307
3308 #[tokio::test]
3313 async fn agys_missing_csrf_aborts_further_candidate_probes_to_spare_tls_listeners() {
3314 let mut server1 = mockito::Server::new_async().await;
3315 let mut server2 = mockito::Server::new_async().await;
3316 let eps = endpoints(&server1);
3317 let root1 = server1
3318 .mock("GET", "/")
3319 .with_status(404)
3320 .expect(1)
3321 .create_async()
3322 .await;
3323 let status_path = format!("/{STATUS_RPC}");
3324 let status1 = server1
3325 .mock("POST", status_path.as_str())
3326 .with_status(401)
3327 .with_body(r#"{"code":"unauthenticated","message":"missing CSRF token"}"#)
3328 .expect(1)
3329 .create_async()
3330 .await;
3331
3332 let root2 = server2.mock("GET", "/").expect(0).create_async().await;
3334 let status2 = server2
3335 .mock("POST", status_path.as_str())
3336 .expect(0)
3337 .create_async()
3338 .await;
3339
3340 let (_td, cache) = fixture();
3341 let error = run(
3342 &cache,
3343 RemoteOverride {
3344 credential: SavedCredential::Absent,
3345 endpoints: Some(&eps),
3346 local_bases: Some(vec![
3347 Candidate {
3348 base: server1.url(),
3349 pid: Some(42),
3350 ide: false,
3351 },
3352 Candidate {
3353 base: server2.url(),
3354 pid: Some(42),
3355 ide: false,
3356 },
3357 ]),
3358 ..Default::default()
3359 },
3360 Duration::ZERO,
3361 )
3362 .await
3363 .expect_err("aborted probe returns agy missing csrf diagnosis");
3364
3365 root1.assert_async().await;
3366 status1.assert_async().await;
3367 root2.assert_async().await;
3368 status2.assert_async().await;
3369 let message = error.to_string();
3370 assert!(message.contains("requires a CSRF token"), "{message}");
3371 }
3372
3373 #[tokio::test]
3380 async fn agys_missing_csrf_does_not_skip_other_products_servers() {
3381 let mut agy = mockito::Server::new_async().await;
3382 let mut ide = mockito::Server::new_async().await;
3383 let eps = endpoints(&agy);
3384 let agy_root = agy
3385 .mock("GET", "/")
3386 .with_status(404)
3387 .expect(1)
3388 .create_async()
3389 .await;
3390 let status_path = format!("/{STATUS_RPC}");
3391 let agy_status = agy
3392 .mock("POST", status_path.as_str())
3393 .with_status(401)
3394 .with_body(r#"{"code":"unauthenticated","message":"missing CSRF token"}"#)
3395 .expect(1)
3396 .create_async()
3397 .await;
3398
3399 let ide_root = ide
3400 .mock("GET", "/")
3401 .with_status(404)
3402 .expect(1)
3403 .create_async()
3404 .await;
3405 let ide_status = ide
3406 .mock("POST", status_path.as_str())
3407 .with_status(200)
3408 .with_body(r#"{"userStatus":{"userTier":{"name":"Pro"},"email":"u@example.com"}}"#)
3409 .expect(1)
3410 .create_async()
3411 .await;
3412 let quota_path = format!("/{QUOTA_RPC}");
3413 let ide_quota = ide
3414 .mock("POST", quota_path.as_str())
3415 .with_status(200)
3416 .with_body(QUOTA_JSON)
3417 .expect(1)
3418 .create_async()
3419 .await;
3420
3421 let (_td, cache) = fixture();
3422 let outcome = run(
3423 &cache,
3424 RemoteOverride {
3425 credential: SavedCredential::Absent,
3426 endpoints: Some(&eps),
3427 local_bases: Some(vec![
3428 Candidate {
3429 base: agy.url(),
3430 pid: Some(42),
3431 ide: false,
3432 },
3433 Candidate {
3434 base: ide.url(),
3435 pid: Some(7),
3436 ide: false,
3437 },
3438 ]),
3439 ..Default::default()
3440 },
3441 Duration::ZERO,
3442 )
3443 .await
3444 .expect("the other product's server still answers");
3445
3446 agy_root.assert_async().await;
3447 agy_status.assert_async().await;
3448 ide_root.assert_async().await;
3449 ide_status.assert_async().await;
3450 ide_quota.assert_async().await;
3451 assert_eq!(outcome.snapshot.source, AntigravitySource::Local);
3452 assert_eq!(outcome.snapshot.plan, "Pro");
3453 }
3454
3455 #[tokio::test]
3458 async fn an_expired_keyring_token_is_refreshed_once_and_the_refresh_is_reused() {
3459 let mut server = mockito::Server::new_async().await;
3460 let eps = endpoints(&server);
3461 let _quota = quota_mock(&mut server, "NEW-AT")
3462 .expect(2)
3463 .create_async()
3464 .await;
3465 let token = token_mock(&mut server).expect(1).create_async().await;
3466 let blob = keyring_blob(EXPIRED, true);
3467 let (_td, cache) = fixture();
3468
3469 let first = run(&cache, remote(&blob, &eps), Duration::ZERO)
3470 .await
3471 .expect("refresh, then quota");
3472 assert_eq!(first.snapshot.source, AntigravitySource::Remote);
3473 token.assert_async().await;
3474
3475 let fingerprint = credential::parse_keyring_blob(&blob).unwrap().fingerprint;
3476 let persisted = cloud::read_persisted(&cloud::oauth_cache_path(&cache), &fingerprint)
3477 .expect("refreshed token persisted under the keyring session's fingerprint");
3478 assert_eq!(persisted.access_token, "NEW-AT");
3479
3480 run(&cache, remote(&blob, &eps), Duration::ZERO)
3482 .await
3483 .expect("persisted token reused");
3484 token.assert_async().await;
3485 }
3486
3487 #[tokio::test]
3490 async fn a_stale_token_the_api_rejects_gets_one_refresh_and_one_retry() {
3491 let mut server = mockito::Server::new_async().await;
3492 let eps = endpoints(&server);
3493 let rejected = server
3494 .mock("POST", "/daily/quota")
3495 .match_header("authorization", "Bearer KEYRING-AT")
3496 .with_status(401)
3497 .expect(1)
3498 .create_async()
3499 .await;
3500 let accepted = quota_mock(&mut server, "NEW-AT")
3501 .expect(1)
3502 .create_async()
3503 .await;
3504 let token = token_mock(&mut server).expect(1).create_async().await;
3505 let blob = keyring_blob(VALID, true);
3506 let (_td, cache) = fixture();
3507
3508 let outcome = run(&cache, remote(&blob, &eps), Duration::ZERO)
3509 .await
3510 .expect("retry with the refreshed token succeeds");
3511
3512 rejected.assert_async().await;
3513 accepted.assert_async().await;
3514 token.assert_async().await;
3515 assert_eq!(outcome.snapshot.source, AntigravitySource::Remote);
3516 }
3517
3518 #[tokio::test]
3521 async fn a_refused_refresh_is_a_credentials_error_without_the_token() {
3522 let mut server = mockito::Server::new_async().await;
3523 let eps = endpoints(&server);
3524 let _token = server
3525 .mock("POST", "/token")
3526 .with_status(400)
3527 .with_body(r#"{"error":"invalid_grant"}"#)
3528 .create_async()
3529 .await;
3530 let blob = keyring_blob(EXPIRED, true);
3531 let (_td, cache) = fixture();
3532
3533 let err = run(&cache, remote(&blob, &eps), Duration::ZERO)
3534 .await
3535 .expect_err("no cache to fall back on");
3536
3537 assert!(matches!(err, AppError::Credentials(_)), "{err}");
3538 let rendered = err.to_string();
3539 assert!(!rendered.contains("KEYRING-RT"), "{rendered}");
3540 assert!(!rendered.contains("KEYRING-AT"), "{rendered}");
3541 }
3542
3543 #[tokio::test]
3546 async fn a_rejection_right_after_a_refresh_asks_to_sign_in_again() {
3547 let mut server = mockito::Server::new_async().await;
3548 let eps = endpoints(&server);
3549 let _quota = server
3550 .mock("POST", "/daily/quota")
3551 .with_status(401)
3552 .create_async()
3553 .await;
3554 let token = token_mock(&mut server).expect(1).create_async().await;
3555 let blob = keyring_blob(EXPIRED, true);
3556 let (_td, cache) = fixture();
3557
3558 let err = run(&cache, remote(&blob, &eps), Duration::ZERO)
3559 .await
3560 .expect_err("rejected after refresh");
3561
3562 token.assert_async().await;
3563 assert!(matches!(err, AppError::Credentials(_)), "{err}");
3564 assert!(err.to_string().contains("sign in again"), "{err}");
3565 }
3566
3567 #[cfg(unix)]
3571 #[tokio::test]
3572 async fn a_subscription_403_surfaces_the_plan_message_without_a_refresh_or_agy_run() {
3573 let mut server = mockito::Server::new_async().await;
3574 let eps = endpoints(&server);
3575 let quota = server
3576 .mock("POST", "/daily/quota")
3577 .match_header("authorization", "Bearer KEYRING-AT")
3578 .with_status(403)
3579 .with_body(
3580 r#"{"error":{"code":403,"status":"PERMISSION_DENIED","details":
3581 [{"reason":"SUBSCRIPTION_REQUIRED","domain":"cloud.google.com"}]}}"#,
3582 )
3583 .expect(1)
3584 .create_async()
3585 .await;
3586 let prod = server
3587 .mock("POST", "/prod/quota")
3588 .expect(0)
3589 .create_async()
3590 .await;
3591 let token = token_mock(&mut server).expect(0).create_async().await;
3592 let (td, cache) = fixture();
3593 let file = td.path().join("credential");
3594 std::fs::write(&file, keyring_blob(VALID, true)).unwrap();
3595 let runs = td.path().join("runs");
3596 let agy = fake_agy(td.path(), &runs, &file, &keyring_blob(VALID, true));
3597
3598 let err = run_without_oauth(
3599 &cache,
3600 RemoteOverride {
3601 credential: SavedCredential::File(&file),
3602 endpoints: Some(&eps),
3603 local_bases: Some(vec![]),
3604 agy: super::super::agy::AgyCommand::At(agy),
3605 ..Default::default()
3606 },
3607 )
3608 .await
3609 .expect_err("a plan without quota is an error, not a snapshot");
3610
3611 quota.assert_async().await;
3612 prod.assert_async().await;
3613 token.assert_async().await;
3614 match err {
3615 AppError::Http { status, body } => {
3616 assert_eq!(status, 403);
3617 assert!(body.contains("paid subscription"), "{body}");
3618 }
3619 other => panic!("expected Http, got {other:?}"),
3620 }
3621 assert!(
3622 !runs.exists(),
3623 "a subscription verdict must not spawn `agy`"
3624 );
3625 }
3626
3627 #[tokio::test]
3631 async fn a_non_subscription_403_keeps_the_refresh_and_retry_behavior() {
3632 let mut server = mockito::Server::new_async().await;
3633 let eps = endpoints(&server);
3634 let rejected = server
3635 .mock("POST", "/daily/quota")
3636 .match_header("authorization", "Bearer KEYRING-AT")
3637 .with_status(403)
3638 .with_body(r#"{"error":{"message":"acl detail"}}"#)
3639 .expect(1)
3640 .create_async()
3641 .await;
3642 let retried = server
3643 .mock("POST", "/daily/quota")
3644 .match_header("authorization", "Bearer NEW-AT")
3645 .with_status(403)
3646 .with_body(r#"{"error":{"message":"acl detail"}}"#)
3647 .expect(1)
3648 .create_async()
3649 .await;
3650 let token = token_mock(&mut server).expect(1).create_async().await;
3651 let blob = keyring_blob(VALID, true);
3652 let (_td, cache) = fixture();
3653
3654 let err = run(&cache, remote(&blob, &eps), Duration::ZERO)
3655 .await
3656 .expect_err("still a rejected session");
3657
3658 rejected.assert_async().await;
3659 retried.assert_async().await;
3660 token.assert_async().await;
3661 assert!(matches!(err, AppError::Credentials(_)), "{err}");
3662 assert!(err.to_string().contains("sign in again"), "{err}");
3663 }
3664
3665 async fn run_without_oauth(cache: &Cache, remote: RemoteOverride<'_>) -> Result<FetchOutcome> {
3667 fetch_snapshot_at(
3668 &reqwest::Client::new(),
3669 cache,
3670 Duration::ZERO,
3671 None,
3672 remote,
3673 now(),
3674 )
3675 .await
3676 }
3677
3678 #[cfg(unix)]
3681 fn fake_agy(
3682 dir: &std::path::Path,
3683 runs: &std::path::Path,
3684 file: &std::path::Path,
3685 blob: &str,
3686 ) -> std::path::PathBuf {
3687 use std::os::unix::fs::PermissionsExt;
3688 let path = dir.join("agy");
3689 std::fs::write(
3690 &path,
3691 format!(
3692 "#!/bin/sh\necho \"$@\" >> '{}'\nprintf '%s' '{}' > '{}'\n",
3693 runs.display(),
3694 blob,
3695 file.display()
3696 ),
3697 )
3698 .unwrap();
3699 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
3700 path
3701 }
3702
3703 #[cfg(unix)]
3706 #[tokio::test]
3707 async fn an_expired_session_is_renewed_by_running_agy() {
3708 let mut server = mockito::Server::new_async().await;
3709 let eps = endpoints(&server);
3710 let quota = quota_mock(&mut server, "KEYRING-AT").create_async().await;
3711 let _plan = server
3712 .mock("POST", "/daily/plan")
3713 .with_status(200)
3714 .with_body(r#"{"currentTier":{"name":"Pro"}}"#)
3715 .create_async()
3716 .await;
3717 let (td, cache) = fixture();
3718 let file = td.path().join("credential");
3719 std::fs::write(&file, keyring_blob(EXPIRED, true)).unwrap();
3720 let runs = td.path().join("runs");
3721 let agy = fake_agy(td.path(), &runs, &file, &keyring_blob(VALID, true));
3722
3723 let out = run_without_oauth(
3724 &cache,
3725 RemoteOverride {
3726 credential: SavedCredential::File(&file),
3727 endpoints: Some(&eps),
3728 local_bases: Some(vec![]),
3729 agy: super::super::agy::AgyCommand::At(agy),
3730 ..Default::default()
3731 },
3732 )
3733 .await
3734 .expect("agy renewed the session");
3735
3736 quota.assert_async().await;
3737 assert_eq!(out.snapshot.source, AntigravitySource::Remote);
3738 assert_eq!(std::fs::read_to_string(runs).unwrap().trim(), "models");
3739 }
3740
3741 #[cfg(unix)]
3744 #[tokio::test]
3745 async fn agy_that_does_not_renew_keeps_the_original_error_and_runs_once() {
3746 let server = mockito::Server::new_async().await;
3747 let eps = endpoints(&server);
3748 let (td, cache) = fixture();
3749 let file = td.path().join("credential");
3750 std::fs::write(&file, keyring_blob(EXPIRED, true)).unwrap();
3751 let runs = td.path().join("runs");
3752 let agy = fake_agy(td.path(), &runs, &file, &keyring_blob(EXPIRED, true));
3753 let remote = || RemoteOverride {
3754 credential: SavedCredential::File(&file),
3755 endpoints: Some(&eps),
3756 local_bases: Some(vec![]),
3757 agy: super::super::agy::AgyCommand::At(agy.clone()),
3758 ..Default::default()
3759 };
3760
3761 for _ in 0..2 {
3762 let err = run_without_oauth(&cache, remote())
3763 .await
3764 .expect_err("still expired");
3765 assert!(err.to_string().contains("no OAuth client"), "{err}");
3766 }
3767 assert_eq!(std::fs::read_to_string(runs).unwrap().lines().count(), 1);
3768 }
3769
3770 #[tokio::test]
3773 async fn without_an_agy_command_an_expired_session_is_left_alone() {
3774 let server = mockito::Server::new_async().await;
3775 let eps = endpoints(&server);
3776 let blob = keyring_blob(EXPIRED, true);
3777 let (_td, cache) = fixture();
3778
3779 let err = run_without_oauth(&cache, remote(&blob, &eps))
3780 .await
3781 .expect_err("nothing renews it");
3782 assert!(err.to_string().contains("no OAuth client"), "{err}");
3783 }
3784
3785 #[tokio::test]
3787 async fn an_expired_session_without_a_refresh_token_asks_to_sign_in_again() {
3788 let mut server = mockito::Server::new_async().await;
3789 let eps = endpoints(&server);
3790 let token = token_mock(&mut server).expect(0).create_async().await;
3791 let blob = keyring_blob(EXPIRED, false);
3792 let (_td, cache) = fixture();
3793
3794 let err = run(&cache, remote(&blob, &eps), Duration::ZERO)
3795 .await
3796 .expect_err("nothing to refresh with");
3797
3798 token.assert_async().await;
3799 assert!(matches!(err, AppError::Credentials(_)), "{err}");
3800 assert!(err.to_string().contains("sign in again"), "{err}");
3801 }
3802
3803 #[tokio::test]
3806 async fn no_saved_session_extends_the_no_local_server_error() {
3807 let mut server = mockito::Server::new_async().await;
3808 let eps = endpoints(&server);
3809 let quota = quota_mock(&mut server, "KEYRING-AT")
3810 .expect(0)
3811 .create_async()
3812 .await;
3813 let (_td, cache) = fixture();
3814
3815 let err = run(
3816 &cache,
3817 RemoteOverride {
3818 credential: SavedCredential::Absent,
3819 endpoints: Some(&eps),
3820 local_bases: Some(vec![]),
3821 ..Default::default()
3822 },
3823 Duration::ZERO,
3824 )
3825 .await
3826 .expect_err("no source at all");
3827
3828 quota.assert_async().await;
3829 assert!(matches!(err, AppError::Credentials(_)), "{err}");
3830 let rendered = err.to_string();
3831 assert!(rendered.contains("no local server found"), "{rendered}");
3832 assert!(rendered.contains("saved Google session"), "{rendered}");
3833 }
3834
3835 #[tokio::test]
3838 async fn a_signed_out_local_server_is_reported_rather_than_bypassed() {
3839 let mut server = mockito::Server::new_async().await;
3840 let eps = endpoints(&server);
3841 let status_path = format!("/{STATUS_RPC}");
3842 let _status = server
3843 .mock("POST", status_path.as_str())
3844 .with_status(401)
3845 .create_async()
3846 .await;
3847 let quota = quota_mock(&mut server, "KEYRING-AT")
3848 .expect(0)
3849 .create_async()
3850 .await;
3851 let blob = keyring_blob(VALID, true);
3852 let (_td, cache) = fixture();
3853
3854 let err = run(
3855 &cache,
3856 RemoteOverride {
3857 credential: SavedCredential::Blob(&blob),
3858 endpoints: Some(&eps),
3859 local_bases: Some(vec![server.url().into()]),
3860 ..Default::default()
3861 },
3862 Duration::ZERO,
3863 )
3864 .await
3865 .expect_err("signed-out local server");
3866
3867 quota.assert_async().await;
3868 assert!(matches!(err, AppError::Http { status: 401, .. }), "{err}");
3869 }
3870
3871 #[tokio::test]
3877 async fn a_signed_out_ide_language_server_is_skipped_so_the_saved_session_answers() {
3878 let mut ide = mockito::Server::new_async().await;
3879 let mut agy = mockito::Server::new_async().await;
3880 let eps = endpoints(&agy);
3881 let ide_root = ide.mock("GET", "/").expect(0).create_async().await;
3883 let status_path = format!("/{STATUS_RPC}");
3884 let ide_status = ide
3885 .mock("POST", status_path.as_str())
3886 .with_status(403)
3887 .expect(0)
3888 .create_async()
3889 .await;
3890 let agy_root = agy
3892 .mock("GET", "/")
3893 .with_status(404)
3894 .expect(1)
3895 .create_async()
3896 .await;
3897 let agy_status = agy
3898 .mock("POST", status_path.as_str())
3899 .with_status(401)
3900 .with_body(r#"{"code":"unauthenticated","message":"missing CSRF token"}"#)
3901 .expect(1)
3902 .create_async()
3903 .await;
3904 let quota = quota_mock(&mut agy, "KEYRING-AT")
3905 .expect(1)
3906 .create_async()
3907 .await;
3908 let plan = agy
3909 .mock("POST", "/daily/plan")
3910 .match_header("authorization", "Bearer KEYRING-AT")
3911 .with_status(200)
3912 .with_body(r#"{"currentTier":{"name":"google_ai_pro"}}"#)
3913 .expect(1)
3914 .create_async()
3915 .await;
3916 let blob = keyring_blob(VALID, true);
3917 let (_td, cache) = fixture();
3918
3919 let outcome = run(
3920 &cache,
3921 RemoteOverride {
3922 credential: SavedCredential::Blob(&blob),
3923 endpoints: Some(&eps),
3924 local_bases: Some(vec![
3925 Candidate {
3926 base: ide.url(),
3927 pid: Some(7),
3928 ide: true,
3929 },
3930 Candidate {
3931 base: agy.url(),
3932 pid: Some(42),
3933 ide: false,
3934 },
3935 ]),
3936 ide_language_server: false,
3937 ..Default::default()
3938 },
3939 Duration::ZERO,
3940 )
3941 .await
3942 .expect("with the IDE skipped, agy's missing-CSRF answer unlocks the saved session");
3943
3944 ide_root.assert_async().await;
3945 ide_status.assert_async().await;
3946 agy_root.assert_async().await;
3947 agy_status.assert_async().await;
3948 quota.assert_async().await;
3949 plan.assert_async().await;
3950 assert_eq!(outcome.snapshot.source, AntigravitySource::Remote);
3951 assert_eq!(outcome.snapshot.plan, "Pro");
3952 }
3953
3954 #[tokio::test]
3958 async fn skipping_the_only_ide_candidate_falls_through_to_the_saved_session() {
3959 let mut ide = mockito::Server::new_async().await;
3960 let eps = endpoints(&ide);
3961 let ide_root = ide.mock("GET", "/").expect(0).create_async().await;
3962 let status_path = format!("/{STATUS_RPC}");
3963 let ide_status = ide
3964 .mock("POST", status_path.as_str())
3965 .expect(0)
3966 .create_async()
3967 .await;
3968 let quota = quota_mock(&mut ide, "KEYRING-AT")
3969 .expect(1)
3970 .create_async()
3971 .await;
3972 let plan = ide
3973 .mock("POST", "/daily/plan")
3974 .with_status(200)
3975 .with_body(r#"{"currentTier":{"name":"Pro"}}"#)
3976 .expect(1)
3977 .create_async()
3978 .await;
3979 let blob = keyring_blob(VALID, true);
3980 let (_td, cache) = fixture();
3981
3982 let outcome = run(
3983 &cache,
3984 RemoteOverride {
3985 credential: SavedCredential::Blob(&blob),
3986 endpoints: Some(&eps),
3987 local_bases: Some(vec![Candidate {
3988 base: ide.url(),
3989 pid: Some(7),
3990 ide: true,
3991 }]),
3992 ide_language_server: false,
3993 ..Default::default()
3994 },
3995 Duration::ZERO,
3996 )
3997 .await
3998 .expect("an IDE-only machine with the IDE skipped uses the saved session");
3999
4000 ide_root.assert_async().await;
4001 ide_status.assert_async().await;
4002 quota.assert_async().await;
4003 plan.assert_async().await;
4004 assert_eq!(outcome.snapshot.source, AntigravitySource::Remote);
4005 }
4006
4007 #[tokio::test]
4010 async fn a_cached_remote_snapshot_is_served_when_the_api_fails() {
4011 let mut server = mockito::Server::new_async().await;
4012 let eps = endpoints(&server);
4013 for path in ["/daily/quota", "/prod/quota"] {
4014 server
4015 .mock("POST", path)
4016 .with_status(500)
4017 .with_body("boom")
4018 .create_async()
4019 .await;
4020 }
4021 let blob = keyring_blob(VALID, true);
4022 let (_td, cache) = fixture();
4023 cache.ensure_dir().unwrap();
4024 let mut earlier = parsed();
4025 earlier.source = AntigravitySource::Remote;
4026 earlier.account = "acct:earlier".into();
4027 cache
4028 .write_payload(&serde_json::to_vec(&snap_to_json(&earlier)).unwrap())
4029 .unwrap();
4030
4031 let outcome = run(&cache, remote(&blob, &eps), Duration::ZERO)
4032 .await
4033 .expect("stale cache stands in");
4034
4035 assert!(outcome.stale);
4036 assert_eq!(outcome.snapshot, earlier);
4037 assert!(
4038 matches!(outcome.last_error, Some((500, _))),
4039 "{:?}",
4040 outcome.last_error
4041 );
4042 }
4043
4044 #[test]
4045 fn source_round_trips_through_the_cache_and_defaults_to_local() {
4046 let mut snap = parsed();
4047 snap.source = AntigravitySource::Remote;
4048 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
4049 assert_eq!(
4050 parse_cache_at(&bytes, None, now()).unwrap().source,
4051 AntigravitySource::Remote
4052 );
4053
4054 snap.source = AntigravitySource::Statusline;
4055 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
4056 assert_eq!(
4057 parse_cache_at(&bytes, None, now()).unwrap().source,
4058 AntigravitySource::Statusline
4059 );
4060
4061 let mut legacy = snap_to_json(&snap);
4063 legacy.as_object_mut().unwrap().remove("source");
4064 let legacy = serde_json::to_vec(&legacy).unwrap();
4065 assert_eq!(
4066 parse_cache_at(&legacy, None, now()).unwrap().source,
4067 AntigravitySource::Local
4068 );
4069 }
4070}