1use std::time::Duration;
48
49use chrono::{DateTime, Utc};
50
51use super::cloud;
52use super::credential::{self, StoredToken};
53use crate::cache::{Cache, acquire_lock_async};
54use crate::error::{AppError, Result};
55use crate::usage::{AntigravitySnapshot, AntigravitySource, ModelPoolWindow, UsageWindow};
56
57const HTTP_TIMEOUT: Duration = Duration::from_secs(5);
58const LOCK_TIMEOUT: Duration = Duration::from_secs(15);
59
60const QUOTA_RPC: &str = "exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary";
61const STATUS_RPC: &str = "exa.language_server_pb.LanguageServerService/GetUserStatus";
62
63const DEFAULT_PLAN: &str = "Antigravity";
64
65const NO_LOCAL_SERVER: &str = "Antigravity: no local server found. Quota is only served while \
66 Antigravity is running — open the Antigravity app, or an interactive \
67 `agy` session, or point ANTIGRAVITY_LS_ADDRESS at a host:port.";
68
69const AGY_CSRF_UNAVAILABLE: &str = "Antigravity: the running `agy` server requires a CSRF token \
70 that it does not publish.";
71
72const NO_SAVED_SESSION: &str = "Or sign in to Antigravity once, so its saved Google session can \
76 be used while it is closed.";
77
78const SESSION_EXPIRED: &str =
79 "Antigravity's saved Google session expired; open Antigravity to sign in again";
80
81pub type FetchOutcome = crate::outcome::Outcome<AntigravitySnapshot>;
84
85impl From<FetchOutcome> for crate::vendor::VendorOutcome {
86 fn from(o: FetchOutcome) -> Self {
87 o.map(crate::usage::VendorSnapshot::Antigravity)
88 }
89}
90
91#[derive(Debug, Clone, Copy, Default)]
95pub enum SavedCredential<'a> {
96 #[default]
98 Keyring,
99 Blob(&'a str),
101 File(&'a std::path::Path),
104 Absent,
106}
107
108pub struct RemoteOverride<'a> {
113 pub credential: SavedCredential<'a>,
115 pub endpoints: Option<&'a cloud::Endpoints>,
117 pub local_bases: Option<Vec<Candidate>>,
122 pub agy: super::agy::AgyCommand,
126 pub ide_language_server: bool,
130}
131
132impl Default for RemoteOverride<'_> {
133 fn default() -> Self {
134 Self {
135 credential: SavedCredential::default(),
136 endpoints: None,
137 local_bases: None,
138 agy: super::agy::AgyCommand::default(),
139 ide_language_server: true,
140 }
141 }
142}
143
144#[derive(Debug, Clone, PartialEq, Eq)]
149pub struct Candidate {
150 pub base: String,
152 pub pid: Option<u32>,
155 pub ide: bool,
162}
163
164impl From<String> for Candidate {
165 fn from(base: String) -> Self {
166 Self {
167 base,
168 pid: None,
169 ide: false,
170 }
171 }
172}
173
174pub async fn fetch_snapshot(
177 client: &reqwest::Client,
178 cache: &Cache,
179 cache_ttl: Duration,
180 oauth: Option<&cloud::OauthClient>,
181 agy_binary: Option<&std::path::Path>,
182 ide_language_server: bool,
183) -> Result<FetchOutcome> {
184 fetch_snapshot_at(
185 client,
186 cache,
187 cache_ttl,
188 oauth,
189 RemoteOverride {
190 agy: agy_binary.map_or(super::agy::AgyCommand::Discover, |path| {
191 super::agy::AgyCommand::At(path.to_path_buf())
192 }),
193 ide_language_server,
194 ..Default::default()
195 },
196 Utc::now(),
197 )
198 .await
199}
200
201pub async fn fetch_snapshot_at(
205 client: &reqwest::Client,
206 cache: &Cache,
207 cache_ttl: Duration,
208 oauth: Option<&cloud::OauthClient>,
209 remote: RemoteOverride<'_>,
210 now: DateTime<Utc>,
211) -> Result<FetchOutcome> {
212 cache.ensure_dir()?;
213 let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
214
215 let origin = match open_session(
225 client,
226 remote.local_bases.as_deref(),
227 remote.ide_language_server,
228 )
229 .await
230 {
231 Ok(session) => Origin::Local(Ok(session)),
232 Err(error) => match remote_fallback_reason(&error) {
233 Some(reason) => Origin::Remote(saved_session(remote.credential, reason)),
234 None => Origin::Local(Err(error)),
235 },
236 };
237 let account = origin.account();
238
239 let cache_ttl = match origin {
242 Origin::Remote(Ok(_)) if remote.agy.is_available() => {
243 cache_ttl.max(super::agy::QUOTA_INTERVAL)
244 }
245 _ => cache_ttl,
246 };
247 if let Some(bytes) = cache.fresh_payload(cache_ttl)?
248 && let Ok(outcome) = reuse_cache(bytes, cache, false, account.as_deref(), now)
249 {
250 return Ok(outcome);
251 }
252
253 let default_endpoints = cloud::Endpoints::default();
254 let endpoints = remote.endpoints.unwrap_or(&default_endpoints);
255 let live = match origin {
256 Origin::Local(session) => fetch_live(client, session).await,
257 Origin::Remote(token) => {
258 let renewal = Renewal {
259 agy: &remote.agy,
260 credential: remote.credential,
261 };
262 match fetch_remote(client, cache, oauth, endpoints, token, renewal, now).await {
263 Ok(RemoteReading::Live(snap)) => Ok(snap),
264 Ok(RemoteReading::Kept(outcome)) => return Ok(outcome),
265 Err(e) => Err(e),
266 }
267 }
268 };
269
270 match live {
271 Ok(snap) => {
272 let bytes = serde_json::to_vec(&snap_to_json(&snap))?;
273 cache.write_payload(&bytes)?;
274 Ok(crate::outcome::Outcome::fresh(snap))
275 }
276 Err(e) if e.is_transient() => fallback_silent(cache, now, e),
277 Err(AppError::Http { status, body }) => {
278 cache.mark_stale();
279 let last_error = Some(cache.write_last_error(status, &body));
280 let reason = AppError::Http { status, body };
281 fallback_with_error(cache, last_error, reason, now)
282 }
283 Err(e) => {
284 cache.mark_stale();
285 let last_error = Some(cache.write_last_error(0, &e.to_string()));
286 fallback_with_error(cache, last_error, e, now)
287 }
288 }
289}
290
291struct Session {
294 base: String,
295 csrf: Option<String>,
296 plan: String,
297 account: String,
298}
299
300enum Origin {
303 Local(Result<Session>),
304 Remote(Result<StoredToken>),
305}
306
307impl Origin {
308 fn account(&self) -> Option<String> {
310 match self {
311 Origin::Local(Ok(session)) => Some(session.account.clone()),
312 Origin::Remote(Ok(token)) => Some(remote_account(&token.fingerprint)),
313 Origin::Local(Err(_)) | Origin::Remote(Err(_)) => None,
314 }
315 }
316}
317
318fn no_local_server() -> AppError {
319 AppError::Credentials(NO_LOCAL_SERVER.into())
320}
321
322#[derive(Clone, Copy)]
323enum RemoteFallbackReason {
324 NoLocalServer,
325 AgyMissingCsrf,
326}
327
328impl RemoteFallbackReason {
329 fn message(self) -> &'static str {
330 match self {
331 Self::NoLocalServer => NO_LOCAL_SERVER,
332 Self::AgyMissingCsrf => AGY_CSRF_UNAVAILABLE,
333 }
334 }
335}
336
337fn remote_fallback_reason(error: &AppError) -> Option<RemoteFallbackReason> {
343 if matches!(error, AppError::Credentials(message) if message == NO_LOCAL_SERVER) {
344 Some(RemoteFallbackReason::NoLocalServer)
345 } else if is_missing_csrf(error) {
346 Some(RemoteFallbackReason::AgyMissingCsrf)
347 } else {
348 None
349 }
350}
351
352async fn open_session(
363 client: &reqwest::Client,
364 bases: Option<&[Candidate]>,
365 ide_language_server: bool,
366) -> Result<Session> {
367 let candidates = bases.map_or_else(
368 || candidate_bases(ide_language_server),
369 <[Candidate]>::to_vec,
370 );
371 let candidates: Vec<Candidate> = candidates.into_iter().filter(|c| !c.ide).collect();
372 if candidates.is_empty() {
373 return Err(no_local_server());
374 }
375
376 let mut errors = Vec::new();
377 let mut blocked_pids = std::collections::HashSet::new();
382 for candidate in candidates {
383 if candidate.pid.is_some_and(|pid| blocked_pids.contains(&pid)) {
384 continue;
385 }
386 let csrf = fetch_csrf(client, &candidate.base).await;
387 match post_rpc(client, &candidate.base, csrf.as_deref(), STATUS_RPC).await {
388 Ok(v) => {
389 return Ok(Session {
390 base: candidate.base,
391 csrf,
392 plan: plan_from_status(&v),
393 account: account_key(&v),
394 });
395 }
396 Err(e) => {
397 if is_missing_csrf(&e)
401 && let Some(pid) = candidate.pid
402 {
403 blocked_pids.insert(pid);
404 }
405 errors.push(e);
406 }
407 }
408 }
409 Err(select_probe_error(errors))
410}
411
412fn select_probe_error(errors: Vec<AppError>) -> AppError {
429 let mut actionable = None;
430 let mut missing_csrf = None;
431 let mut last = None;
432 let mut echo = None;
433 for e in errors {
434 if missing_csrf.is_none() && is_missing_csrf(&e) {
435 missing_csrf = Some(e);
436 } else if actionable.is_none() && is_actionable(&e) {
437 actionable = Some(e);
438 } else if is_tls_echo(&e) {
439 echo = Some(e);
440 } else {
441 last = Some(e);
442 }
443 }
444 actionable
445 .or(missing_csrf)
446 .or(last)
447 .or(echo)
448 .unwrap_or_else(|| {
449 AppError::Other("antigravity: no local server answered GetUserStatus".into())
450 })
451}
452
453fn is_missing_csrf(error: &AppError) -> bool {
459 let AppError::Http { status: 401, body } = error else {
460 return false;
461 };
462 let Ok(body) = serde_json::from_str::<serde_json::Value>(body) else {
463 return false;
464 };
465 matches!(
466 (body["code"].as_str(), body["message"].as_str()),
467 (Some(code), Some(message))
468 if code.eq_ignore_ascii_case("unauthenticated")
469 && is_missing_csrf_message(message)
470 )
471}
472
473fn is_missing_csrf_message(message: &str) -> bool {
476 message
477 .rsplit(':')
478 .next()
479 .is_some_and(|part| part.trim().eq_ignore_ascii_case("missing CSRF token"))
480}
481
482fn is_actionable(e: &AppError) -> bool {
487 matches!(e, AppError::Http { status, .. } if *status == 401 || *status == 403)
488 && !is_missing_csrf(e)
489}
490
491fn is_tls_echo(e: &AppError) -> bool {
504 matches!(
505 e,
506 AppError::Http { status: 400, body } if body.contains("HTTP request to an HTTPS server")
507 )
508}
509
510async fn fetch_live(
511 client: &reqwest::Client,
512 session: Result<Session>,
513) -> Result<AntigravitySnapshot> {
514 let session = session?;
515 let quota = post_rpc(client, &session.base, session.csrf.as_deref(), QUOTA_RPC).await?;
516 let mut snap = parse_quota_summary("a, session.plan)?;
517 snap.account = session.account;
518 Ok(snap)
519}
520
521fn saved_session(
531 credential: SavedCredential<'_>,
532 reason: RemoteFallbackReason,
533) -> Result<StoredToken> {
534 let raw = match credential {
535 SavedCredential::Keyring => credential::read()?,
536 SavedCredential::Blob(blob) => Some(blob.to_string()),
537 SavedCredential::File(path) => std::fs::read_to_string(path).ok(),
538 SavedCredential::Absent => None,
539 };
540 let Some(raw) = raw else {
541 return Err(AppError::Credentials(format!(
542 "{} {NO_SAVED_SESSION}",
543 reason.message()
544 )));
545 };
546 credential::parse_keyring_blob(&raw)
547}
548
549fn remote_account(fingerprint: &str) -> String {
553 format!("acct:{fingerprint}")
554}
555
556fn session_expired() -> AppError {
557 AppError::Credentials(SESSION_EXPIRED.into())
558}
559
560fn refresh_unconfigured() -> AppError {
564 AppError::Credentials(
565 "Antigravity's saved Google session expired and ai-usagebar has no OAuth client to \
566 refresh it; open Antigravity (or run `agy`) to sign in again, or set [antigravity] oauth_client_id \
567 and oauth_client_secret in config.toml"
568 .into(),
569 )
570}
571
572fn is_auth_rejection(e: &AppError) -> bool {
573 matches!(
574 e,
575 AppError::Http {
576 status: 401 | 403,
577 ..
578 }
579 )
580}
581
582struct AccessToken {
586 value: String,
587 just_refreshed: bool,
588}
589
590fn current_access_token(
595 oauth_path: &std::path::Path,
596 token: &StoredToken,
597 now: DateTime<Utc>,
598) -> Option<String> {
599 if let Some(persisted) = cloud::read_persisted(oauth_path, &token.fingerprint)
600 && token
601 .expires_at
602 .is_none_or(|keyring| persisted.expires_at > keyring)
603 && !cloud::needs_refresh(Some(persisted.expires_at), now)
604 {
605 return Some(persisted.access_token);
606 }
607 (!cloud::needs_refresh(token.expires_at, now)).then(|| token.access_token.clone())
608}
609
610async fn resolve_access_token(
612 client: &reqwest::Client,
613 oauth: Option<&cloud::OauthClient>,
614 endpoints: &cloud::Endpoints,
615 oauth_path: &std::path::Path,
616 token: &StoredToken,
617 now: DateTime<Utc>,
618) -> Result<AccessToken> {
619 if let Some(value) = current_access_token(oauth_path, token, now) {
620 return Ok(AccessToken {
621 value,
622 just_refreshed: false,
623 });
624 }
625 refresh_and_persist(client, oauth, endpoints, oauth_path, token).await
626}
627
628async fn refresh_and_persist(
632 client: &reqwest::Client,
633 oauth: Option<&cloud::OauthClient>,
634 endpoints: &cloud::Endpoints,
635 oauth_path: &std::path::Path,
636 token: &StoredToken,
637) -> Result<AccessToken> {
638 let Some(refresh_token) = token.refresh_token.as_deref() else {
639 return Err(session_expired());
640 };
641 let Some(oauth) = oauth else {
642 return Err(refresh_unconfigured());
643 };
644 let refreshed = cloud::refresh(client, &endpoints.token, oauth, refresh_token).await?;
645 cloud::write_persisted(
646 oauth_path,
647 &cloud::PersistedOAuth {
648 fingerprint: token.fingerprint.clone(),
649 access_token: refreshed.access_token.clone(),
650 expires_at: refreshed.expires_at,
651 },
652 )?;
653 Ok(AccessToken {
654 value: refreshed.access_token,
655 just_refreshed: true,
656 })
657}
658
659struct Renewal<'a> {
662 agy: &'a super::agy::AgyCommand,
663 credential: SavedCredential<'a>,
664}
665
666impl Renewal<'_> {
667 async fn renewed(&self, cache: &Cache, now: DateTime<Utc>) -> Option<StoredToken> {
671 if !super::agy::renew_session(self.agy, cache, now).await {
672 return None;
673 }
674 let raw = match self.credential {
675 SavedCredential::Keyring => credential::read().ok().flatten()?,
676 SavedCredential::Blob(blob) => blob.to_string(),
677 SavedCredential::File(path) => std::fs::read_to_string(path).ok()?,
678 SavedCredential::Absent => return None,
679 };
680 credential::parse_keyring_blob(&raw).ok()
681 }
682}
683
684enum RemoteReading {
688 Live(AntigravitySnapshot),
689 Kept(FetchOutcome),
690}
691
692async fn fetch_remote(
718 client: &reqwest::Client,
719 cache: &Cache,
720 oauth: Option<&cloud::OauthClient>,
721 endpoints: &cloud::Endpoints,
722 token: Result<StoredToken>,
723 renewal: Renewal<'_>,
724 now: DateTime<Utc>,
725) -> Result<RemoteReading> {
726 let mut token = token?;
727 let oauth_path = cloud::oauth_cache_path(cache);
728 match super::agy::quota_snapshot(renewal.agy, cache, now).await {
729 Some(mut snap) => {
730 snap.plan = match current_access_token(&oauth_path, &token, now) {
731 Some(access) => cloud::fetch_plan(client, endpoints, &access).await,
732 None => None,
733 }
734 .unwrap_or_else(|| DEFAULT_PLAN.to_string());
735 snap.account = remote_account(&token.fingerprint);
736 snap.source = AntigravitySource::Agy;
737 return Ok(RemoteReading::Live(snap));
738 }
739 None if renewal.agy.is_available() => {
740 if let Some(kept) = kept_agy_reading(cache, &remote_account(&token.fingerprint), now) {
741 return Ok(RemoteReading::Kept(kept));
742 }
743 }
744 None => {}
745 }
746 let mut access =
747 match resolve_access_token(client, oauth, endpoints, &oauth_path, &token, now).await {
748 Ok(access) => access,
749 Err(e) if oauth.is_none() => {
752 let Some(renewed) = renewal.renewed(cache, now).await else {
753 return Err(e);
754 };
755 token = renewed;
756 resolve_access_token(client, oauth, endpoints, &oauth_path, &token, now).await?
757 }
758 Err(e) => return Err(e),
759 };
760
761 let quota = match cloud::fetch_quota(client, endpoints, &access.value).await {
762 Err(e) if cloud::is_no_plan_quota(&e) => return Err(e),
765 Err(e) if is_auth_rejection(&e) && !access.just_refreshed => {
766 access = if oauth.is_none() {
767 let Some(renewed) = renewal.renewed(cache, now).await else {
768 return Err(refresh_unconfigured());
769 };
770 token = renewed;
771 resolve_access_token(client, oauth, endpoints, &oauth_path, &token, now).await?
772 } else {
773 refresh_and_persist(client, oauth, endpoints, &oauth_path, &token).await?
774 };
775 cloud::fetch_quota(client, endpoints, &access.value)
776 .await
777 .map_err(|e| {
778 if cloud::is_no_plan_quota(&e) {
779 e
780 } else if is_auth_rejection(&e) {
781 session_expired()
782 } else {
783 e
784 }
785 })?
786 }
787 Err(e) if is_auth_rejection(&e) => return Err(session_expired()),
788 other => other?,
789 };
790
791 let plan = cloud::fetch_plan(client, endpoints, &access.value)
792 .await
793 .unwrap_or_else(|| DEFAULT_PLAN.to_string());
794 let mut snap = parse_quota_summary("a, plan)?;
795 snap.account = remote_account(&token.fingerprint);
796 snap.source = AntigravitySource::Remote;
797 Ok(RemoteReading::Live(snap))
798}
799
800fn kept_agy_reading(cache: &Cache, account: &str, now: DateTime<Utc>) -> Option<FetchOutcome> {
809 let not_agys = || AppError::Other("Antigravity: no reading of `agy`'s own to keep".into());
810 crate::outcome::fallback(cache, None, not_agys(), |bytes| {
811 let snap = parse_cache_at(bytes, Some(account), now)?;
812 (snap.source == AntigravitySource::Agy)
813 .then_some(snap)
814 .ok_or_else(not_agys)
815 })
816 .ok()
817}
818
819fn account_key(user_status: &serde_json::Value) -> String {
824 let email = user_status["userStatus"]["email"]
825 .as_str()
826 .filter(|s| !s.is_empty());
827 match email {
828 Some(e) => {
829 use std::hash::{Hash, Hasher};
830 let mut h = std::collections::hash_map::DefaultHasher::new();
831 e.hash(&mut h);
832 format!("acct:{:016x}", h.finish())
833 }
834 None => "acct:unknown".to_string(),
835 }
836}
837
838async fn fetch_csrf(client: &reqwest::Client, base: &str) -> Option<String> {
842 let resp = client.get(base).timeout(HTTP_TIMEOUT).send().await.ok()?;
843 let bytes = crate::vendor::read_body_capped(resp, crate::vendor::MAX_BODY_BYTES)
846 .await
847 .ok()?;
848 let html = String::from_utf8_lossy(&bytes);
849 html.split("csrfToken\":\"")
850 .nth(1)
851 .and_then(|s| s.split('"').next())
852 .filter(|t| !t.is_empty())
853 .map(|t| t.to_string())
854}
855
856async fn post_rpc(
857 client: &reqwest::Client,
858 base: &str,
859 csrf: Option<&str>,
860 rpc: &str,
861) -> Result<serde_json::Value> {
862 let mut req = client
863 .post(format!("{base}/{rpc}"))
864 .header("Content-Type", "application/json")
865 .body("{}")
866 .timeout(HTTP_TIMEOUT);
867 if let Some(token) = csrf {
868 req = req.header("x-codeium-csrf-token", token);
869 }
870 let resp = req.send().await?;
871
872 let status = resp.status();
873 let bytes = crate::vendor::read_body_capped(resp, crate::vendor::MAX_BODY_BYTES).await?;
877 if !status.is_success() {
878 let body = String::from_utf8_lossy(&bytes).into_owned();
879 return Err(AppError::Http {
880 status: status.as_u16(),
881 body,
882 });
883 }
884
885 Ok(serde_json::from_slice(&bytes)?)
886}
887
888pub fn plan_from_status(v: &serde_json::Value) -> String {
889 v["userStatus"]["userTier"]["name"]
890 .as_str()
891 .or_else(|| v["userStatus"]["userTier"]["description"].as_str())
892 .or_else(|| v["userStatus"]["planStatus"]["planInfo"]["planName"].as_str())
893 .filter(|s| !s.is_empty())
894 .unwrap_or(DEFAULT_PLAN)
895 .to_string()
896}
897
898fn describe_bucket(group_name: &str, id: &str, window: Option<&str>) -> String {
910 let id = if id.is_empty() { "<unnamed>" } else { id };
911 let group = if group_name.is_empty() {
912 String::new()
913 } else {
914 format!(" in {group_name:?}")
915 };
916 match window {
917 Some(window) if !window.is_empty() => format!("{id} (window {window}){group}"),
918 _ => format!("{id}{group}"),
919 }
920}
921
922fn no_usable_bucket(seen: &[String]) -> AppError {
928 if seen.is_empty() {
929 return AppError::Other(
930 "antigravity: quota summary has no buckets at all — the running product may \
931 not have a quota for this account yet"
932 .into(),
933 );
934 }
935 AppError::Other(format!(
936 "antigravity: quota summary has no bucket in a window we recognise (5h or \
937 weekly, Gemini or Claude/GPT); it offered: {}",
938 seen.join(", ")
939 ))
940}
941
942pub fn parse_quota_summary(v: &serde_json::Value, plan: String) -> Result<AntigravitySnapshot> {
943 let groups = v["response"]["groups"]
944 .as_array()
945 .or_else(|| v["groups"].as_array())
946 .ok_or_else(|| AppError::Other("antigravity: quota summary has no groups".into()))?;
947
948 const SLOT_NAMES: [&str; 4] = [
950 "Gemini 5h",
951 "Gemini weekly",
952 "Claude/GPT 5h",
953 "Claude/GPT weekly",
954 ];
955 const CANONICAL_IDS: [&str; 4] = ["gemini-5h", "gemini-weekly", "3p-5h", "3p-weekly"];
956 let is_canonical = |bucket: &serde_json::Value| {
957 CANONICAL_IDS.contains(&bucket["bucketId"].as_str().unwrap_or_default())
958 };
959 let shares_a_slot = |group: &serde_json::Value| {
960 group["buckets"]
961 .as_array()
962 .is_some_and(|buckets| buckets.iter().any(is_canonical))
963 };
964 let renamed = !groups.iter().any(shares_a_slot);
971
972 let mut exact: [Vec<&serde_json::Value>; 4] = Default::default();
975 let mut inferred: [Vec<&serde_json::Value>; 4] = Default::default();
976 let mut pools: Vec<(&str, &serde_json::Value, bool)> = Vec::new();
977 let mut seen: Vec<String> = Vec::new();
981
982 for group in groups {
983 let group_name = group["displayName"].as_str().unwrap_or_default();
984 let Some(buckets) = group["buckets"].as_array() else {
985 continue;
986 };
987 let own_pool = !renamed && !shares_a_slot(group);
988 for bucket in buckets {
989 let id = bucket["bucketId"].as_str().unwrap_or_default();
990 seen.push(describe_bucket(group_name, id, bucket["window"].as_str()));
991 if let Some(slot) = CANONICAL_IDS.iter().position(|c| *c == id) {
992 exact[slot].push(bucket);
993 continue;
994 }
995 let window = bucket["window"].as_str().unwrap_or_default();
996 let is_weekly = if id.ends_with("weekly") || window == "weekly" {
997 true
998 } else if id.ends_with("5h") || window == "5h" {
999 false
1000 } else {
1001 continue;
1004 };
1005 if own_pool {
1006 pools.push((group_name, bucket, is_weekly));
1007 continue;
1008 }
1009 if !renamed {
1012 continue;
1013 }
1014 let is_gemini = if id.starts_with("gemini") {
1015 true
1016 } else if id.starts_with("3p") {
1017 false
1018 } else if group_name.contains("Gemini") {
1019 true
1020 } else if group_name.contains("Claude") || group_name.contains("GPT") {
1021 false
1022 } else {
1023 pools.push((group_name, bucket, is_weekly));
1026 continue;
1027 };
1028
1029 let slot = usize::from(!is_gemini) * 2 + usize::from(is_weekly);
1030 inferred[slot].push(bucket);
1031 }
1032 }
1033
1034 let mut slots: [Option<UsageWindow>; 4] = Default::default();
1038 for slot in 0..4 {
1039 let candidates = if renamed {
1040 &inferred[slot]
1041 } else {
1042 &exact[slot]
1043 };
1044 match candidates.as_slice() {
1045 [] => {}
1046 [bucket] => slots[slot] = Some(usage_window(bucket, slot % 2 == 1)?),
1047 _ => {
1048 return Err(AppError::Schema(format!(
1049 "antigravity: duplicate {} bucket",
1050 SLOT_NAMES[slot]
1051 )));
1052 }
1053 }
1054 }
1055 let [gemini_5h, gemini_weekly, tp_5h, tp_weekly] = slots;
1056
1057 let model_pools: Vec<ModelPoolWindow> = pools
1061 .into_iter()
1062 .filter_map(|(group_name, bucket, is_weekly)| {
1063 let window = usage_window(bucket, is_weekly).ok()?;
1064 Some(ModelPoolWindow {
1065 name: pool_name(group_name, bucket),
1066 window,
1067 })
1068 })
1069 .collect();
1070
1071 if gemini_5h.is_none()
1075 && gemini_weekly.is_none()
1076 && tp_5h.is_none()
1077 && tp_weekly.is_none()
1078 && model_pools.is_empty()
1079 {
1080 return Err(no_usable_bucket(&seen));
1081 }
1082
1083 Ok(AntigravitySnapshot {
1084 plan,
1085 account: String::new(),
1088 source: AntigravitySource::Local,
1089 session: gemini_5h,
1090 weekly: gemini_weekly,
1091 third_party_session: tp_5h,
1092 third_party_weekly: tp_weekly,
1093 model_pools,
1094 })
1095}
1096
1097fn pool_name(group_name: &str, bucket: &serde_json::Value) -> String {
1101 [
1102 group_name,
1103 bucket["displayName"].as_str().unwrap_or_default(),
1104 bucket["bucketId"].as_str().unwrap_or_default(),
1105 ]
1106 .into_iter()
1107 .map(crate::display::sanitize_untrusted_field)
1108 .find(|name| !name.trim().is_empty())
1109 .unwrap_or_else(|| "Model pool".to_string())
1110}
1111
1112fn usage_window(bucket: &serde_json::Value, is_weekly: bool) -> Result<UsageWindow> {
1116 let remaining = bucket["remainingFraction"]
1117 .as_f64()
1118 .filter(|f| f.is_finite() && (0.0..=1.0).contains(f))
1119 .ok_or_else(|| {
1120 AppError::Schema(format!(
1121 "antigravity: bucket {} has no valid remainingFraction in 0..=1",
1122 bucket["bucketId"].as_str().unwrap_or("<unnamed>")
1123 ))
1124 })?;
1125 Ok(UsageWindow {
1126 utilization_pct: pct_used(remaining),
1127 resets_at: parse_reset(&bucket["resetTime"], "quota resetTime")?,
1128 window_duration: if is_weekly {
1129 chrono::Duration::days(7)
1130 } else {
1131 chrono::Duration::hours(5)
1132 },
1133 })
1134}
1135
1136fn pct_used(remaining_fraction: f64) -> i32 {
1139 let used = (1.0 - remaining_fraction) * 100.0;
1140 used.round() as i32
1141}
1142
1143fn parse_reset(value: &serde_json::Value, field: &str) -> Result<Option<DateTime<Utc>>> {
1144 match value {
1145 serde_json::Value::Null => Ok(None),
1146 serde_json::Value::String(s) => DateTime::parse_from_rfc3339(s)
1147 .map(|dt| Some(dt.with_timezone(&Utc)))
1148 .map_err(|_| AppError::Schema(format!("antigravity: invalid {field}"))),
1149 _ => Err(AppError::Schema(format!(
1150 "antigravity: {field} must be a timestamp or null"
1151 ))),
1152 }
1153}
1154
1155fn candidate_bases(ide_language_server: bool) -> Vec<Candidate> {
1163 let discovered = discover_ls_ports();
1164 let ide_pids = if ide_language_server {
1167 std::collections::HashSet::new()
1168 } else {
1169 ide_ls_pids(discovered.iter().map(|&(pid, _)| pid).collect())
1170 };
1171 candidate_bases_with(
1172 std::env::var("ANTIGRAVITY_LS_ADDRESS").ok().as_deref(),
1173 discovered,
1174 &ide_pids,
1175 )
1176}
1177
1178fn candidate_bases_with(
1182 override_addr: Option<&str>,
1183 discovered: Vec<(u32, u16)>,
1184 ide_pids: &std::collections::HashSet<u32>,
1185) -> Vec<Candidate> {
1186 let mut candidates = Vec::new();
1187 if let Some(base) = override_addr.and_then(normalize_base) {
1188 let pid = base_port(&base).and_then(|port| unique_owner(&discovered, port));
1193 candidates.push(Candidate {
1194 base,
1195 pid,
1196 ide: false,
1197 });
1198 }
1199
1200 for (pid, port) in discovered {
1206 let base = format!("http://127.0.0.1:{port}");
1207 if !candidates.iter().any(|c| c.base == base) {
1208 candidates.push(Candidate {
1209 base,
1210 pid: Some(pid),
1211 ide: ide_pids.contains(&pid),
1212 });
1213 }
1214 }
1215
1216 candidates
1217}
1218
1219#[cfg(any(test, target_os = "linux", target_os = "macos"))]
1224fn is_ide_install(exe: &str) -> bool {
1225 let exe = exe.to_lowercase().replace('\\', "/");
1226 exe.contains("antigravity-ide") || exe.contains("antigravity ide")
1227}
1228
1229#[cfg(target_os = "linux")]
1231fn ide_ls_pids(pids: std::collections::HashSet<u32>) -> std::collections::HashSet<u32> {
1232 pids.into_iter()
1233 .filter(|pid| {
1234 std::fs::read_link(format!("/proc/{pid}/exe"))
1235 .map(|path| path.to_string_lossy().into_owned())
1236 .is_ok_and(|exe| is_ide_install(&exe))
1237 })
1238 .collect()
1239}
1240
1241#[cfg(target_os = "macos")]
1244fn ide_ls_pids(pids: std::collections::HashSet<u32>) -> std::collections::HashSet<u32> {
1245 if pids.is_empty() {
1246 return std::collections::HashSet::new();
1247 }
1248 let list = pids
1249 .iter()
1250 .map(u32::to_string)
1251 .collect::<Vec<_>>()
1252 .join(",");
1253 let Ok(output) = std::process::Command::new("/bin/ps")
1256 .args(["-o", "pid=,comm=", "-p", &list])
1257 .output()
1258 else {
1259 return std::collections::HashSet::new();
1260 };
1261 parse_ps_comm(&String::from_utf8_lossy(&output.stdout))
1262 .into_iter()
1263 .filter(|(pid, exe)| pids.contains(pid) && is_ide_install(exe))
1264 .map(|(pid, _)| pid)
1265 .collect()
1266}
1267
1268#[cfg(any(test, target_os = "macos"))]
1271fn parse_ps_comm(output: &str) -> Vec<(u32, String)> {
1272 output
1273 .lines()
1274 .filter_map(|line| {
1275 let (pid, exe) = line.trim().split_once(char::is_whitespace)?;
1276 Some((pid.parse().ok()?, exe.trim().to_string()))
1277 })
1278 .collect()
1279}
1280
1281#[cfg(target_os = "windows")]
1285fn ide_ls_pids(_: std::collections::HashSet<u32>) -> std::collections::HashSet<u32> {
1286 std::collections::HashSet::new()
1287}
1288
1289#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
1290fn ide_ls_pids(_: std::collections::HashSet<u32>) -> std::collections::HashSet<u32> {
1291 std::collections::HashSet::new()
1292}
1293
1294fn base_port(base: &str) -> Option<u16> {
1296 let (_, authority) = base.split_once("://")?;
1297 authority.rsplit_once(':')?.1.parse().ok()
1298}
1299
1300fn unique_owner(discovered: &[(u32, u16)], port: u16) -> Option<u32> {
1303 let mut owners = discovered
1304 .iter()
1305 .filter(|(_, p)| *p == port)
1306 .map(|(pid, _)| *pid);
1307 let first = owners.next()?;
1308 owners.all(|pid| pid == first).then_some(first)
1309}
1310
1311fn normalize_base(addr: &str) -> Option<String> {
1320 let trimmed = addr.trim();
1321 let (scheme, authority) = match trimmed.split_once("://") {
1322 Some((scheme @ ("http" | "https"), rest)) => (scheme, rest),
1323 _ => ("http", trimmed),
1324 };
1325 let authority = authority.trim_end_matches('/');
1326 (!authority.is_empty()).then(|| format!("{scheme}://{authority}"))
1327}
1328
1329fn is_antigravity_process(comm: &str, exe: Option<&str>) -> bool {
1336 let comm = comm.trim().to_lowercase();
1337 let comm = comm.strip_suffix(".exe").unwrap_or(&comm);
1338 if comm.contains("language_server") || comm == "agy" || comm == "antigravity" {
1339 return true;
1340 }
1341 exe.is_some_and(|p| {
1342 let p = p.to_lowercase().replace('\\', "/");
1343 let p = p.strip_suffix(".exe").unwrap_or(&p);
1344 p.contains("antigravity") || p.ends_with("/agy")
1345 })
1346}
1347
1348#[cfg(any(test, target_os = "linux", target_os = "macos", target_os = "windows"))]
1380fn probe_order(per_pid: std::collections::BTreeMap<u32, Vec<u16>>) -> Vec<(u32, u16)> {
1381 let groups: Vec<(u32, Vec<u16>)> = per_pid
1382 .into_iter()
1383 .map(|(pid, mut group)| {
1384 group.sort_unstable_by(|a, b| b.cmp(a));
1385 group.dedup();
1390 (pid, group)
1391 })
1392 .collect();
1393
1394 let mut ports: Vec<(u32, u16)> = Vec::new();
1395 for rank in 0..groups.iter().map(|(_, g)| g.len()).max().unwrap_or(0) {
1396 for (pid, port) in groups
1397 .iter()
1398 .filter_map(|(pid, group)| group.get(rank).map(|port| (*pid, *port)))
1399 {
1400 if !ports.iter().any(|(_, p)| *p == port) {
1403 ports.push((pid, port));
1404 }
1405 }
1406 }
1407 ports
1408}
1409
1410#[cfg(target_os = "linux")]
1418pub(crate) fn discover_ls_ports() -> Vec<(u32, u16)> {
1419 use std::collections::{BTreeMap, HashMap};
1420
1421 let mut owners: HashMap<u64, u32> = HashMap::new();
1424 let Ok(entries) = std::fs::read_dir("/proc") else {
1425 return Vec::new();
1426 };
1427 for entry in entries.flatten() {
1428 let pid_dir = entry.path();
1429 let Some(pid) = pid_dir
1430 .file_name()
1431 .and_then(|name| name.to_str())
1432 .and_then(|name| name.parse::<u32>().ok())
1433 else {
1434 continue;
1435 };
1436 let Ok(comm) = std::fs::read_to_string(pid_dir.join("comm")) else {
1437 continue;
1438 };
1439 let exe = std::fs::read_link(pid_dir.join("exe")).ok();
1440 if !is_antigravity_process(&comm, exe.as_deref().and_then(|p| p.to_str())) {
1441 continue;
1442 }
1443 let Ok(fds) = std::fs::read_dir(pid_dir.join("fd")) else {
1444 continue;
1445 };
1446 for fd in fds.flatten() {
1447 let Ok(target) = std::fs::read_link(fd.path()) else {
1448 continue;
1449 };
1450 if let Some(ino) = target
1451 .to_str()
1452 .and_then(|s| s.strip_prefix("socket:["))
1453 .and_then(|s| s.strip_suffix(']'))
1454 .and_then(|s| s.parse::<u64>().ok())
1455 {
1456 owners.insert(ino, pid);
1457 }
1458 }
1459 }
1460
1461 if owners.is_empty() {
1462 return Vec::new();
1463 }
1464
1465 let mut per_pid: BTreeMap<u32, Vec<u16>> = BTreeMap::new();
1466 for table in ["/proc/net/tcp", "/proc/net/tcp6"] {
1467 let Ok(contents) = std::fs::read_to_string(table) else {
1468 continue;
1469 };
1470 for line in contents.lines().skip(1) {
1471 if let Some((port, ino)) = parse_proc_net_line(line)
1472 && let Some(&pid) = owners.get(&ino)
1473 {
1474 per_pid.entry(pid).or_default().push(port);
1475 }
1476 }
1477 }
1478 probe_order(per_pid)
1479}
1480
1481#[cfg(target_os = "macos")]
1488pub(crate) fn discover_ls_ports() -> Vec<(u32, u16)> {
1489 let Ok(output) = std::process::Command::new("/usr/sbin/lsof")
1491 .args(["-nP", "-iTCP", "-sTCP:LISTEN", "-F", "pcn"])
1492 .output()
1493 else {
1494 return Vec::new();
1495 };
1496 parse_lsof_pcn(&String::from_utf8_lossy(&output.stdout))
1500}
1501
1502#[cfg(any(test, target_os = "macos"))]
1507fn parse_lsof_pcn(output: &str) -> Vec<(u32, u16)> {
1508 let mut per_pid: std::collections::BTreeMap<u32, Vec<u16>> = std::collections::BTreeMap::new();
1509 let mut pid = None;
1512 let mut owner = None;
1513 for line in output.lines() {
1514 let Some(rest) = line.get(1..) else { continue };
1515 match line.as_bytes().first() {
1516 Some(b'p') => {
1517 pid = rest.parse::<u32>().ok();
1518 owner = None;
1519 }
1520 Some(b'c') => owner = pid.filter(|_| is_antigravity_process(rest, None)),
1521 Some(b'n') => {
1522 if let Some(pid) = owner
1523 && let Some(port) = rest.rsplit(':').next().and_then(|p| p.parse::<u16>().ok())
1524 {
1525 per_pid.entry(pid).or_default().push(port);
1526 }
1527 }
1528 _ => {}
1529 }
1530 }
1531 probe_order(per_pid)
1532}
1533
1534#[cfg(any(test, target_os = "windows"))]
1535#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1536struct WindowsTcpRow {
1537 local_addr: [u8; 4],
1538 local_port: u32,
1539 pid: u32,
1540}
1541
1542#[cfg(any(test, target_os = "windows"))]
1543fn decode_windows_process_name(raw: &[u16]) -> String {
1544 let end = raw.iter().position(|&unit| unit == 0).unwrap_or(raw.len());
1545 String::from_utf16_lossy(&raw[..end])
1546}
1547
1548#[cfg(any(test, target_os = "windows"))]
1549fn matching_windows_process_ids(processes: &[(u32, String)]) -> std::collections::HashSet<u32> {
1550 processes
1551 .iter()
1552 .filter(|(_, name)| is_antigravity_process(name, None))
1553 .map(|(pid, _)| *pid)
1554 .collect()
1555}
1556
1557#[cfg(any(test, target_os = "windows"))]
1560fn matching_windows_ports(
1561 pids: &std::collections::HashSet<u32>,
1562 rows: &[WindowsTcpRow],
1563) -> Vec<(u32, u16)> {
1564 let mut per_pid: std::collections::BTreeMap<u32, Vec<u16>> = std::collections::BTreeMap::new();
1565 for row in rows {
1566 if !pids.contains(&row.pid) || row.local_addr != [127, 0, 0, 1] {
1567 continue;
1568 }
1569 let port = u16::from_be((row.local_port & u32::from(u16::MAX)) as u16);
1570 if port != 0 {
1571 per_pid.entry(row.pid).or_default().push(port);
1572 }
1573 }
1574 probe_order(per_pid)
1575}
1576
1577#[cfg(any(test, target_os = "windows"))]
1578fn checked_windows_row_count(
1579 buffer_len: usize,
1580 rows_offset: usize,
1581 row_size: usize,
1582 declared: usize,
1583) -> Option<usize> {
1584 let rows_len = row_size.checked_mul(declared)?;
1585 let end = rows_offset.checked_add(rows_len)?;
1586 (row_size != 0 && end <= buffer_len).then_some(declared)
1587}
1588
1589#[cfg(target_os = "windows")]
1590struct WindowsHandle(windows_sys::Win32::Foundation::HANDLE);
1591
1592#[cfg(target_os = "windows")]
1593impl Drop for WindowsHandle {
1594 fn drop(&mut self) {
1595 unsafe {
1596 windows_sys::Win32::Foundation::CloseHandle(self.0);
1597 }
1598 }
1599}
1600
1601#[cfg(target_os = "windows")]
1602fn windows_processes() -> Vec<(u32, String)> {
1603 use std::mem::size_of;
1604 use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE;
1605 use windows_sys::Win32::System::Diagnostics::ToolHelp::{
1606 CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW,
1607 TH32CS_SNAPPROCESS,
1608 };
1609
1610 let handle = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
1611 if handle == INVALID_HANDLE_VALUE {
1612 return Vec::new();
1613 }
1614 let snapshot = WindowsHandle(handle);
1615 let mut entry = PROCESSENTRY32W::default();
1616 let Ok(entry_size) = u32::try_from(size_of::<PROCESSENTRY32W>()) else {
1617 return Vec::new();
1618 };
1619 entry.dwSize = entry_size;
1620 if unsafe { Process32FirstW(snapshot.0, &mut entry) } == 0 {
1621 return Vec::new();
1622 }
1623
1624 let mut processes = Vec::new();
1625 loop {
1626 processes.push((
1627 entry.th32ProcessID,
1628 decode_windows_process_name(&entry.szExeFile),
1629 ));
1630 if unsafe { Process32NextW(snapshot.0, &mut entry) } == 0 {
1631 break;
1632 }
1633 }
1634 processes
1635}
1636
1637#[cfg(target_os = "windows")]
1638fn parse_windows_tcp_rows(buffer: &[u32], used_bytes: usize) -> Vec<WindowsTcpRow> {
1639 use std::mem::{offset_of, size_of, size_of_val};
1640 use windows_sys::Win32::NetworkManagement::IpHelper::{
1641 MIB_TCPROW_OWNER_PID, MIB_TCPTABLE_OWNER_PID,
1642 };
1643
1644 let available = used_bytes.min(size_of_val(buffer));
1645 let rows_offset = offset_of!(MIB_TCPTABLE_OWNER_PID, table);
1646 if available < size_of::<u32>() || available < rows_offset {
1647 return Vec::new();
1648 }
1649 let base = buffer.as_ptr().cast::<u8>();
1650 let declared = unsafe { base.cast::<u32>().read_unaligned() } as usize;
1651 if checked_windows_row_count(
1652 available,
1653 rows_offset,
1654 size_of::<MIB_TCPROW_OWNER_PID>(),
1655 declared,
1656 )
1657 .is_none()
1658 {
1659 return Vec::new();
1660 }
1661
1662 let rows = unsafe { base.add(rows_offset).cast::<MIB_TCPROW_OWNER_PID>() };
1663 (0..declared)
1664 .map(|index| unsafe { rows.add(index).read_unaligned() })
1665 .map(|row| WindowsTcpRow {
1666 local_addr: row.dwLocalAddr.to_ne_bytes(),
1667 local_port: row.dwLocalPort,
1668 pid: row.dwOwningPid,
1669 })
1670 .collect()
1671}
1672
1673#[cfg(target_os = "windows")]
1674fn windows_tcp_rows() -> Vec<WindowsTcpRow> {
1675 use std::mem::size_of;
1676 use std::ptr::null_mut;
1677 use windows_sys::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER;
1678 use windows_sys::Win32::NetworkManagement::IpHelper::{
1679 GetExtendedTcpTable, TCP_TABLE_OWNER_PID_LISTENER,
1680 };
1681 use windows_sys::Win32::Networking::WinSock::AF_INET;
1682
1683 let mut size = 0u32;
1684 let status = unsafe {
1685 GetExtendedTcpTable(
1686 null_mut(),
1687 &mut size,
1688 0,
1689 u32::from(AF_INET),
1690 TCP_TABLE_OWNER_PID_LISTENER,
1691 0,
1692 )
1693 };
1694 if status != ERROR_INSUFFICIENT_BUFFER {
1695 return Vec::new();
1696 }
1697
1698 for _ in 0..3 {
1699 let Some(words) = (size as usize)
1700 .checked_add(size_of::<u32>() - 1)
1701 .map(|bytes| bytes / size_of::<u32>())
1702 else {
1703 return Vec::new();
1704 };
1705 if words == 0 {
1706 return Vec::new();
1707 }
1708 let mut buffer = Vec::<u32>::new();
1709 if buffer.try_reserve_exact(words).is_err() {
1710 return Vec::new();
1711 }
1712 buffer.resize(words, 0);
1713 let mut used = size;
1714 let status = unsafe {
1715 GetExtendedTcpTable(
1716 buffer.as_mut_ptr().cast(),
1717 &mut used,
1718 0,
1719 u32::from(AF_INET),
1720 TCP_TABLE_OWNER_PID_LISTENER,
1721 0,
1722 )
1723 };
1724 if status == ERROR_INSUFFICIENT_BUFFER {
1725 size = used;
1726 continue;
1727 }
1728 if status != 0 {
1729 return Vec::new();
1730 }
1731 return parse_windows_tcp_rows(&buffer, used as usize);
1732 }
1733 Vec::new()
1734}
1735
1736#[cfg(target_os = "windows")]
1737pub(crate) fn discover_ls_ports() -> Vec<(u32, u16)> {
1738 let pids = matching_windows_process_ids(&windows_processes());
1739 if pids.is_empty() {
1740 return Vec::new();
1741 }
1742 matching_windows_ports(&pids, &windows_tcp_rows())
1743}
1744
1745#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
1746pub(crate) fn discover_ls_ports() -> Vec<(u32, u16)> {
1747 Vec::new()
1748}
1749
1750#[cfg(target_os = "linux")]
1753fn parse_proc_net_line(line: &str) -> Option<(u16, u64)> {
1754 let cols: Vec<&str> = line.split_whitespace().collect();
1755 if cols.len() < 10 {
1756 return None;
1757 }
1758 if cols[3] != "0A" {
1760 return None;
1761 }
1762 let port = u16::from_str_radix(cols[1].split(':').nth(1)?, 16).ok()?;
1763 let inode = cols[9].parse::<u64>().ok()?;
1764 Some((port, inode))
1765}
1766
1767fn fallback_silent(cache: &Cache, now: DateTime<Utc>, original: AppError) -> Result<FetchOutcome> {
1772 crate::outcome::fallback(cache, None, original, |bytes| {
1773 parse_cache_at(bytes, None, now)
1774 })
1775}
1776
1777fn fallback_with_error(
1782 cache: &Cache,
1783 last_error: Option<(u16, String)>,
1784 reason: AppError,
1785 now: DateTime<Utc>,
1786) -> Result<FetchOutcome> {
1787 crate::outcome::fallback(cache, last_error, reason, |bytes| {
1788 parse_cache_at(bytes, None, now)
1789 })
1790}
1791
1792fn reuse_cache(
1793 bytes: Vec<u8>,
1794 cache: &Cache,
1795 stale: bool,
1796 account: Option<&str>,
1797 now: DateTime<Utc>,
1798) -> Result<FetchOutcome> {
1799 let snap = parse_cache_at(&bytes, account, now)?;
1800 Ok(crate::outcome::Outcome::cached(snap, cache, stale))
1801}
1802
1803pub fn parse_cache(bytes: &[u8], account: Option<&str>) -> Result<AntigravitySnapshot> {
1809 parse_cache_at(bytes, account, Utc::now())
1810}
1811
1812fn expired_window(snap: &AntigravitySnapshot, now: DateTime<Utc>) -> Option<String> {
1822 let shared = [
1823 ("Gemini 5h", snap.session.as_ref()),
1824 ("Gemini weekly", snap.weekly.as_ref()),
1825 ("Claude & GPT OSS 5h", snap.third_party_session.as_ref()),
1826 ("Claude & GPT OSS weekly", snap.third_party_weekly.as_ref()),
1827 ]
1828 .into_iter()
1829 .filter_map(|(name, w)| Some((name.to_string(), w?)));
1830 let pools = snap.model_pools.iter().map(|pool| {
1831 let name = format!("{} {}", pool.name, pool_cadence(pool.is_weekly()));
1832 (name, &pool.window)
1833 });
1834 shared
1835 .chain(pools)
1836 .find(|(_, w)| w.resets_at.is_some_and(|r| r <= now))
1837 .map(|(name, _)| name)
1838}
1839
1840fn pool_cadence(weekly: bool) -> &'static str {
1843 if weekly { "weekly" } else { "5h" }
1844}
1845
1846pub fn parse_cache_at(
1847 bytes: &[u8],
1848 account: Option<&str>,
1849 now: DateTime<Utc>,
1850) -> Result<AntigravitySnapshot> {
1851 let v: serde_json::Value = serde_json::from_slice(bytes)?;
1852
1853 let cached_account = v.get("account").and_then(serde_json::Value::as_str);
1854 if let Some(expected) = account
1855 && cached_account != Some(expected)
1856 {
1857 return Err(AppError::Schema(
1858 "antigravity cache belongs to a different account; refetching".into(),
1859 ));
1860 }
1861
1862 let cached_pct = |pct_key: &'static str| -> Result<Option<i32>> {
1872 match v.get(pct_key) {
1873 None => Err(AppError::Schema(format!(
1874 "antigravity: cached payload is missing {pct_key}"
1875 ))),
1876 Some(serde_json::Value::Null) => Ok(None),
1877 Some(value) => value
1878 .as_i64()
1879 .filter(|pct| (0..=100).contains(pct))
1880 .map(|pct| Some(pct as i32))
1881 .ok_or_else(|| {
1882 AppError::Schema(format!(
1883 "antigravity: cached {pct_key} must be an integer in 0..=100"
1884 ))
1885 }),
1886 }
1887 };
1888
1889 let optional = |pct_key: &'static str, reset_key: &str, weekly: bool| {
1890 let Some(pct) = cached_pct(pct_key)? else {
1891 return Ok(None);
1892 };
1893 Ok::<_, AppError>(Some(UsageWindow {
1894 utilization_pct: pct,
1895 resets_at: parse_reset(&v[reset_key], reset_key)?,
1896 window_duration: if weekly {
1897 chrono::Duration::days(7)
1898 } else {
1899 chrono::Duration::hours(5)
1900 },
1901 }))
1902 };
1903
1904 let snap = AntigravitySnapshot {
1905 plan: v["plan"].as_str().unwrap_or(DEFAULT_PLAN).to_string(),
1906 account: cached_account.unwrap_or_default().to_string(),
1907 source: v["source"]
1909 .as_str()
1910 .and_then(AntigravitySource::parse)
1911 .unwrap_or_default(),
1912 session: optional("session_pct", "session_reset", false)?,
1913 weekly: optional("weekly_pct", "weekly_reset", true)?,
1914 third_party_session: optional("tp_session_pct", "tp_session_reset", false)?,
1915 third_party_weekly: optional("tp_weekly_pct", "tp_weekly_reset", true)?,
1916 model_pools: cached_pools(&v)?,
1917 };
1918
1919 if snap.session.is_none()
1922 && snap.weekly.is_none()
1923 && snap.third_party_session.is_none()
1924 && snap.third_party_weekly.is_none()
1925 && snap.model_pools.is_empty()
1926 {
1927 return Err(AppError::Schema(
1928 "antigravity cache holds no usable window; refetching".into(),
1929 ));
1930 }
1931
1932 if let Some(window) = expired_window(&snap, now) {
1933 return Err(AppError::Schema(format!(
1934 "antigravity cache is past its {window} reset; refetching"
1935 )));
1936 }
1937 Ok(snap)
1938}
1939
1940fn cached_pools(v: &serde_json::Value) -> Result<Vec<ModelPoolWindow>> {
1944 let Some(entries) = v.get("model_pools") else {
1945 return Ok(Vec::new());
1946 };
1947 let malformed = || AppError::Schema("antigravity: cached model_pools is malformed".into());
1948 entries
1949 .as_array()
1950 .ok_or_else(malformed)?
1951 .iter()
1952 .map(|entry| {
1953 let name = entry["name"].as_str().ok_or_else(malformed)?;
1954 let weekly = match entry["window"].as_str() {
1955 Some(cadence) if cadence == pool_cadence(true) => true,
1956 Some(cadence) if cadence == pool_cadence(false) => false,
1957 _ => return Err(malformed()),
1958 };
1959 let pct = entry["pct"]
1960 .as_i64()
1961 .filter(|pct| (0..=100).contains(pct))
1962 .ok_or_else(malformed)?;
1963 Ok(ModelPoolWindow {
1964 name: crate::display::sanitize_untrusted_field(name),
1968 window: UsageWindow {
1969 utilization_pct: pct as i32,
1970 resets_at: parse_reset(&entry["reset"], "model_pools reset")?,
1971 window_duration: if weekly {
1972 chrono::Duration::days(7)
1973 } else {
1974 chrono::Duration::hours(5)
1975 },
1976 },
1977 })
1978 })
1979 .collect()
1980}
1981
1982pub fn snap_to_json(snap: &AntigravitySnapshot) -> serde_json::Value {
1983 let model_pools: Vec<serde_json::Value> = snap
1984 .model_pools
1985 .iter()
1986 .map(|pool| {
1987 serde_json::json!({
1988 "name": pool.name,
1989 "window": pool_cadence(pool.is_weekly()),
1990 "pct": pool.window.utilization_pct,
1991 "reset": pool.window.resets_at.map(|dt| dt.to_rfc3339()),
1992 })
1993 })
1994 .collect();
1995 serde_json::json!({
1996 "plan": snap.plan,
1997 "account": snap.account,
1998 "source": snap.source.as_str(),
1999 "session_pct": snap.session.as_ref().map(|w| w.utilization_pct),
2000 "session_reset": snap.session.as_ref().and_then(|w| w.resets_at.map(|dt| dt.to_rfc3339())),
2001 "weekly_pct": snap.weekly.as_ref().map(|w| w.utilization_pct),
2002 "weekly_reset": snap.weekly.as_ref().and_then(|w| w.resets_at.map(|dt| dt.to_rfc3339())),
2003 "tp_session_pct": snap.third_party_session.as_ref().map(|w| w.utilization_pct),
2004 "tp_session_reset": snap.third_party_session.as_ref().and_then(|w| w.resets_at.map(|dt| dt.to_rfc3339())),
2005 "tp_weekly_pct": snap.third_party_weekly.as_ref().map(|w| w.utilization_pct),
2006 "tp_weekly_reset": snap.third_party_weekly.as_ref().and_then(|w| w.resets_at.map(|dt| dt.to_rfc3339())),
2007 "model_pools": model_pools,
2008 })
2009}
2010
2011#[cfg(test)]
2012mod tests {
2013 use super::*;
2014
2015 const QUOTA_JSON: &str = r#"{
2019 "response": {
2020 "groups": [
2021 {
2022 "displayName": "Gemini Models",
2023 "buckets": [
2024 {"bucketId": "gemini-weekly", "displayName": "Weekly Limit",
2025 "window": "weekly", "remainingFraction": 0.9191212,
2026 "resetTime": "2026-07-28T17:39:58Z"},
2027 {"bucketId": "gemini-5h", "displayName": "Five Hour Limit",
2028 "window": "5h", "remainingFraction": 0.5672253,
2029 "resetTime": "2026-07-22T17:47:00Z"}
2030 ]
2031 },
2032 {
2033 "displayName": "Claude and GPT models",
2034 "buckets": [
2035 {"bucketId": "3p-weekly", "window": "weekly",
2036 "remainingFraction": 1, "resetTime": "2026-07-29T12:47:00Z"},
2037 {"bucketId": "3p-5h", "window": "5h",
2038 "remainingFraction": 0.25, "resetTime": "2026-07-22T17:47:00Z"}
2039 ]
2040 }
2041 ]
2042 }
2043 }"#;
2044
2045 fn now() -> DateTime<Utc> {
2048 DateTime::parse_from_rfc3339("2026-07-22T12:00:00Z")
2049 .unwrap()
2050 .with_timezone(&Utc)
2051 }
2052
2053 fn parsed() -> AntigravitySnapshot {
2054 let v: serde_json::Value = serde_json::from_str(QUOTA_JSON).unwrap();
2055 parse_quota_summary(&v, "Google AI Pro".into()).unwrap()
2056 }
2057
2058 #[test]
2059 fn quota_summary_maps_four_distinct_windows() {
2060 let snap = parsed();
2061 assert_eq!(snap.plan, "Google AI Pro");
2062 assert_eq!(snap.session.as_ref().unwrap().utilization_pct, 43);
2064 assert_eq!(snap.weekly.as_ref().unwrap().utilization_pct, 8);
2065 assert_eq!(
2066 snap.third_party_session.as_ref().unwrap().utilization_pct,
2067 75
2068 );
2069 assert_eq!(snap.third_party_weekly.as_ref().unwrap().utilization_pct, 0);
2070 }
2071
2072 #[test]
2073 fn each_window_keeps_its_own_reset_time() {
2074 let snap = parsed();
2075 let at = |s: &str| Some(DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc));
2076 assert_eq!(
2077 snap.session.as_ref().unwrap().resets_at,
2078 at("2026-07-22T17:47:00Z")
2079 );
2080 assert_eq!(
2081 snap.weekly.as_ref().unwrap().resets_at,
2082 at("2026-07-28T17:39:58Z")
2083 );
2084 assert_eq!(
2085 snap.third_party_weekly.as_ref().unwrap().resets_at,
2086 at("2026-07-29T12:47:00Z")
2087 );
2088 assert_ne!(
2090 snap.session.as_ref().unwrap().resets_at,
2091 snap.weekly.as_ref().unwrap().resets_at
2092 );
2093 }
2094
2095 #[test]
2096 fn window_durations_match_their_bucket() {
2097 let snap = parsed();
2098 assert_eq!(
2099 snap.session.as_ref().unwrap().window_duration,
2100 chrono::Duration::hours(5)
2101 );
2102 assert_eq!(
2103 snap.weekly.as_ref().unwrap().window_duration,
2104 chrono::Duration::days(7)
2105 );
2106 assert_eq!(
2107 snap.third_party_weekly.as_ref().unwrap().window_duration,
2108 chrono::Duration::days(7)
2109 );
2110 }
2111
2112 #[test]
2113 fn groups_are_matched_by_display_name_when_bucket_ids_change() {
2114 let v: serde_json::Value = serde_json::from_str(
2115 r#"{"response":{"groups":[
2116 {"displayName":"Gemini Models","buckets":[
2117 {"bucketId":"x1","window":"5h","remainingFraction":0.5,"resetTime":"2026-07-22T17:47:00Z"},
2118 {"bucketId":"x2","window":"weekly","remainingFraction":0.9,"resetTime":"2026-07-28T17:39:58Z"}]},
2119 {"displayName":"Claude and GPT models","buckets":[
2120 {"bucketId":"y1","window":"5h","remainingFraction":0.0,"resetTime":"2026-07-22T17:47:00Z"}]}
2121 ]}}"#,
2122 )
2123 .unwrap();
2124 let snap = parse_quota_summary(&v, "Pro".into()).unwrap();
2125 assert_eq!(snap.session.as_ref().unwrap().utilization_pct, 50);
2126 assert_eq!(snap.weekly.as_ref().unwrap().utilization_pct, 10);
2127 assert_eq!(snap.third_party_session.unwrap().utilization_pct, 100);
2128 assert!(snap.third_party_weekly.is_none());
2129 }
2130
2131 #[test]
2137 fn a_per_model_pool_is_reported_beside_the_canonical_buckets() {
2138 let v: serde_json::Value = serde_json::from_str(
2139 r#"{"response":{"groups":[
2140 {"displayName":"Gemini Models","buckets":[
2141 {"bucketId":"gemini-weekly","displayName":"Weekly Limit Remaining","window":"weekly",
2142 "remainingFraction":0.7839184,"resetTime":"2026-10-23T12:22:17Z"},
2143 {"bucketId":"gemini-5h","displayName":"Five Hour Limit Remaining","window":"5h",
2144 "remainingFraction":0.253501,"resetTime":"2026-10-16T17:22:17Z"}]},
2145 {"displayName":"Claude and GPT models","buckets":[
2146 {"bucketId":"3p-weekly","displayName":"Weekly Limit Remaining","window":"weekly",
2147 "remainingFraction":1,"resetTime":"2026-10-23T17:05:12Z"},
2148 {"bucketId":"3p-5h","displayName":"Five Hour Limit Remaining","window":"5h",
2149 "remainingFraction":1,"resetTime":"2026-10-16T22:05:12Z"}]},
2150 {"displayName":"Gemini 4 Argon","buckets":[
2151 {"bucketId":"gemini-argon-wtu-weekly","displayName":"Gemini 4 Argon Weekly Limit",
2152 "window":"weekly","remainingFraction":0.4,"resetTime":"2026-10-23T17:05:12Z"}]}
2153 ]}}"#,
2154 )
2155 .unwrap();
2156 let snap = parse_quota_summary(&v, "Google AI Pro".into()).unwrap();
2157 assert_eq!(snap.session.as_ref().unwrap().utilization_pct, 75);
2158 assert_eq!(snap.weekly.as_ref().unwrap().utilization_pct, 22);
2159 assert_eq!(
2160 snap.third_party_session.as_ref().unwrap().utilization_pct,
2161 0
2162 );
2163 assert_eq!(snap.third_party_weekly.as_ref().unwrap().utilization_pct, 0);
2164 let [argon] = snap.model_pools.as_slice() else {
2165 panic!("{:?}", snap.model_pools);
2166 };
2167 assert_eq!(argon.name, "Gemini 4 Argon");
2168 assert_eq!(argon.window.utilization_pct, 60);
2169 assert!(argon.is_weekly());
2170 }
2171
2172 #[test]
2175 fn a_malformed_model_pool_is_left_out() {
2176 let v: serde_json::Value = serde_json::from_str(
2177 r#"{"response":{"groups":[
2178 {"displayName":"Gemini Models","buckets":[
2179 {"bucketId":"gemini-weekly","window":"weekly","remainingFraction":0.5}]},
2180 {"displayName":"Gemini 4 Argon","buckets":[
2181 {"bucketId":"gemini-argon-wtu-weekly","window":"weekly","remainingFraction":"x"}]}
2182 ]}}"#,
2183 )
2184 .unwrap();
2185 let snap = parse_quota_summary(&v, "Pro".into()).unwrap();
2186 assert_eq!(snap.weekly.unwrap().utilization_pct, 50);
2187 assert!(snap.model_pools.is_empty());
2188 }
2189
2190 #[test]
2192 fn a_model_pool_name_is_sanitized_and_falls_back_to_the_bucket() {
2193 let v: serde_json::Value = serde_json::from_str(
2194 "{\"response\":{\"groups\":[
2195 {\"displayName\":\"Gemini Models\",\"buckets\":[
2196 {\"bucketId\":\"gemini-weekly\",\"window\":\"weekly\",\"remainingFraction\":0.5}]},
2197 {\"displayName\":\"Argon\\u001b[31m\",\"buckets\":[
2198 {\"bucketId\":\"argon-weekly\",\"window\":\"weekly\",\"remainingFraction\":0.5}]},
2199 {\"buckets\":[
2200 {\"bucketId\":\"nova-5h\",\"displayName\":\"Nova Five Hour\",\"window\":\"5h\",\"remainingFraction\":0.5}]}
2201 ]}}",
2202 )
2203 .unwrap();
2204 let snap = parse_quota_summary(&v, "Pro".into()).unwrap();
2205 let names: Vec<&str> = snap.model_pools.iter().map(|p| p.name.as_str()).collect();
2206 assert_eq!(names, ["Argon[31m", "Nova Five Hour"]);
2207 }
2208
2209 #[test]
2210 fn canonical_presence_disables_inference_in_a_shared_group() {
2211 for remaining in [serde_json::json!(0.1), serde_json::json!("unknown")] {
2212 let summary = serde_json::json!({"groups": [{
2213 "displayName": "Gemini Models",
2214 "buckets": [
2215 {"bucketId": "gemini-5h", "remainingFraction": 0.5},
2216 {"bucketId": "gemini-argon-wtu-weekly", "remainingFraction": remaining}
2217 ]
2218 }]});
2219 let snap = parse_quota_summary(&summary, "Pro".into()).unwrap();
2220 assert_eq!(snap.session.unwrap().utilization_pct, 50);
2221 assert!(snap.weekly.is_none());
2222 assert!(snap.model_pools.is_empty());
2223 }
2224 }
2225
2226 #[test]
2227 fn duplicate_or_unclassified_buckets_cannot_overwrite_a_slot() {
2228 let lookalike: serde_json::Value = serde_json::from_str(
2231 r#"{"response":{"groups":[{"displayName":"Gemini Models","buckets":[
2232 {"bucketId":"gemini-5h-copy","window":"5h"},
2233 {"bucketId":"gemini-5h","window":"5h","remainingFraction":0.9},
2234 {"bucketId":"gemini-weekly","window":"weekly","remainingFraction":0.8}
2235 ]}]}}"#,
2236 )
2237 .unwrap();
2238 let snap = parse_quota_summary(&lookalike, "Pro".into()).unwrap();
2239 assert_eq!(snap.session.as_ref().unwrap().utilization_pct, 10);
2240
2241 for buckets in [
2244 r#"{"bucketId":"gemini-5h","window":"5h","remainingFraction":0.9},
2245 {"bucketId":"gemini-5h","window":"5h","remainingFraction":0.1}"#,
2246 r#"{"bucketId":"x1","window":"5h","remainingFraction":0.9},
2247 {"bucketId":"x2","window":"5h","remainingFraction":0.1}"#,
2248 ] {
2249 let duplicate: serde_json::Value = serde_json::from_str(&format!(
2250 r#"{{"response":{{"groups":[{{"displayName":"Gemini Models","buckets":[{buckets}]}}]}}}}"#
2251 ))
2252 .unwrap();
2253 let err = parse_quota_summary(&duplicate, "Pro".into()).unwrap_err();
2254 assert!(err.to_string().contains("duplicate Gemini 5h"), "{err}");
2255 }
2256
2257 let unrelated: serde_json::Value = serde_json::from_str(
2260 r#"{"response":{"groups":[
2261 {"displayName":"Gemini Models","buckets":[
2262 {"bucketId":"gemini-5h","window":"5h","remainingFraction":0.9},
2263 {"bucketId":"gemini-weekly","window":"weekly","remainingFraction":0.8},
2264 {"bucketId":"gemini-monthly","window":"monthly","remainingFraction":0.7}
2265 ]},
2266 {"displayName":"Future Models","buckets":[
2267 {"bucketId":"future-5h","window":"5h","remainingFraction":0.1}
2268 ]}
2269 ]}}"#,
2270 )
2271 .unwrap();
2272 let snap = parse_quota_summary(&unrelated, "Pro".into()).unwrap();
2273 assert!(snap.third_party_session.is_none());
2274 assert!(snap.third_party_weekly.is_none());
2275 let [future] = snap.model_pools.as_slice() else {
2276 panic!("{:?}", snap.model_pools);
2277 };
2278 assert_eq!(future.name, "Future Models");
2279 assert!(!future.is_weekly());
2280 }
2281
2282 #[test]
2285 fn a_bucket_without_a_usable_fraction_is_rejected() {
2286 for bad in [r#""oops""#, "null", "-0.01", "1.01"] {
2287 let v: serde_json::Value = serde_json::from_str(&format!(
2288 r#"{{"response":{{"groups":[{{"displayName":"Gemini Models","buckets":[
2289 {{"bucketId":"gemini-5h","window":"5h","remainingFraction":{bad}}},
2290 {{"bucketId":"gemini-weekly","window":"weekly","remainingFraction":0.9}}]}}]}}}}"#
2291 ))
2292 .unwrap();
2293 let err = parse_quota_summary(&v, "Pro".into()).unwrap_err();
2294 assert!(err.to_string().contains("gemini-5h"), "{bad}: {err}");
2295 }
2296 }
2297
2298 #[test]
2299 fn malformed_present_reset_is_rejected_instead_of_disabling_expiry() {
2300 for bad in [serde_json::json!("not-a-time"), serde_json::json!(42)] {
2301 let mut v: serde_json::Value = serde_json::from_str(QUOTA_JSON).unwrap();
2302 v["response"]["groups"][0]["buckets"][0]["resetTime"] = bad;
2303 let err = parse_quota_summary(&v, "Pro".into()).unwrap_err();
2304 assert!(err.to_string().contains("resetTime"), "{err}");
2305 }
2306 }
2307
2308 #[test]
2312 fn a_weekly_only_snapshot_round_trips_through_the_cache() {
2313 let mut snap = parsed();
2314 snap.session = None;
2315 snap.third_party_session = None;
2316
2317 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
2318 let back = parse_cache_at(&bytes, None, now()).expect("weekly-only cache is usable");
2319
2320 assert!(back.session.is_none());
2321 assert_eq!(
2322 back.weekly.as_ref().unwrap().utilization_pct,
2323 snap.weekly.as_ref().unwrap().utilization_pct
2324 );
2325 }
2326
2327 #[test]
2332 fn a_product_reporting_only_weekly_buckets_still_renders_them() {
2333 let summary = serde_json::json!({
2334 "groups": [
2335 {
2336 "displayName": "Gemini Models",
2337 "buckets": [{
2338 "bucketId": "gemini-weekly", "window": "weekly",
2339 "remainingFraction": 0.42,
2340 }],
2341 },
2342 {
2343 "displayName": "Claude and GPT models",
2344 "buckets": [{
2345 "bucketId": "3p-weekly", "window": "weekly",
2346 "remainingFraction": 0.9,
2347 }],
2348 },
2349 ],
2350 });
2351
2352 let snap = parse_quota_summary(&summary, "Pro".into()).expect("weekly-only is usable");
2353
2354 assert!(snap.session.is_none(), "no 5h bucket arrived");
2355 assert!(snap.third_party_session.is_none());
2356 assert_eq!(snap.weekly.as_ref().unwrap().utilization_pct, 58);
2357 assert_eq!(
2358 snap.third_party_weekly.as_ref().unwrap().utilization_pct,
2359 10
2360 );
2361 }
2362
2363 #[test]
2366 fn a_product_reporting_only_five_hour_buckets_still_renders_them() {
2367 let summary = serde_json::json!({
2368 "groups": [{
2369 "displayName": "Gemini Models",
2370 "buckets": [{
2371 "bucketId": "gemini-5h", "window": "5h", "remainingFraction": 0.25,
2372 }],
2373 }],
2374 });
2375
2376 let snap = parse_quota_summary(&summary, "Pro".into()).expect("5h-only is usable");
2377
2378 assert_eq!(snap.session.as_ref().unwrap().utilization_pct, 75);
2379 assert!(snap.weekly.is_none());
2380 }
2381
2382 #[test]
2385 fn a_summary_with_no_recognisable_bucket_errors_and_names_what_it_had() {
2386 let summary = serde_json::json!({
2387 "groups": [{
2388 "displayName": "Gemini Models",
2389 "buckets": [{
2390 "bucketId": "gemini-daily", "window": "daily", "remainingFraction": 0.9,
2391 }],
2392 }],
2393 });
2394
2395 let rendered = parse_quota_summary(&summary, "Pro".into())
2396 .expect_err("an unrecognised cadence alone is not a snapshot")
2397 .to_string();
2398
2399 assert!(
2400 rendered.contains("no bucket in a window we recognise"),
2401 "{rendered}"
2402 );
2403 assert!(rendered.contains("gemini-daily"), "{rendered}");
2404 assert!(rendered.contains("window daily"), "{rendered}");
2405 }
2406
2407 #[test]
2410 fn a_summary_with_no_buckets_at_all_says_that_rather_than_listing_nothing() {
2411 let summary = serde_json::json!({
2412 "groups": [{"displayName": "Gemini", "buckets": []}],
2413 });
2414
2415 let rendered = parse_quota_summary(&summary, "Pro".into())
2416 .expect_err("no buckets is an error")
2417 .to_string();
2418
2419 assert!(rendered.contains("no buckets at all"), "{rendered}");
2420 assert!(!rendered.contains("it offered:"), "{rendered}");
2421 }
2422
2423 #[test]
2426 fn buckets_without_an_id_are_still_named_in_the_error() {
2427 let summary = serde_json::json!({
2428 "groups": [{"displayName": "", "buckets": [{"remainingFraction": 0.5}]}],
2429 });
2430
2431 let rendered = parse_quota_summary(&summary, "Pro".into())
2432 .expect_err("an unusable summary is an error")
2433 .to_string();
2434
2435 assert!(rendered.contains("<unnamed>"), "{rendered}");
2436 }
2437
2438 #[test]
2439 fn missing_gemini_buckets_is_an_error_not_a_zero_bar() {
2440 let v: serde_json::Value = serde_json::from_str(r#"{"response":{"groups":[]}}"#).unwrap();
2441 assert!(parse_quota_summary(&v, "Pro".into()).is_err());
2442 }
2443
2444 #[test]
2445 fn cache_round_trip_preserves_every_window() {
2446 let snap = parsed();
2447 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
2448 assert_eq!(parse_cache_at(&bytes, None, now()).unwrap(), snap);
2449 }
2450
2451 #[test]
2455 fn a_truncated_cached_payload_is_rejected_not_zeroed() {
2456 let full = snap_to_json(&parsed());
2457 for missing in ["session_pct", "weekly_pct"] {
2458 let mut v = full.clone();
2459 v.as_object_mut().unwrap().remove(missing);
2460 let bytes = serde_json::to_vec(&v).unwrap();
2461 let err = parse_cache_at(&bytes, None, now()).unwrap_err();
2462 assert!(err.to_string().contains(missing), "{missing}: {err}");
2463 }
2464 assert!(parse_cache_at(b"{}", None, now()).is_err());
2466 }
2467
2468 #[test]
2469 fn cached_percentages_are_range_checked_before_narrowing() {
2470 let full = snap_to_json(&parsed());
2471 for (key, bad) in [
2472 ("session_pct", serde_json::json!(-1)),
2473 ("weekly_pct", serde_json::json!(101)),
2474 ("session_pct", serde_json::json!(i64::MAX)),
2475 ("tp_session_pct", serde_json::json!("75")),
2476 ] {
2477 let mut v = full.clone();
2478 v[key] = bad;
2479 let bytes = serde_json::to_vec(&v).unwrap();
2480 let err = parse_cache_at(&bytes, None, now()).unwrap_err();
2481 assert!(err.to_string().contains(key), "{key}: {err}");
2482 }
2483 }
2484
2485 #[test]
2486 fn malformed_cached_reset_is_rejected_instead_of_served_for_a_week() {
2487 let mut v = snap_to_json(&parsed());
2488 v["session_reset"] = serde_json::json!("not-a-time");
2489 let bytes = serde_json::to_vec(&v).unwrap();
2490 let err = parse_cache_at(&bytes, None, now()).unwrap_err();
2491 assert!(err.to_string().contains("session_reset"), "{err}");
2492 }
2493
2494 #[test]
2499 fn a_cache_past_its_reset_is_refused() {
2500 let bytes = serde_json::to_vec(&snap_to_json(&parsed())).unwrap();
2501 let at = |s: &str| DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc);
2502
2503 assert!(parse_cache_at(&bytes, None, now()).is_ok());
2505 assert!(parse_cache_at(&bytes, None, at("2026-07-22T17:46:59Z")).is_ok());
2507
2508 let err = parse_cache_at(&bytes, None, at("2026-07-22T17:47:00Z")).unwrap_err();
2510 assert!(err.to_string().contains("5h"), "{err}");
2511
2512 assert!(parse_cache_at(&bytes, None, at("2026-07-23T09:00:00Z")).is_err());
2514 }
2515
2516 #[test]
2519 fn expiry_names_the_window_that_rolled_over() {
2520 let mut snap = parsed();
2521 snap.session.as_mut().unwrap().resets_at = None;
2523 snap.third_party_session = None;
2524 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
2525 let at = |s: &str| DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc);
2526
2527 assert!(parse_cache_at(&bytes, None, at("2026-07-23T09:00:00Z")).is_ok());
2529
2530 let err = parse_cache_at(&bytes, None, at("2026-07-28T18:00:00Z")).unwrap_err();
2532 assert!(err.to_string().contains("Gemini weekly"), "{err}");
2533 }
2534
2535 #[test]
2537 fn a_window_without_a_reset_never_expires() {
2538 let mut snap = parsed();
2539 for w in [&mut snap.session, &mut snap.weekly].into_iter().flatten() {
2540 w.resets_at = None;
2541 }
2542 snap.third_party_session = None;
2543 snap.third_party_weekly = None;
2544 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
2545 let far_future = DateTime::parse_from_rfc3339("2030-01-01T00:00:00Z")
2546 .unwrap()
2547 .with_timezone(&Utc);
2548 assert!(parse_cache_at(&bytes, None, far_future).is_ok());
2549 }
2550
2551 #[test]
2553 fn a_cache_from_another_account_is_rejected() {
2554 let mut snap = parsed();
2555 snap.account = "acct:aaaa".into();
2556 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
2557
2558 assert!(parse_cache_at(&bytes, Some("acct:bbbb"), now()).is_err());
2559 assert_eq!(
2560 parse_cache_at(&bytes, Some("acct:aaaa"), now()).unwrap(),
2561 snap
2562 );
2563
2564 let mut legacy = snap_to_json(&snap);
2566 legacy.as_object_mut().unwrap().remove("account");
2567 let legacy = serde_json::to_vec(&legacy).unwrap();
2568 assert!(parse_cache_at(&legacy, Some("acct:aaaa"), now()).is_err());
2569 }
2570
2571 #[test]
2574 fn an_unverifiable_cache_is_served_rather_than_discarded() {
2575 let mut snap = parsed();
2576 snap.account = "acct:aaaa".into();
2577 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
2578 assert_eq!(parse_cache_at(&bytes, None, now()).unwrap(), snap);
2579 }
2580
2581 #[test]
2582 fn account_key_fingerprints_rather_than_storing_the_address() {
2583 let with = |email: &str| account_key(&serde_json::json!({"userStatus": {"email": email}}));
2584 let a = with("someone@example.com");
2585 assert!(!a.contains("someone"), "{a}");
2586 assert!(!a.contains('@'), "{a}");
2587 assert_eq!(a, with("someone@example.com"), "must be stable");
2588 assert_ne!(a, with("other@example.com"));
2589 let unknown = account_key(&serde_json::json!({}));
2591 assert_eq!(unknown, account_key(&serde_json::json!({"userStatus": {}})));
2592 assert_ne!(unknown, a);
2593 }
2594
2595 #[test]
2598 fn absent_third_party_windows_are_not_treated_as_corruption() {
2599 let mut snap = parsed();
2600 snap.third_party_session = None;
2601 snap.third_party_weekly = None;
2602 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
2603 assert_eq!(parse_cache_at(&bytes, None, now()).unwrap(), snap);
2604 }
2605
2606 #[test]
2607 fn cache_round_trip_preserves_absent_third_party_windows() {
2608 let mut snap = parsed();
2609 snap.third_party_session = None;
2610 snap.third_party_weekly = None;
2611 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
2612 assert_eq!(parse_cache_at(&bytes, None, now()).unwrap(), snap);
2613 }
2614
2615 #[test]
2616 fn pct_used_inverts_valid_fractions() {
2617 assert_eq!(pct_used(1.0), 0);
2618 assert_eq!(pct_used(0.0), 100);
2619 assert_eq!(pct_used(0.5), 50);
2620 }
2621
2622 #[test]
2623 fn plan_falls_back_through_the_status_payload() {
2624 let tier: serde_json::Value =
2625 serde_json::from_str(r#"{"userStatus":{"userTier":{"name":"Google AI Pro"}}}"#)
2626 .unwrap();
2627 assert_eq!(plan_from_status(&tier), "Google AI Pro");
2628
2629 let plan_only: serde_json::Value = serde_json::from_str(
2630 r#"{"userStatus":{"planStatus":{"planInfo":{"planName":"Pro"}}}}"#,
2631 )
2632 .unwrap();
2633 assert_eq!(plan_from_status(&plan_only), "Pro");
2634
2635 let empty: serde_json::Value = serde_json::from_str("{}").unwrap();
2636 assert_eq!(plan_from_status(&empty), DEFAULT_PLAN);
2637 }
2638
2639 #[cfg(target_os = "linux")]
2640 #[test]
2641 fn proc_net_parser_keeps_only_listening_rows() {
2642 let listen = " 0: 0100007F:975B 00000000:0000 0A 00000000:00000000 \
2643 00:00000000 00000000 1000 0 123456 1 0000 100 0";
2644 assert_eq!(parse_proc_net_line(listen), Some((38747, 123456)));
2645
2646 let established = " 1: 0100007F:975B 0100007F:A1B2 01 00000000:00000000 \
2647 00:00000000 00000000 1000 0 123457 1 0000 100 0";
2648 assert_eq!(parse_proc_net_line(established), None);
2649
2650 assert_eq!(parse_proc_net_line("garbage"), None);
2651 }
2652
2653 #[test]
2654 fn explicit_address_comes_first_and_gets_a_scheme() {
2655 let candidate = |base: &str, pid| Candidate {
2656 base: base.to_string(),
2657 pid,
2658 ide: false,
2659 };
2660 assert_eq!(
2661 candidate_bases_with(Some("127.0.0.1:1234"), vec![(10, 5678)], &no_ide()),
2662 vec![
2663 candidate("http://127.0.0.1:1234", None),
2664 candidate("http://127.0.0.1:5678", Some(10)),
2665 ]
2666 );
2667 assert_eq!(
2669 candidate_bases_with(Some("127.0.0.1:1234/"), vec![(10, 5678)], &no_ide()),
2670 vec![
2671 candidate("http://127.0.0.1:1234", None),
2672 candidate("http://127.0.0.1:5678", Some(10)),
2673 ]
2674 );
2675 assert_eq!(
2678 candidate_bases_with(Some("127.0.0.1:5678"), vec![(10, 5678)], &no_ide()),
2679 vec![candidate("http://127.0.0.1:5678", Some(10))]
2680 );
2681 assert_eq!(
2683 candidate_bases_with(None, vec![(10, 5678), (10, 5678)], &no_ide()),
2684 vec![candidate("http://127.0.0.1:5678", Some(10))]
2685 );
2686 assert_eq!(
2688 candidate_bases_with(Some("https://host:9"), vec![], &no_ide()),
2689 vec![candidate("https://host:9", None)]
2690 );
2691 }
2692
2693 fn no_ide() -> std::collections::HashSet<u32> {
2696 std::collections::HashSet::new()
2697 }
2698
2699 fn http(status: u16) -> AppError {
2700 AppError::Http {
2701 status,
2702 body: String::new(),
2703 }
2704 }
2705
2706 fn missing_csrf() -> AppError {
2707 missing_csrf_with("missing CSRF token")
2708 }
2709
2710 fn missing_csrf_with(message: &str) -> AppError {
2711 AppError::Http {
2712 status: 401,
2713 body: serde_json::json!({
2714 "code": "unauthenticated",
2715 "message": message,
2716 })
2717 .to_string(),
2718 }
2719 }
2720
2721 #[test]
2722 fn only_agys_exact_missing_csrf_response_enables_remote_fallback() {
2723 assert!(is_missing_csrf(&missing_csrf()));
2724 assert!(is_missing_csrf(&missing_csrf_with(
2725 "unauthenticated: missing CSRF token"
2726 )));
2727 assert!(is_missing_csrf(&missing_csrf_with(
2728 " unauthenticated: missing CSRF token "
2729 )));
2730 assert!(!is_missing_csrf(&missing_csrf_with(
2731 "unauthenticated: invalid token"
2732 )));
2733 assert!(!is_missing_csrf(&AppError::Http {
2734 status: 403,
2735 body: r#"{"code":"unauthenticated","message":"missing CSRF token"}"#.into(),
2736 }));
2737 assert!(!is_missing_csrf(&AppError::Http {
2738 status: 401,
2739 body: r#"{"code":"other","message":"missing CSRF token"}"#.into(),
2740 }));
2741 assert!(!is_missing_csrf(&AppError::Http {
2742 status: 401,
2743 body: "prefix: missing CSRF token".into(),
2744 }));
2745 }
2746
2747 #[test]
2748 fn a_real_auth_failure_outranks_agys_missing_csrf_response() {
2749 for errors in [
2750 vec![missing_csrf(), http(403)],
2751 vec![http(401), missing_csrf()],
2752 ] {
2753 let err = select_probe_error(errors);
2754 assert!(is_actionable(&err), "{err}");
2755 assert!(!is_missing_csrf(&err), "{err}");
2756 }
2757 }
2758
2759 #[test]
2760 fn several_agy_sessions_still_select_the_remote_fallback_reason() {
2761 let err = select_probe_error(vec![missing_csrf(), missing_csrf()]);
2762 assert!(is_missing_csrf(&err), "{err}");
2763 assert!(remote_fallback_reason(&err).is_some(), "{err}");
2764 }
2765
2766 #[test]
2770 fn an_auth_failure_outranks_later_transport_noise() {
2771 let err = select_probe_error(vec![
2772 http(401),
2773 AppError::Transport("connection refused".into()),
2774 ]);
2775 assert!(matches!(err, AppError::Http { status: 401, .. }), "{err}");
2776
2777 let err = select_probe_error(vec![
2778 AppError::Transport("connection refused".into()),
2779 http(403),
2780 ]);
2781 assert!(matches!(err, AppError::Http { status: 403, .. }), "{err}");
2782 }
2783
2784 #[test]
2787 fn the_first_auth_failure_wins() {
2788 let err = select_probe_error(vec![http(401), http(403)]);
2789 assert!(matches!(err, AppError::Http { status: 401, .. }), "{err}");
2790 }
2791
2792 #[test]
2796 fn without_an_auth_failure_the_last_error_stands() {
2797 let err = select_probe_error(vec![
2798 AppError::Transport("first".into()),
2799 http(500),
2800 AppError::Transport("last".into()),
2801 ]);
2802 assert!(
2803 matches!(&err, AppError::Transport(m) if m == "last"),
2804 "{err}"
2805 );
2806 assert!(err.is_transient());
2807 }
2808
2809 #[test]
2812 fn a_server_error_is_not_treated_as_actionable() {
2813 let err = select_probe_error(vec![http(500), http(401)]);
2814 assert!(matches!(err, AppError::Http { status: 401, .. }), "{err}");
2815 }
2816
2817 #[test]
2818 fn no_candidates_at_all_yields_a_generic_error() {
2819 let err = select_probe_error(Vec::new());
2820 assert!(
2821 err.to_string().contains("no local server answered"),
2822 "{err}"
2823 );
2824 }
2825
2826 fn tls_echo() -> AppError {
2830 AppError::Http {
2831 status: 400,
2832 body: "Client sent an HTTP request to an HTTPS server.\n".into(),
2833 }
2834 }
2835
2836 #[test]
2841 fn a_tls_echo_does_not_mask_what_the_rpc_listener_said() {
2842 let err = select_probe_error(vec![
2843 AppError::Http {
2844 status: 500,
2845 body: "GetUserStatus: internal".into(),
2846 },
2847 tls_echo(),
2848 ]);
2849 assert!(
2850 matches!(&err, AppError::Http { status: 500, body } if body.contains("internal")),
2851 "{err}"
2852 );
2853 }
2854
2855 #[test]
2860 fn a_tls_echo_does_not_cost_the_silent_fallback() {
2861 let err = select_probe_error(vec![
2862 AppError::Transport("connection refused".into()),
2863 tls_echo(),
2864 ]);
2865 assert!(
2866 matches!(&err, AppError::Transport(m) if m == "connection refused"),
2867 "{err}"
2868 );
2869 assert!(
2870 err.is_transient(),
2871 "the echo must not make the run non-transient: {err}"
2872 );
2873 }
2874
2875 #[test]
2878 fn a_tls_echo_still_stands_when_it_is_the_only_thing_that_answered() {
2879 let err = select_probe_error(vec![tls_echo()]);
2880 assert!(matches!(err, AppError::Http { status: 400, .. }), "{err}");
2881 }
2882
2883 #[test]
2886 fn a_genuine_bad_request_is_not_mistaken_for_a_tls_echo() {
2887 let err = select_probe_error(vec![
2888 AppError::Http {
2889 status: 400,
2890 body: "unknown method GetUserStatus".into(),
2891 },
2892 tls_echo(),
2893 ]);
2894 assert!(
2895 matches!(&err, AppError::Http { status: 400, body } if body.contains("unknown method")),
2896 "{err}"
2897 );
2898 }
2899
2900 #[test]
2902 fn an_auth_failure_still_outranks_a_tls_echo() {
2903 let err = select_probe_error(vec![tls_echo(), http(401)]);
2904 assert!(matches!(err, AppError::Http { status: 401, .. }), "{err}");
2905 }
2906
2907 #[test]
2908 fn every_discovered_port_is_probed_in_order() {
2909 assert_eq!(
2910 candidate_bases_with(None, vec![(10, 33875), (10, 37435)], &no_ide()),
2911 vec![
2912 Candidate {
2913 base: "http://127.0.0.1:33875".to_string(),
2914 pid: Some(10),
2915 ide: false,
2916 },
2917 Candidate {
2918 base: "http://127.0.0.1:37435".to_string(),
2919 pid: Some(10),
2920 ide: false,
2921 },
2922 ]
2923 );
2924 }
2925
2926 #[test]
2931 fn empty_discovery_yields_no_candidates() {
2932 assert!(candidate_bases_with(None, vec![], &no_ide()).is_empty());
2933 assert!(candidate_bases_with(Some(""), vec![], &no_ide()).is_empty());
2934 }
2935
2936 #[test]
2937 fn every_antigravity_product_is_recognised() {
2938 assert!(is_antigravity_process(
2940 "language_server\n",
2941 Some("/opt/antigravity/resources/bin/language_server")
2942 ));
2943 assert!(is_antigravity_process(
2945 "agy\n",
2946 Some("/home/u/.local/bin/agy")
2947 ));
2948 assert!(is_antigravity_process(
2950 "node",
2951 Some("/opt/antigravity/bin/helper")
2952 ));
2953 assert!(is_antigravity_process("antigravity", None));
2954 assert!(is_antigravity_process("agy.exe", None));
2955 assert!(is_antigravity_process("Antigravity.exe", None));
2956 assert!(is_antigravity_process("language_server.exe", None));
2957 assert!(is_antigravity_process(
2958 "language_server_windows_x64.exe",
2959 None
2960 ));
2961 assert!(is_antigravity_process(
2962 "node.exe",
2963 Some(r"C:\Users\u\AppData\Local\agy.exe")
2964 ));
2965 }
2966
2967 #[test]
2968 fn unrelated_processes_are_not_probed() {
2969 assert!(!is_antigravity_process("sshd", Some("/usr/sbin/sshd")));
2970 assert!(!is_antigravity_process("node", Some("/usr/bin/node")));
2971 assert!(!is_antigravity_process("legacy", Some("/usr/bin/legacy")));
2973 assert!(!is_antigravity_process("legacy.exe", None));
2974 assert!(!is_antigravity_process("not-agy.exe", None));
2975 assert!(!is_antigravity_process("", None));
2976 }
2977
2978 #[test]
2979 fn windows_process_names_decode_until_nul_and_tolerate_invalid_utf16() {
2980 let mut raw: Vec<u16> = "agy.exe".encode_utf16().collect();
2981 raw.extend([0, b'x' as u16]);
2982 assert_eq!(decode_windows_process_name(&raw), "agy.exe");
2983 assert_eq!(decode_windows_process_name(&[0xd800]), "�");
2984 assert_eq!(decode_windows_process_name(&[]), "");
2985 }
2986
2987 #[test]
2988 fn windows_process_filter_keeps_only_antigravity_pids() {
2989 let processes = vec![
2990 (10, "agy.exe".to_string()),
2991 (20, "language_server_windows_x64.exe".to_string()),
2992 (30, "sshd.exe".to_string()),
2993 ];
2994 let pids = matching_windows_process_ids(&processes);
2995 assert_eq!(pids, std::collections::HashSet::from([10, 20]));
2996 }
2997
2998 #[test]
2999 fn windows_listener_filter_joins_pid_loopback_and_port() {
3000 let pids = std::collections::HashSet::from([10]);
3001 let rows = [
3002 WindowsTcpRow {
3003 local_addr: [127, 0, 0, 1],
3004 local_port: u32::from(59870u16.to_be()),
3005 pid: 10,
3006 },
3007 WindowsTcpRow {
3008 local_addr: [127, 0, 0, 1],
3009 local_port: u32::from(59868u16.to_be()),
3010 pid: 10,
3011 },
3012 WindowsTcpRow {
3013 local_addr: [127, 0, 0, 1],
3014 local_port: u32::from(59870u16.to_be()),
3015 pid: 10,
3016 },
3017 WindowsTcpRow {
3018 local_addr: [0, 0, 0, 0],
3019 local_port: u32::from(50000u16.to_be()),
3020 pid: 10,
3021 },
3022 WindowsTcpRow {
3023 local_addr: [127, 0, 0, 1],
3024 local_port: u32::from(50001u16.to_be()),
3025 pid: 99,
3026 },
3027 WindowsTcpRow {
3028 local_addr: [127, 0, 0, 1],
3029 local_port: 0,
3030 pid: 10,
3031 },
3032 ];
3033 assert_eq!(
3034 matching_windows_ports(&pids, &rows),
3035 vec![(10, 59870), (10, 59868)]
3036 );
3037 }
3038
3039 #[test]
3043 fn windows_ports_from_two_products_keep_tls_listeners_last() {
3044 let pids = std::collections::HashSet::from([10, 20]);
3045 let row = |port: u16, pid: u32| WindowsTcpRow {
3046 local_addr: [127, 0, 0, 1],
3047 local_port: u32::from(port.to_be()),
3048 pid,
3049 };
3050 let rows = [
3051 row(40000, 10),
3052 row(40001, 10),
3053 row(50000, 20),
3054 row(50001, 20),
3055 ];
3056 assert_eq!(
3057 matching_windows_ports(&pids, &rows),
3058 vec![(10, 40001), (20, 50001), (10, 40000), (20, 50000)]
3059 );
3060 }
3061
3062 #[test]
3066 fn probe_order_puts_every_rpc_listener_ahead_of_every_tls_listener() {
3067 use std::collections::BTreeMap;
3068
3069 assert_eq!(
3071 probe_order(BTreeMap::from([(10, vec![59868, 59870])])),
3072 vec![(10, 59870), (10, 59868)]
3073 );
3074 assert_eq!(
3079 probe_order(BTreeMap::from([
3080 (10, vec![40000, 40001]),
3081 (20, vec![50000, 50001]),
3082 ])),
3083 vec![(10, 40001), (20, 50001), (10, 40000), (20, 50000)]
3084 );
3085 assert_eq!(
3088 probe_order(BTreeMap::from([
3089 (10, vec![6000, 5000, 4000]),
3090 (20, vec![6000, 7000]),
3091 ])),
3092 vec![(10, 6000), (20, 7000), (10, 5000), (10, 4000)]
3093 );
3094 assert!(probe_order(BTreeMap::new()).is_empty());
3095 }
3096
3097 #[test]
3102 fn a_port_named_twice_by_one_product_still_occupies_one_rank() {
3103 use std::collections::BTreeMap;
3104
3105 assert_eq!(
3106 probe_order(BTreeMap::from([
3107 (10, vec![40001, 40001, 40000, 40000]),
3108 (20, vec![50001, 50000]),
3109 ])),
3110 vec![(10, 40001), (20, 50001), (10, 40000), (20, 50000)],
3111 "duplicate rows must not reorder the ranks below them"
3112 );
3113 }
3114
3115 #[test]
3120 fn a_half_started_product_is_the_documented_exception() {
3121 use std::collections::BTreeMap;
3122
3123 assert_eq!(
3124 probe_order(BTreeMap::from([
3125 (10, vec![40000]),
3126 (20, vec![50001, 50000])
3127 ])),
3128 vec![(10, 40000), (20, 50001), (20, 50000)]
3129 );
3130 }
3131
3132 #[test]
3137 fn an_override_with_no_authority_is_dropped_not_probed() {
3138 for junk in ["/", "///", "http://", "https://", " / "] {
3139 assert_eq!(
3140 candidate_bases_with(Some(junk), vec![(10, 4242)], &no_ide()),
3141 vec![Candidate {
3142 base: "http://127.0.0.1:4242".to_string(),
3143 pid: Some(10),
3144 ide: false,
3145 }],
3146 "{junk:?} should not survive as a candidate"
3147 );
3148 }
3149 assert!(candidate_bases_with(Some("/"), vec![], &no_ide()).is_empty());
3150 }
3151
3152 #[test]
3153 fn windows_table_bounds_reject_truncation_and_overflow() {
3154 assert_eq!(checked_windows_row_count(52, 4, 24, 2), Some(2));
3155 assert_eq!(checked_windows_row_count(51, 4, 24, 2), None);
3156 assert_eq!(checked_windows_row_count(4, 4, 24, 0), Some(0));
3157 assert_eq!(checked_windows_row_count(52, 4, 0, 2), None);
3158 assert_eq!(
3159 checked_windows_row_count(usize::MAX, 4, 24, usize::MAX),
3160 None
3161 );
3162 }
3163
3164 #[cfg(target_os = "windows")]
3165 #[test]
3166 fn windows_tcp_table_parser_copies_complete_rows_only() {
3167 use std::mem::{offset_of, size_of};
3168 use windows_sys::Win32::NetworkManagement::IpHelper::{
3169 MIB_TCPROW_OWNER_PID, MIB_TCPTABLE_OWNER_PID,
3170 };
3171
3172 let offset = offset_of!(MIB_TCPTABLE_OWNER_PID, table);
3173 let used = offset + 2 * size_of::<MIB_TCPROW_OWNER_PID>();
3174 let words = used.div_ceil(size_of::<u32>());
3175 let mut buffer = vec![0u32; words];
3176 let first = MIB_TCPROW_OWNER_PID {
3177 dwLocalAddr: u32::from_ne_bytes([127, 0, 0, 1]),
3178 dwLocalPort: u32::from(59868u16.to_be()),
3179 dwOwningPid: 10,
3180 ..Default::default()
3181 };
3182 let second = MIB_TCPROW_OWNER_PID {
3183 dwLocalAddr: u32::from_ne_bytes([127, 0, 0, 1]),
3184 dwLocalPort: u32::from(59870u16.to_be()),
3185 dwOwningPid: 10,
3186 ..Default::default()
3187 };
3188 unsafe {
3189 buffer.as_mut_ptr().write_unaligned(2);
3190 let rows = buffer
3191 .as_mut_ptr()
3192 .cast::<u8>()
3193 .add(offset)
3194 .cast::<MIB_TCPROW_OWNER_PID>();
3195 rows.write_unaligned(first);
3196 rows.add(1).write_unaligned(second);
3197 }
3198
3199 assert_eq!(
3200 parse_windows_tcp_rows(&buffer, used),
3201 vec![
3202 WindowsTcpRow {
3203 local_addr: [127, 0, 0, 1],
3204 local_port: u32::from(59868u16.to_be()),
3205 pid: 10,
3206 },
3207 WindowsTcpRow {
3208 local_addr: [127, 0, 0, 1],
3209 local_port: u32::from(59870u16.to_be()),
3210 pid: 10,
3211 },
3212 ]
3213 );
3214 assert!(parse_windows_tcp_rows(&buffer, used - 1).is_empty());
3215 }
3216
3217 #[test]
3218 fn lsof_parser_keeps_only_ports_owned_by_antigravity_processes() {
3219 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";
3222 assert_eq!(
3223 parse_lsof_pcn(output),
3224 vec![(74101, 61290), (74101, 61289), (74101, 8829)]
3225 );
3226 }
3227
3228 #[test]
3232 fn lsof_parser_keeps_each_products_ports_in_its_own_group() {
3233 let output = concat!(
3234 "p100\ncagy\nf3\nn127.0.0.1:40000\nf4\nn127.0.0.1:40001\n",
3235 "p200\nclanguage_server\nf5\nn127.0.0.1:50000\nf6\nn127.0.0.1:50001\n",
3236 );
3237 assert_eq!(
3238 parse_lsof_pcn(output),
3239 vec![(100, 40001), (200, 50001), (100, 40000), (200, 50000)],
3240 "both HTTP listeners must precede both TLS listeners"
3241 );
3242 }
3243
3244 #[test]
3245 fn lsof_parser_matches_the_capitalised_macos_app_name() {
3246 let output = "p900\ncAntigravity\nf7\nn127.0.0.1:54321\n";
3247 assert_eq!(parse_lsof_pcn(output), vec![(900, 54321)]);
3248 }
3249
3250 #[test]
3251 fn lsof_parser_deduplicates_and_handles_empty_output() {
3252 let output = "p1\ncagy\nf3\nn127.0.0.1:9000\nf4\nn127.0.0.1:9000\n";
3253 assert_eq!(parse_lsof_pcn(output), vec![(1, 9000)]);
3254 assert!(parse_lsof_pcn("").is_empty());
3255 }
3256
3257 #[test]
3261 fn macos_system_tools_are_pinned_to_trusted_paths() {
3262 let sources = [
3263 crate::guard::production_code(include_str!("fetch.rs")),
3264 crate::guard::production_code(include_str!("../tray/tui_launch_macos.rs")),
3265 crate::guard::production_code(include_str!("../tray/browse.rs")),
3266 crate::guard::production_code(include_str!("../nous/oauth.rs")),
3267 crate::guard::production_code(include_str!("../../macos/ai-usagebar-menubar.swift")),
3268 ];
3269 let code = sources.join("\n");
3270 for bare in [
3271 r#"Command::new("lsof")"#,
3272 r#"Command::new("ps")"#,
3273 r#"Command::new("open")"#,
3274 r#"Command::new("osascript")"#,
3275 r#"do script \"bash "#,
3276 ] {
3277 assert!(!code.contains(bare), "{bare} looks the tool up on PATH");
3278 }
3279 for pinned in [
3280 r#"Command::new("/usr/sbin/lsof")"#,
3281 r#"Command::new("/bin/ps")"#,
3282 r#"Command::new("/usr/bin/open")"#,
3283 r#"Command::new("/usr/bin/osascript")"#,
3284 r#"do script \"/bin/bash "#,
3285 ] {
3286 assert!(
3287 code.contains(pinned),
3288 "missing trusted system path: {pinned}"
3289 );
3290 }
3291 }
3292
3293 #[test]
3296 fn missing_cache_surfaces_the_diagnosis_not_a_cache_miss() {
3297 let dir = tempfile::tempdir().unwrap();
3298 let cache = Cache::at(dir.path().join("usage.json"));
3299 let reason = AppError::Credentials("Antigravity: no local language server found".into());
3300
3301 let err = fallback_with_error(&cache, None, reason, now()).unwrap_err();
3302 let msg = err.to_string();
3303 assert!(msg.contains("no local language server found"), "{msg}");
3304 assert!(!msg.contains("no usable cache"), "{msg}");
3305 }
3306
3307 #[test]
3308 fn unusable_cache_does_not_replace_the_live_diagnosis() {
3309 let dir = tempfile::tempdir().unwrap();
3310 let cache = Cache::at(dir.path().join("antigravity"));
3311 cache.write_payload(b"{}").unwrap();
3312
3313 let reason = AppError::Credentials("Antigravity must be running".into());
3314 let err = fallback_with_error(&cache, None, reason, now()).unwrap_err();
3315 assert!(err.to_string().contains("must be running"), "{err}");
3316
3317 let original = AppError::Transport("original loopback failure".into());
3318 let err = fallback_silent(&cache, now(), original).unwrap_err();
3319 assert!(
3320 err.to_string().contains("original loopback failure"),
3321 "{err}"
3322 );
3323 }
3324
3325 #[tokio::test]
3326 async fn rpc_error_bodies_are_bounded_too() {
3327 let mut server = mockito::Server::new_async().await;
3328 let path = format!("/{STATUS_RPC}");
3329 server
3330 .mock("POST", path.as_str())
3331 .with_status(500)
3332 .with_body("x".repeat(crate::vendor::MAX_BODY_BYTES + 1))
3333 .create_async()
3334 .await;
3335
3336 let err = post_rpc(&reqwest::Client::new(), &server.url(), None, STATUS_RPC)
3337 .await
3338 .unwrap_err();
3339 assert!(err.to_string().contains("exceeds"), "{err}");
3340 }
3341
3342 #[test]
3343 fn blank_override_falls_through_to_discovery() {
3344 assert_eq!(
3345 candidate_bases_with(Some(" "), vec![(10, 4242)], &no_ide()),
3346 vec![Candidate {
3347 base: "http://127.0.0.1:4242".to_string(),
3348 pid: Some(10),
3349 ide: false,
3350 }]
3351 );
3352 }
3353
3354 #[test]
3357 fn only_the_ide_install_is_recognised() {
3358 assert!(is_ide_install(
3359 "/opt/antigravity-ide/resources/app/extensions/antigravity/bin/language_server_linux_x64"
3360 ));
3361 assert!(is_ide_install(
3362 "/Applications/Antigravity IDE.app/Contents/Resources/app/extensions/antigravity/bin/language_server_darwin_arm64"
3363 ));
3364 assert!(is_ide_install(
3365 r"C:\Users\u\AppData\Local\Programs\Antigravity IDE\language_server_windows_x64.exe"
3366 ));
3367 assert!(!is_ide_install(
3368 "/opt/antigravity/resources/bin/language_server"
3369 ));
3370 assert!(!is_ide_install("/home/u/.local/bin/agy"));
3371 assert!(!is_ide_install("/usr/bin/node"));
3372 }
3373
3374 #[test]
3376 fn ps_output_pairs_pids_with_full_paths() {
3377 let output = " 101 /opt/antigravity-ide/bin/language_server_linux_x64\n\
3378 202 /Applications/Antigravity IDE.app/Contents/MacOS/Antigravity IDE\n";
3379 assert_eq!(
3380 parse_ps_comm(output),
3381 vec![
3382 (
3383 101,
3384 "/opt/antigravity-ide/bin/language_server_linux_x64".to_string()
3385 ),
3386 (
3387 202,
3388 "/Applications/Antigravity IDE.app/Contents/MacOS/Antigravity IDE".to_string()
3389 ),
3390 ]
3391 );
3392 assert!(parse_ps_comm("").is_empty());
3393 assert!(parse_ps_comm("not-a-pid /tmp/x").is_empty());
3394 }
3395
3396 #[test]
3400 fn ide_owned_listeners_are_marked_but_an_explicit_override_is_not() {
3401 let ide: std::collections::HashSet<u32> = [10].into_iter().collect();
3402
3403 let candidates = candidate_bases_with(None, vec![(10, 40001), (20, 50001)], &ide);
3404 assert_eq!(candidates.len(), 2);
3405 assert!(candidates[0].ide, "pid 10 is the IDE's language server");
3406 assert!(!candidates[1].ide);
3407
3408 let candidates = candidate_bases_with(Some("127.0.0.1:40001"), vec![(10, 40001)], &ide);
3409 assert_eq!(
3410 candidates,
3411 vec![Candidate {
3412 base: "http://127.0.0.1:40001".to_string(),
3413 pid: Some(10),
3414 ide: false,
3415 }],
3416 "ANTIGRAVITY_LS_ADDRESS is an explicit target, never skipped"
3417 );
3418 }
3419
3420 fn fixture() -> (tempfile::TempDir, Cache) {
3425 let td = tempfile::TempDir::new().unwrap();
3426 let cache = Cache::at(td.path().join("antigravity"));
3427 (td, cache)
3428 }
3429
3430 fn endpoints(server: &mockito::Server) -> cloud::Endpoints {
3431 let base = server.url();
3432 cloud::Endpoints {
3433 quota: vec![format!("{base}/daily/quota"), format!("{base}/prod/quota")],
3434 load_code_assist: vec![format!("{base}/daily/plan"), format!("{base}/prod/plan")],
3435 token: format!("{base}/token"),
3436 }
3437 }
3438
3439 fn keyring_blob(expiry: &str, with_refresh: bool) -> String {
3441 let mut token = serde_json::json!({
3442 "access_token": "KEYRING-AT",
3443 "expiry": expiry,
3444 });
3445 if with_refresh {
3446 token["refresh_token"] = serde_json::json!("KEYRING-RT");
3447 }
3448 serde_json::json!({ "token": token }).to_string()
3449 }
3450
3451 const EXPIRED: &str = "2026-07-22T11:00:00Z";
3453 const VALID: &str = "2026-07-22T13:00:00Z";
3455
3456 fn remote<'a>(blob: &'a str, eps: &'a cloud::Endpoints) -> RemoteOverride<'a> {
3458 RemoteOverride {
3459 credential: SavedCredential::Blob(blob),
3460 endpoints: Some(eps),
3461 local_bases: Some(vec![]),
3462 ..Default::default()
3463 }
3464 }
3465
3466 fn bare_summary() -> String {
3469 let v: serde_json::Value = serde_json::from_str(QUOTA_JSON).unwrap();
3470 v["response"].to_string()
3471 }
3472
3473 fn quota_mock(server: &mut mockito::Server, bearer: &str) -> mockito::Mock {
3474 server
3475 .mock("POST", "/daily/quota")
3476 .match_header("authorization", format!("Bearer {bearer}").as_str())
3477 .with_status(200)
3478 .with_body(bare_summary())
3479 }
3480
3481 fn token_mock(server: &mut mockito::Server) -> mockito::Mock {
3482 server
3483 .mock("POST", "/token")
3484 .with_status(200)
3485 .with_body(r#"{"access_token":"NEW-AT","expires_in":3600}"#)
3486 }
3487
3488 fn test_oauth() -> cloud::OauthClient {
3489 cloud::OauthClient {
3490 id: "test-client".into(),
3491 secret: "test-client-secret".into(),
3492 }
3493 }
3494
3495 async fn run(cache: &Cache, remote: RemoteOverride<'_>, ttl: Duration) -> Result<FetchOutcome> {
3496 fetch_snapshot_at(
3497 &reqwest::Client::new(),
3498 cache,
3499 ttl,
3500 Some(&test_oauth()),
3501 remote,
3502 now(),
3503 )
3504 .await
3505 }
3506
3507 #[tokio::test]
3508 async fn with_no_local_server_the_saved_session_answers_from_the_api() {
3509 let mut server = mockito::Server::new_async().await;
3510 let eps = endpoints(&server);
3511 let quota = quota_mock(&mut server, "KEYRING-AT")
3512 .expect(1)
3513 .create_async()
3514 .await;
3515 let plan = server
3516 .mock("POST", "/daily/plan")
3517 .match_header("authorization", "Bearer KEYRING-AT")
3518 .with_status(200)
3519 .with_body(r#"{"currentTier":{"name":"google_ai_pro"}}"#)
3520 .expect(1)
3521 .create_async()
3522 .await;
3523 let token = token_mock(&mut server).expect(0).create_async().await;
3525 let blob = keyring_blob(VALID, true);
3526 let (_td, cache) = fixture();
3527
3528 let outcome = run(&cache, remote(&blob, &eps), Duration::from_secs(60))
3529 .await
3530 .expect("remote path yields a snapshot");
3531
3532 quota.assert_async().await;
3533 plan.assert_async().await;
3534 token.assert_async().await;
3535 let snap = outcome.snapshot;
3536 assert!(!outcome.stale);
3537 assert_eq!(snap.source, AntigravitySource::Remote);
3538 assert_eq!(snap.plan, "Pro");
3539 assert_eq!(snap.session.as_ref().unwrap().utilization_pct, 43);
3540 assert_eq!(snap.weekly.as_ref().unwrap().utilization_pct, 8);
3541 assert_eq!(snap.third_party_session.unwrap().utilization_pct, 75);
3542 assert_eq!(snap.third_party_weekly.unwrap().utilization_pct, 0);
3543 let fingerprint = credential::parse_keyring_blob(&blob).unwrap().fingerprint;
3544 assert_eq!(snap.account, format!("acct:{fingerprint}"));
3545 assert!(!snap.account.contains("KEYRING"), "{}", snap.account);
3546
3547 let cached = parse_cache_at(
3549 &cache
3550 .fresh_payload(Duration::from_secs(60))
3551 .unwrap()
3552 .unwrap(),
3553 None,
3554 now(),
3555 )
3556 .unwrap();
3557 assert_eq!(cached.source, AntigravitySource::Remote);
3558 assert_eq!(cached.account, snap.account);
3559 }
3560
3561 #[tokio::test]
3566 async fn agys_missing_csrf_response_uses_the_saved_session() {
3567 let mut server = mockito::Server::new_async().await;
3568 let eps = endpoints(&server);
3569 let root = server
3570 .mock("GET", "/")
3571 .with_status(404)
3572 .expect(1)
3573 .create_async()
3574 .await;
3575 let status_path = format!("/{STATUS_RPC}");
3576 let status = server
3577 .mock("POST", status_path.as_str())
3578 .with_status(401)
3579 .with_body(r#"{"code":"unauthenticated","message":"missing CSRF token"}"#)
3580 .expect(1)
3581 .create_async()
3582 .await;
3583 let quota = quota_mock(&mut server, "KEYRING-AT")
3584 .expect(1)
3585 .create_async()
3586 .await;
3587 let plan = server
3588 .mock("POST", "/daily/plan")
3589 .match_header("authorization", "Bearer KEYRING-AT")
3590 .with_status(200)
3591 .with_body(r#"{"currentTier":{"name":"google_ai_pro"}}"#)
3592 .expect(1)
3593 .create_async()
3594 .await;
3595 let blob = keyring_blob(VALID, true);
3596 let (_td, cache) = fixture();
3597
3598 let outcome = run(
3599 &cache,
3600 RemoteOverride {
3601 credential: SavedCredential::Blob(&blob),
3602 endpoints: Some(&eps),
3603 local_bases: Some(vec![server.url().into()]),
3604 ..Default::default()
3605 },
3606 Duration::ZERO,
3607 )
3608 .await
3609 .expect("saved session bypasses agy's unusable local RPC");
3610
3611 root.assert_async().await;
3612 status.assert_async().await;
3613 quota.assert_async().await;
3614 plan.assert_async().await;
3615 assert_eq!(outcome.snapshot.source, AntigravitySource::Remote);
3616 }
3617
3618 #[tokio::test]
3622 async fn agys_prefixed_missing_csrf_response_uses_the_saved_session() {
3623 let mut server = mockito::Server::new_async().await;
3624 let eps = endpoints(&server);
3625 let root = server
3626 .mock("GET", "/")
3627 .with_status(404)
3628 .expect(1)
3629 .create_async()
3630 .await;
3631 let status_path = format!("/{STATUS_RPC}");
3632 let status = server
3633 .mock("POST", status_path.as_str())
3634 .with_status(401)
3635 .with_body(
3636 r#"{"code":"unauthenticated","message":"unauthenticated: missing CSRF token"}"#,
3637 )
3638 .expect(1)
3639 .create_async()
3640 .await;
3641 let quota = quota_mock(&mut server, "KEYRING-AT")
3642 .expect(1)
3643 .create_async()
3644 .await;
3645 let plan = server
3646 .mock("POST", "/daily/plan")
3647 .match_header("authorization", "Bearer KEYRING-AT")
3648 .with_status(200)
3649 .with_body(r#"{"currentTier":{"name":"google_ai_pro"}}"#)
3650 .expect(1)
3651 .create_async()
3652 .await;
3653 let blob = keyring_blob(VALID, true);
3654 let (_td, cache) = fixture();
3655
3656 let outcome = run(
3657 &cache,
3658 RemoteOverride {
3659 credential: SavedCredential::Blob(&blob),
3660 endpoints: Some(&eps),
3661 local_bases: Some(vec![server.url().into()]),
3662 ..Default::default()
3663 },
3664 Duration::ZERO,
3665 )
3666 .await
3667 .expect("saved session bypasses agy 1.3.1's prefixed missing-CSRF RPC");
3668
3669 root.assert_async().await;
3670 status.assert_async().await;
3671 quota.assert_async().await;
3672 plan.assert_async().await;
3673 assert_eq!(outcome.snapshot.source, AntigravitySource::Remote);
3674 }
3675
3676 #[tokio::test]
3677 async fn agys_missing_csrf_without_a_saved_session_explains_both_options() {
3678 let mut server = mockito::Server::new_async().await;
3679 let eps = endpoints(&server);
3680 let root = server
3681 .mock("GET", "/")
3682 .with_status(404)
3683 .expect(1)
3684 .create_async()
3685 .await;
3686 let status_path = format!("/{STATUS_RPC}");
3687 let status = server
3688 .mock("POST", status_path.as_str())
3689 .with_status(401)
3690 .with_body(r#"{"code":"unauthenticated","message":"missing CSRF token"}"#)
3691 .expect(1)
3692 .create_async()
3693 .await;
3694 let quota = quota_mock(&mut server, "KEYRING-AT")
3695 .expect(0)
3696 .create_async()
3697 .await;
3698 let (_td, cache) = fixture();
3699
3700 let error = run(
3701 &cache,
3702 RemoteOverride {
3703 credential: SavedCredential::Absent,
3704 endpoints: Some(&eps),
3705 local_bases: Some(vec![server.url().into()]),
3706 ..Default::default()
3707 },
3708 Duration::ZERO,
3709 )
3710 .await
3711 .expect_err("neither the local RPC nor a saved session is usable");
3712
3713 root.assert_async().await;
3714 status.assert_async().await;
3715 quota.assert_async().await;
3716 let message = error.to_string();
3717 assert!(message.contains("requires a CSRF token"), "{message}");
3718 assert!(message.contains("saved Google session"), "{message}");
3719 }
3720
3721 #[tokio::test]
3726 async fn agys_missing_csrf_aborts_further_candidate_probes_to_spare_tls_listeners() {
3727 let mut server1 = mockito::Server::new_async().await;
3728 let mut server2 = mockito::Server::new_async().await;
3729 let eps = endpoints(&server1);
3730 let root1 = server1
3731 .mock("GET", "/")
3732 .with_status(404)
3733 .expect(1)
3734 .create_async()
3735 .await;
3736 let status_path = format!("/{STATUS_RPC}");
3737 let status1 = server1
3738 .mock("POST", status_path.as_str())
3739 .with_status(401)
3740 .with_body(r#"{"code":"unauthenticated","message":"missing CSRF token"}"#)
3741 .expect(1)
3742 .create_async()
3743 .await;
3744
3745 let root2 = server2.mock("GET", "/").expect(0).create_async().await;
3747 let status2 = server2
3748 .mock("POST", status_path.as_str())
3749 .expect(0)
3750 .create_async()
3751 .await;
3752
3753 let (_td, cache) = fixture();
3754 let error = run(
3755 &cache,
3756 RemoteOverride {
3757 credential: SavedCredential::Absent,
3758 endpoints: Some(&eps),
3759 local_bases: Some(vec![
3760 Candidate {
3761 base: server1.url(),
3762 pid: Some(42),
3763 ide: false,
3764 },
3765 Candidate {
3766 base: server2.url(),
3767 pid: Some(42),
3768 ide: false,
3769 },
3770 ]),
3771 ..Default::default()
3772 },
3773 Duration::ZERO,
3774 )
3775 .await
3776 .expect_err("aborted probe returns agy missing csrf diagnosis");
3777
3778 root1.assert_async().await;
3779 status1.assert_async().await;
3780 root2.assert_async().await;
3781 status2.assert_async().await;
3782 let message = error.to_string();
3783 assert!(message.contains("requires a CSRF token"), "{message}");
3784 }
3785
3786 #[tokio::test]
3793 async fn agys_missing_csrf_does_not_skip_other_products_servers() {
3794 let mut agy = mockito::Server::new_async().await;
3795 let mut ide = mockito::Server::new_async().await;
3796 let eps = endpoints(&agy);
3797 let agy_root = agy
3798 .mock("GET", "/")
3799 .with_status(404)
3800 .expect(1)
3801 .create_async()
3802 .await;
3803 let status_path = format!("/{STATUS_RPC}");
3804 let agy_status = agy
3805 .mock("POST", status_path.as_str())
3806 .with_status(401)
3807 .with_body(r#"{"code":"unauthenticated","message":"missing CSRF token"}"#)
3808 .expect(1)
3809 .create_async()
3810 .await;
3811
3812 let ide_root = ide
3813 .mock("GET", "/")
3814 .with_status(404)
3815 .expect(1)
3816 .create_async()
3817 .await;
3818 let ide_status = ide
3819 .mock("POST", status_path.as_str())
3820 .with_status(200)
3821 .with_body(r#"{"userStatus":{"userTier":{"name":"Pro"},"email":"u@example.com"}}"#)
3822 .expect(1)
3823 .create_async()
3824 .await;
3825 let quota_path = format!("/{QUOTA_RPC}");
3826 let ide_quota = ide
3827 .mock("POST", quota_path.as_str())
3828 .with_status(200)
3829 .with_body(QUOTA_JSON)
3830 .expect(1)
3831 .create_async()
3832 .await;
3833
3834 let (_td, cache) = fixture();
3835 let outcome = run(
3836 &cache,
3837 RemoteOverride {
3838 credential: SavedCredential::Absent,
3839 endpoints: Some(&eps),
3840 local_bases: Some(vec![
3841 Candidate {
3842 base: agy.url(),
3843 pid: Some(42),
3844 ide: false,
3845 },
3846 Candidate {
3847 base: ide.url(),
3848 pid: Some(7),
3849 ide: false,
3850 },
3851 ]),
3852 ..Default::default()
3853 },
3854 Duration::ZERO,
3855 )
3856 .await
3857 .expect("the other product's server still answers");
3858
3859 agy_root.assert_async().await;
3860 agy_status.assert_async().await;
3861 ide_root.assert_async().await;
3862 ide_status.assert_async().await;
3863 ide_quota.assert_async().await;
3864 assert_eq!(outcome.snapshot.source, AntigravitySource::Local);
3865 assert_eq!(outcome.snapshot.plan, "Pro");
3866 }
3867
3868 #[tokio::test]
3871 async fn an_expired_keyring_token_is_refreshed_once_and_the_refresh_is_reused() {
3872 let mut server = mockito::Server::new_async().await;
3873 let eps = endpoints(&server);
3874 let _quota = quota_mock(&mut server, "NEW-AT")
3875 .expect(2)
3876 .create_async()
3877 .await;
3878 let token = token_mock(&mut server).expect(1).create_async().await;
3879 let blob = keyring_blob(EXPIRED, true);
3880 let (_td, cache) = fixture();
3881
3882 let first = run(&cache, remote(&blob, &eps), Duration::ZERO)
3883 .await
3884 .expect("refresh, then quota");
3885 assert_eq!(first.snapshot.source, AntigravitySource::Remote);
3886 token.assert_async().await;
3887
3888 let fingerprint = credential::parse_keyring_blob(&blob).unwrap().fingerprint;
3889 let persisted = cloud::read_persisted(&cloud::oauth_cache_path(&cache), &fingerprint)
3890 .expect("refreshed token persisted under the keyring session's fingerprint");
3891 assert_eq!(persisted.access_token, "NEW-AT");
3892
3893 run(&cache, remote(&blob, &eps), Duration::ZERO)
3895 .await
3896 .expect("persisted token reused");
3897 token.assert_async().await;
3898 }
3899
3900 #[tokio::test]
3903 async fn a_stale_token_the_api_rejects_gets_one_refresh_and_one_retry() {
3904 let mut server = mockito::Server::new_async().await;
3905 let eps = endpoints(&server);
3906 let rejected = server
3907 .mock("POST", "/daily/quota")
3908 .match_header("authorization", "Bearer KEYRING-AT")
3909 .with_status(401)
3910 .expect(1)
3911 .create_async()
3912 .await;
3913 let accepted = quota_mock(&mut server, "NEW-AT")
3914 .expect(1)
3915 .create_async()
3916 .await;
3917 let token = token_mock(&mut server).expect(1).create_async().await;
3918 let blob = keyring_blob(VALID, true);
3919 let (_td, cache) = fixture();
3920
3921 let outcome = run(&cache, remote(&blob, &eps), Duration::ZERO)
3922 .await
3923 .expect("retry with the refreshed token succeeds");
3924
3925 rejected.assert_async().await;
3926 accepted.assert_async().await;
3927 token.assert_async().await;
3928 assert_eq!(outcome.snapshot.source, AntigravitySource::Remote);
3929 }
3930
3931 #[tokio::test]
3934 async fn a_refused_refresh_is_a_credentials_error_without_the_token() {
3935 let mut server = mockito::Server::new_async().await;
3936 let eps = endpoints(&server);
3937 let _token = server
3938 .mock("POST", "/token")
3939 .with_status(400)
3940 .with_body(r#"{"error":"invalid_grant"}"#)
3941 .create_async()
3942 .await;
3943 let blob = keyring_blob(EXPIRED, true);
3944 let (_td, cache) = fixture();
3945
3946 let err = run(&cache, remote(&blob, &eps), Duration::ZERO)
3947 .await
3948 .expect_err("no cache to fall back on");
3949
3950 assert!(matches!(err, AppError::Credentials(_)), "{err}");
3951 let rendered = err.to_string();
3952 assert!(!rendered.contains("KEYRING-RT"), "{rendered}");
3953 assert!(!rendered.contains("KEYRING-AT"), "{rendered}");
3954 }
3955
3956 #[tokio::test]
3959 async fn a_rejection_right_after_a_refresh_asks_to_sign_in_again() {
3960 let mut server = mockito::Server::new_async().await;
3961 let eps = endpoints(&server);
3962 let _quota = server
3963 .mock("POST", "/daily/quota")
3964 .with_status(401)
3965 .create_async()
3966 .await;
3967 let token = token_mock(&mut server).expect(1).create_async().await;
3968 let blob = keyring_blob(EXPIRED, true);
3969 let (_td, cache) = fixture();
3970
3971 let err = run(&cache, remote(&blob, &eps), Duration::ZERO)
3972 .await
3973 .expect_err("rejected after refresh");
3974
3975 token.assert_async().await;
3976 assert!(matches!(err, AppError::Credentials(_)), "{err}");
3977 assert!(err.to_string().contains("sign in again"), "{err}");
3978 }
3979
3980 #[cfg(unix)]
3984 #[tokio::test]
3985 async fn a_subscription_403_surfaces_the_plan_message_without_a_refresh_or_agy_run() {
3986 let mut server = mockito::Server::new_async().await;
3987 let eps = endpoints(&server);
3988 let quota = server
3989 .mock("POST", "/daily/quota")
3990 .match_header("authorization", "Bearer KEYRING-AT")
3991 .with_status(403)
3992 .with_body(
3993 r#"{"error":{"code":403,"status":"PERMISSION_DENIED","details":
3994 [{"reason":"SUBSCRIPTION_REQUIRED","domain":"cloud.google.com"}]}}"#,
3995 )
3996 .expect(1)
3997 .create_async()
3998 .await;
3999 let prod = server
4000 .mock("POST", "/prod/quota")
4001 .expect(0)
4002 .create_async()
4003 .await;
4004 let token = token_mock(&mut server).expect(0).create_async().await;
4005 let (td, cache) = fixture();
4006 let file = td.path().join("credential");
4007 std::fs::write(&file, keyring_blob(VALID, true)).unwrap();
4008 let runs = td.path().join("runs");
4009 let agy = fake_agy(td.path(), &runs, &file, &keyring_blob(VALID, true));
4010
4011 let err = run_without_oauth(
4012 &cache,
4013 RemoteOverride {
4014 credential: SavedCredential::File(&file),
4015 endpoints: Some(&eps),
4016 local_bases: Some(vec![]),
4017 agy: super::super::agy::AgyCommand::At(agy),
4018 ..Default::default()
4019 },
4020 )
4021 .await
4022 .expect_err("a plan without quota is an error, not a snapshot");
4023
4024 quota.assert_async().await;
4025 prod.assert_async().await;
4026 token.assert_async().await;
4027 match err {
4028 AppError::Http { status, body } => {
4029 assert_eq!(status, 403);
4030 assert!(body.contains("paid subscription"), "{body}");
4031 }
4032 other => panic!("expected Http, got {other:?}"),
4033 }
4034 assert!(
4035 !runs.exists(),
4036 "a subscription verdict must not spawn `agy`"
4037 );
4038 }
4039
4040 #[tokio::test]
4044 async fn a_non_subscription_403_keeps_the_refresh_and_retry_behavior() {
4045 let mut server = mockito::Server::new_async().await;
4046 let eps = endpoints(&server);
4047 let rejected = server
4048 .mock("POST", "/daily/quota")
4049 .match_header("authorization", "Bearer KEYRING-AT")
4050 .with_status(403)
4051 .with_body(r#"{"error":{"message":"acl detail"}}"#)
4052 .expect(1)
4053 .create_async()
4054 .await;
4055 let retried = server
4056 .mock("POST", "/daily/quota")
4057 .match_header("authorization", "Bearer NEW-AT")
4058 .with_status(403)
4059 .with_body(r#"{"error":{"message":"acl detail"}}"#)
4060 .expect(1)
4061 .create_async()
4062 .await;
4063 let token = token_mock(&mut server).expect(1).create_async().await;
4064 let blob = keyring_blob(VALID, true);
4065 let (_td, cache) = fixture();
4066
4067 let err = run(&cache, remote(&blob, &eps), Duration::ZERO)
4068 .await
4069 .expect_err("still a rejected session");
4070
4071 rejected.assert_async().await;
4072 retried.assert_async().await;
4073 token.assert_async().await;
4074 assert!(matches!(err, AppError::Credentials(_)), "{err}");
4075 assert!(err.to_string().contains("sign in again"), "{err}");
4076 }
4077
4078 async fn run_without_oauth(cache: &Cache, remote: RemoteOverride<'_>) -> Result<FetchOutcome> {
4080 fetch_snapshot_at(
4081 &reqwest::Client::new(),
4082 cache,
4083 Duration::ZERO,
4084 None,
4085 remote,
4086 now(),
4087 )
4088 .await
4089 }
4090
4091 #[cfg(unix)]
4096 fn fake_agy(
4097 dir: &std::path::Path,
4098 runs: &std::path::Path,
4099 file: &std::path::Path,
4100 blob: &str,
4101 ) -> std::path::PathBuf {
4102 let path = dir.join("agy");
4103 super::super::agy::test_support::write_script(
4104 &path,
4105 &format!(
4106 "[ \"$1\" = -p ] && exit 1\necho \"$@\" >> '{}'\nprintf '%s' '{}' > '{}'",
4107 runs.display(),
4108 blob,
4109 file.display()
4110 ),
4111 );
4112 path
4113 }
4114
4115 #[cfg(unix)]
4118 #[tokio::test]
4119 async fn an_expired_session_is_renewed_by_running_agy() {
4120 let mut server = mockito::Server::new_async().await;
4121 let eps = endpoints(&server);
4122 let quota = quota_mock(&mut server, "KEYRING-AT").create_async().await;
4123 let _plan = server
4124 .mock("POST", "/daily/plan")
4125 .with_status(200)
4126 .with_body(r#"{"currentTier":{"name":"Pro"}}"#)
4127 .create_async()
4128 .await;
4129 let (td, cache) = fixture();
4130 let file = td.path().join("credential");
4131 std::fs::write(&file, keyring_blob(EXPIRED, true)).unwrap();
4132 let runs = td.path().join("runs");
4133 let agy = fake_agy(td.path(), &runs, &file, &keyring_blob(VALID, true));
4134
4135 let out = run_without_oauth(
4136 &cache,
4137 RemoteOverride {
4138 credential: SavedCredential::File(&file),
4139 endpoints: Some(&eps),
4140 local_bases: Some(vec![]),
4141 agy: super::super::agy::AgyCommand::At(agy),
4142 ..Default::default()
4143 },
4144 )
4145 .await
4146 .expect("agy renewed the session");
4147
4148 quota.assert_async().await;
4149 assert_eq!(out.snapshot.source, AntigravitySource::Remote);
4150 assert_eq!(std::fs::read_to_string(runs).unwrap().trim(), "models");
4151 }
4152
4153 #[cfg(unix)]
4156 #[tokio::test]
4157 async fn agy_that_does_not_renew_keeps_the_original_error_and_runs_once() {
4158 let server = mockito::Server::new_async().await;
4159 let eps = endpoints(&server);
4160 let (td, cache) = fixture();
4161 let file = td.path().join("credential");
4162 std::fs::write(&file, keyring_blob(EXPIRED, true)).unwrap();
4163 let runs = td.path().join("runs");
4164 let agy = fake_agy(td.path(), &runs, &file, &keyring_blob(EXPIRED, true));
4165 let remote = || RemoteOverride {
4166 credential: SavedCredential::File(&file),
4167 endpoints: Some(&eps),
4168 local_bases: Some(vec![]),
4169 agy: super::super::agy::AgyCommand::At(agy.clone()),
4170 ..Default::default()
4171 };
4172
4173 for _ in 0..2 {
4174 let err = run_without_oauth(&cache, remote())
4175 .await
4176 .expect_err("still expired");
4177 assert!(err.to_string().contains("no OAuth client"), "{err}");
4178 }
4179 assert_eq!(std::fs::read_to_string(runs).unwrap().lines().count(), 1);
4180 }
4181
4182 #[tokio::test]
4185 async fn without_an_agy_command_an_expired_session_is_left_alone() {
4186 let server = mockito::Server::new_async().await;
4187 let eps = endpoints(&server);
4188 let blob = keyring_blob(EXPIRED, true);
4189 let (_td, cache) = fixture();
4190
4191 let err = run_without_oauth(&cache, remote(&blob, &eps))
4192 .await
4193 .expect_err("nothing renews it");
4194 assert!(err.to_string().contains("no OAuth client"), "{err}");
4195 }
4196
4197 #[cfg(unix)]
4200 const AGY_QUOTA: &str = r#"{"status":"SUCCESS","num_turns":0,"command":{"name":"usage","data":{"groups":[
4201{"name":"Gemini Models","buckets":[
4202 {"id":"gemini-weekly","name":"Weekly Limit Remaining","window":"weekly","remaining_fraction":0.63,"reset_time":"2026-07-28T01:11:19Z"},
4203 {"id":"gemini-5h","name":"Five Hour Limit Remaining","window":"5h","remaining_fraction":0.49,"reset_time":"2026-07-22T15:25:45Z"}]},
4204{"name":"Claude and GPT models","buckets":[
4205 {"id":"3p-weekly","name":"Weekly Limit Remaining","window":"weekly","remaining_fraction":1,"reset_time":"2026-07-29T12:00:00Z"},
4206 {"id":"3p-5h","name":"Five Hour Limit Remaining","window":"5h","remaining_fraction":1,"reset_time":"2026-07-22T17:00:00Z"}]},
4207{"name":"Gemini 4 Argon","buckets":[
4208 {"id":"gemini-argon-wtu-weekly","name":"Gemini 4 Argon Weekly Limit","window":"weekly","remaining_fraction":0,"reset_time":"2026-07-28T01:10:47Z"}]}]}}}"#;
4209
4210 #[cfg(unix)]
4213 fn agy_reading(dir: &std::path::Path, runs: &std::path::Path) -> std::path::PathBuf {
4214 let path = dir.join("agy");
4215 super::super::agy::test_support::write_script(
4216 &path,
4217 &format!(
4218 "echo \"$@\" >> '{}'\n[ \"$1\" = -p ] || exit 1\ncat <<'EOF'\n{AGY_QUOTA}\nEOF",
4219 runs.display()
4220 ),
4221 );
4222 path
4223 }
4224
4225 #[cfg(unix)]
4229 #[tokio::test]
4230 async fn agys_own_reading_answers_before_the_cloud_code_api() {
4231 let mut server = mockito::Server::new_async().await;
4232 let eps = endpoints(&server);
4233 let daily = server
4234 .mock("POST", "/daily/quota")
4235 .expect(0)
4236 .create_async()
4237 .await;
4238 let prod = server
4239 .mock("POST", "/prod/quota")
4240 .expect(0)
4241 .create_async()
4242 .await;
4243 let plan = server
4244 .mock("POST", "/daily/plan")
4245 .match_header("authorization", "Bearer KEYRING-AT")
4246 .with_status(200)
4247 .with_body(r#"{"currentTier":{"name":"google_ai_pro"}}"#)
4248 .expect(1)
4249 .create_async()
4250 .await;
4251 let blob = keyring_blob(VALID, true);
4252 let (td, cache) = fixture();
4253 let runs = td.path().join("runs");
4254 let agy = agy_reading(td.path(), &runs);
4255
4256 let out = run(
4257 &cache,
4258 RemoteOverride {
4259 agy: super::super::agy::AgyCommand::At(agy),
4260 ..remote(&blob, &eps)
4261 },
4262 Duration::from_secs(60),
4263 )
4264 .await
4265 .expect("agy answered");
4266
4267 daily.assert_async().await;
4268 prod.assert_async().await;
4269 plan.assert_async().await;
4270 let snap = out.snapshot;
4271 assert_eq!(snap.source, AntigravitySource::Agy);
4272 assert_eq!(snap.plan, "Pro");
4273 assert_eq!(snap.session.as_ref().unwrap().utilization_pct, 51);
4274 assert_eq!(snap.weekly.as_ref().unwrap().utilization_pct, 37);
4275 assert_eq!(
4276 snap.third_party_session.as_ref().unwrap().utilization_pct,
4277 0
4278 );
4279 assert_eq!(snap.model_pools[0].window.utilization_pct, 100);
4280 let fingerprint = credential::parse_keyring_blob(&blob).unwrap().fingerprint;
4281 assert_eq!(snap.account, format!("acct:{fingerprint}"));
4282 let cached = parse_cache_at(
4283 &cache
4284 .fresh_payload(Duration::from_secs(60))
4285 .unwrap()
4286 .unwrap(),
4287 None,
4288 now(),
4289 )
4290 .unwrap();
4291 assert_eq!(cached.source, AntigravitySource::Agy);
4292 assert_eq!(
4293 std::fs::read_to_string(runs).unwrap().trim(),
4294 "-p /quota --output-format json --log-file /dev/null"
4295 );
4296 }
4297
4298 #[cfg(unix)]
4301 #[tokio::test]
4302 async fn agys_reading_needs_no_good_token() {
4303 let mut server = mockito::Server::new_async().await;
4304 let eps = endpoints(&server);
4305 let plan = server
4306 .mock("POST", "/daily/plan")
4307 .expect(0)
4308 .create_async()
4309 .await;
4310 let token = token_mock(&mut server).expect(0).create_async().await;
4311 let blob = keyring_blob(EXPIRED, true);
4312 let (td, cache) = fixture();
4313 let runs = td.path().join("runs");
4314 let agy = agy_reading(td.path(), &runs);
4315
4316 let out = run_without_oauth(
4317 &cache,
4318 RemoteOverride {
4319 agy: super::super::agy::AgyCommand::At(agy),
4320 ..remote(&blob, &eps)
4321 },
4322 )
4323 .await
4324 .expect("agy answered");
4325
4326 plan.assert_async().await;
4327 token.assert_async().await;
4328 assert_eq!(out.snapshot.source, AntigravitySource::Agy);
4329 assert_eq!(out.snapshot.plan, "Antigravity");
4330 assert_eq!(std::fs::read_to_string(runs).unwrap().lines().count(), 1);
4331 }
4332
4333 #[cfg(unix)]
4336 #[tokio::test]
4337 async fn when_agy_cannot_read_the_quota_the_cloud_code_api_answers() {
4338 let mut server = mockito::Server::new_async().await;
4339 let eps = endpoints(&server);
4340 let quota = quota_mock(&mut server, "KEYRING-AT")
4341 .expect(2)
4342 .create_async()
4343 .await;
4344 let blob = keyring_blob(VALID, true);
4345 let (td, cache) = fixture();
4346 let runs = td.path().join("runs");
4347 let agy = td.path().join("agy");
4348 super::super::agy::test_support::write_script(
4349 &agy,
4350 &format!("echo \"$@\" >> '{}'\nexit 1", runs.display()),
4351 );
4352 let remote = || RemoteOverride {
4353 agy: super::super::agy::AgyCommand::At(agy.clone()),
4354 ..remote(&blob, &eps)
4355 };
4356
4357 for _ in 0..2 {
4358 let out = run(&cache, remote(), Duration::ZERO)
4359 .await
4360 .expect("the Cloud Code API answered");
4361 assert_eq!(out.snapshot.source, AntigravitySource::Remote);
4362 std::fs::remove_file(cache.payload_path()).unwrap();
4364 }
4365 quota.assert_async().await;
4366 assert_eq!(std::fs::read_to_string(runs).unwrap().lines().count(), 1);
4367 }
4368
4369 #[cfg(unix)]
4373 fn age_past_reading_floor(cache: &Cache) {
4374 let aged = std::time::SystemTime::now() - super::super::agy::QUOTA_INTERVAL * 2;
4375 std::fs::OpenOptions::new()
4376 .write(true)
4377 .open(cache.payload_path())
4378 .unwrap()
4379 .set_times(std::fs::FileTimes::new().set_modified(aged))
4380 .unwrap();
4381 }
4382
4383 #[cfg(unix)]
4388 #[tokio::test]
4389 async fn when_agy_stops_answering_its_last_reading_is_kept_over_the_cloud_code_api() {
4390 let mut server = mockito::Server::new_async().await;
4391 let eps = endpoints(&server);
4392 let quota = quota_mock(&mut server, "KEYRING-AT")
4393 .expect(0)
4394 .create_async()
4395 .await;
4396 let _plan = server
4397 .mock("POST", "/daily/plan")
4398 .with_status(200)
4399 .with_body(r#"{"currentTier":{"name":"google_ai_pro"}}"#)
4400 .create_async()
4401 .await;
4402 let blob = keyring_blob(VALID, true);
4403 let (td, cache) = fixture();
4404 let runs = td.path().join("runs");
4405 let agy = agy_reading(td.path(), &runs);
4406 let remote = || RemoteOverride {
4407 agy: super::super::agy::AgyCommand::At(agy.clone()),
4408 ..remote(&blob, &eps)
4409 };
4410
4411 let first = run(&cache, remote(), Duration::ZERO)
4412 .await
4413 .expect("agy answered");
4414 assert_eq!(first.snapshot.source, AntigravitySource::Agy);
4415 assert!(!first.stale);
4416
4417 age_past_reading_floor(&cache);
4419 super::super::agy::test_support::write_script(
4420 &agy,
4421 &format!("echo \"$@\" >> '{}'\nexit 1", runs.display()),
4422 );
4423
4424 for _ in 0..2 {
4425 let out = run(&cache, remote(), Duration::ZERO)
4426 .await
4427 .expect("agy's reading is kept");
4428 assert_eq!(out.snapshot.source, AntigravitySource::Agy);
4429 assert_eq!(out.snapshot.weekly.as_ref().unwrap().utilization_pct, 37);
4430 assert_eq!(out.snapshot.plan, "Pro");
4431 assert!(out.stale, "a kept reading is not passed off as current");
4432 age_past_reading_floor(&cache);
4433 }
4434 quota.assert_async().await;
4435 assert_eq!(std::fs::read_to_string(runs).unwrap().lines().count(), 2);
4438 }
4439
4440 #[cfg(unix)]
4443 #[tokio::test]
4444 async fn a_cloud_code_figure_is_not_kept_in_agys_place() {
4445 let mut server = mockito::Server::new_async().await;
4446 let eps = endpoints(&server);
4447 let quota = quota_mock(&mut server, "KEYRING-AT")
4448 .expect(2)
4449 .create_async()
4450 .await;
4451 let blob = keyring_blob(VALID, true);
4452 let (td, cache) = fixture();
4453 let agy = td.path().join("agy");
4454 super::super::agy::test_support::write_script(&agy, "exit 1");
4455 let remote = || RemoteOverride {
4456 agy: super::super::agy::AgyCommand::At(agy.clone()),
4457 ..remote(&blob, &eps)
4458 };
4459
4460 for _ in 0..2 {
4461 let out = run(&cache, remote(), Duration::ZERO)
4462 .await
4463 .expect("the Cloud Code API answered");
4464 assert_eq!(out.snapshot.source, AntigravitySource::Remote);
4465 assert!(!out.stale);
4466 age_past_reading_floor(&cache);
4467 }
4468 quota.assert_async().await;
4469 }
4470
4471 #[cfg(unix)]
4474 #[tokio::test]
4475 async fn agys_reading_is_taken_at_most_every_quota_interval() {
4476 let server = mockito::Server::new_async().await;
4477 let eps = endpoints(&server);
4478 let blob = keyring_blob(VALID, true);
4479 let (td, cache) = fixture();
4480 let runs = td.path().join("runs");
4481 let agy = agy_reading(td.path(), &runs);
4482 for _ in 0..2 {
4483 let out = run(
4484 &cache,
4485 RemoteOverride {
4486 agy: super::super::agy::AgyCommand::At(agy.clone()),
4487 ..remote(&blob, &eps)
4488 },
4489 Duration::ZERO,
4490 )
4491 .await
4492 .expect("a snapshot");
4493 assert_eq!(out.snapshot.source, AntigravitySource::Agy);
4494 }
4495 assert_eq!(std::fs::read_to_string(runs).unwrap().lines().count(), 1);
4496 }
4497
4498 #[tokio::test]
4500 async fn an_expired_session_without_a_refresh_token_asks_to_sign_in_again() {
4501 let mut server = mockito::Server::new_async().await;
4502 let eps = endpoints(&server);
4503 let token = token_mock(&mut server).expect(0).create_async().await;
4504 let blob = keyring_blob(EXPIRED, false);
4505 let (_td, cache) = fixture();
4506
4507 let err = run(&cache, remote(&blob, &eps), Duration::ZERO)
4508 .await
4509 .expect_err("nothing to refresh with");
4510
4511 token.assert_async().await;
4512 assert!(matches!(err, AppError::Credentials(_)), "{err}");
4513 assert!(err.to_string().contains("sign in again"), "{err}");
4514 }
4515
4516 #[tokio::test]
4519 async fn no_saved_session_extends_the_no_local_server_error() {
4520 let mut server = mockito::Server::new_async().await;
4521 let eps = endpoints(&server);
4522 let quota = quota_mock(&mut server, "KEYRING-AT")
4523 .expect(0)
4524 .create_async()
4525 .await;
4526 let (_td, cache) = fixture();
4527
4528 let err = run(
4529 &cache,
4530 RemoteOverride {
4531 credential: SavedCredential::Absent,
4532 endpoints: Some(&eps),
4533 local_bases: Some(vec![]),
4534 ..Default::default()
4535 },
4536 Duration::ZERO,
4537 )
4538 .await
4539 .expect_err("no source at all");
4540
4541 quota.assert_async().await;
4542 assert!(matches!(err, AppError::Credentials(_)), "{err}");
4543 let rendered = err.to_string();
4544 assert!(rendered.contains("no local server found"), "{rendered}");
4545 assert!(rendered.contains("saved Google session"), "{rendered}");
4546 }
4547
4548 #[tokio::test]
4551 async fn a_signed_out_local_server_is_reported_rather_than_bypassed() {
4552 let mut server = mockito::Server::new_async().await;
4553 let eps = endpoints(&server);
4554 let status_path = format!("/{STATUS_RPC}");
4555 let _status = server
4556 .mock("POST", status_path.as_str())
4557 .with_status(401)
4558 .create_async()
4559 .await;
4560 let quota = quota_mock(&mut server, "KEYRING-AT")
4561 .expect(0)
4562 .create_async()
4563 .await;
4564 let blob = keyring_blob(VALID, true);
4565 let (_td, cache) = fixture();
4566
4567 let err = run(
4568 &cache,
4569 RemoteOverride {
4570 credential: SavedCredential::Blob(&blob),
4571 endpoints: Some(&eps),
4572 local_bases: Some(vec![server.url().into()]),
4573 ..Default::default()
4574 },
4575 Duration::ZERO,
4576 )
4577 .await
4578 .expect_err("signed-out local server");
4579
4580 quota.assert_async().await;
4581 assert!(matches!(err, AppError::Http { status: 401, .. }), "{err}");
4582 }
4583
4584 #[tokio::test]
4590 async fn a_signed_out_ide_language_server_is_skipped_so_the_saved_session_answers() {
4591 let mut ide = mockito::Server::new_async().await;
4592 let mut agy = mockito::Server::new_async().await;
4593 let eps = endpoints(&agy);
4594 let ide_root = ide.mock("GET", "/").expect(0).create_async().await;
4596 let status_path = format!("/{STATUS_RPC}");
4597 let ide_status = ide
4598 .mock("POST", status_path.as_str())
4599 .with_status(403)
4600 .expect(0)
4601 .create_async()
4602 .await;
4603 let agy_root = agy
4605 .mock("GET", "/")
4606 .with_status(404)
4607 .expect(1)
4608 .create_async()
4609 .await;
4610 let agy_status = agy
4611 .mock("POST", status_path.as_str())
4612 .with_status(401)
4613 .with_body(r#"{"code":"unauthenticated","message":"missing CSRF token"}"#)
4614 .expect(1)
4615 .create_async()
4616 .await;
4617 let quota = quota_mock(&mut agy, "KEYRING-AT")
4618 .expect(1)
4619 .create_async()
4620 .await;
4621 let plan = agy
4622 .mock("POST", "/daily/plan")
4623 .match_header("authorization", "Bearer KEYRING-AT")
4624 .with_status(200)
4625 .with_body(r#"{"currentTier":{"name":"google_ai_pro"}}"#)
4626 .expect(1)
4627 .create_async()
4628 .await;
4629 let blob = keyring_blob(VALID, true);
4630 let (_td, cache) = fixture();
4631
4632 let outcome = run(
4633 &cache,
4634 RemoteOverride {
4635 credential: SavedCredential::Blob(&blob),
4636 endpoints: Some(&eps),
4637 local_bases: Some(vec![
4638 Candidate {
4639 base: ide.url(),
4640 pid: Some(7),
4641 ide: true,
4642 },
4643 Candidate {
4644 base: agy.url(),
4645 pid: Some(42),
4646 ide: false,
4647 },
4648 ]),
4649 ide_language_server: false,
4650 ..Default::default()
4651 },
4652 Duration::ZERO,
4653 )
4654 .await
4655 .expect("with the IDE skipped, agy's missing-CSRF answer unlocks the saved session");
4656
4657 ide_root.assert_async().await;
4658 ide_status.assert_async().await;
4659 agy_root.assert_async().await;
4660 agy_status.assert_async().await;
4661 quota.assert_async().await;
4662 plan.assert_async().await;
4663 assert_eq!(outcome.snapshot.source, AntigravitySource::Remote);
4664 assert_eq!(outcome.snapshot.plan, "Pro");
4665 }
4666
4667 #[tokio::test]
4671 async fn skipping_the_only_ide_candidate_falls_through_to_the_saved_session() {
4672 let mut ide = mockito::Server::new_async().await;
4673 let eps = endpoints(&ide);
4674 let ide_root = ide.mock("GET", "/").expect(0).create_async().await;
4675 let status_path = format!("/{STATUS_RPC}");
4676 let ide_status = ide
4677 .mock("POST", status_path.as_str())
4678 .expect(0)
4679 .create_async()
4680 .await;
4681 let quota = quota_mock(&mut ide, "KEYRING-AT")
4682 .expect(1)
4683 .create_async()
4684 .await;
4685 let plan = ide
4686 .mock("POST", "/daily/plan")
4687 .with_status(200)
4688 .with_body(r#"{"currentTier":{"name":"Pro"}}"#)
4689 .expect(1)
4690 .create_async()
4691 .await;
4692 let blob = keyring_blob(VALID, true);
4693 let (_td, cache) = fixture();
4694
4695 let outcome = run(
4696 &cache,
4697 RemoteOverride {
4698 credential: SavedCredential::Blob(&blob),
4699 endpoints: Some(&eps),
4700 local_bases: Some(vec![Candidate {
4701 base: ide.url(),
4702 pid: Some(7),
4703 ide: true,
4704 }]),
4705 ide_language_server: false,
4706 ..Default::default()
4707 },
4708 Duration::ZERO,
4709 )
4710 .await
4711 .expect("an IDE-only machine with the IDE skipped uses the saved session");
4712
4713 ide_root.assert_async().await;
4714 ide_status.assert_async().await;
4715 quota.assert_async().await;
4716 plan.assert_async().await;
4717 assert_eq!(outcome.snapshot.source, AntigravitySource::Remote);
4718 }
4719
4720 #[tokio::test]
4723 async fn a_cached_remote_snapshot_is_served_when_the_api_fails() {
4724 let mut server = mockito::Server::new_async().await;
4725 let eps = endpoints(&server);
4726 for path in ["/daily/quota", "/prod/quota"] {
4727 server
4728 .mock("POST", path)
4729 .with_status(500)
4730 .with_body("boom")
4731 .create_async()
4732 .await;
4733 }
4734 let blob = keyring_blob(VALID, true);
4735 let (_td, cache) = fixture();
4736 cache.ensure_dir().unwrap();
4737 let mut earlier = parsed();
4738 earlier.source = AntigravitySource::Remote;
4739 earlier.account = "acct:earlier".into();
4740 cache
4741 .write_payload(&serde_json::to_vec(&snap_to_json(&earlier)).unwrap())
4742 .unwrap();
4743
4744 let outcome = run(&cache, remote(&blob, &eps), Duration::ZERO)
4745 .await
4746 .expect("stale cache stands in");
4747
4748 assert!(outcome.stale);
4749 assert_eq!(outcome.snapshot, earlier);
4750 assert!(
4751 matches!(outcome.last_error, Some((500, _))),
4752 "{:?}",
4753 outcome.last_error
4754 );
4755 }
4756
4757 fn argon(pct: i32, reset: &str) -> ModelPoolWindow {
4758 ModelPoolWindow {
4759 name: "Gemini 4 Argon".into(),
4760 window: UsageWindow {
4761 utilization_pct: pct,
4762 resets_at: Some(
4763 DateTime::parse_from_rfc3339(reset)
4764 .unwrap()
4765 .with_timezone(&Utc),
4766 ),
4767 window_duration: chrono::Duration::days(7),
4768 },
4769 }
4770 }
4771
4772 #[test]
4773 fn model_pools_round_trip_through_the_cache() {
4774 let mut snap = parsed();
4775 snap.model_pools.push(argon(60, "2026-07-28T17:00:00Z"));
4776 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
4777 assert_eq!(parse_cache_at(&bytes, None, now()).unwrap(), snap);
4778
4779 let mut legacy = snap_to_json(&parsed());
4781 legacy.as_object_mut().unwrap().remove("model_pools");
4782 let legacy = serde_json::to_vec(&legacy).unwrap();
4783 assert!(
4784 parse_cache_at(&legacy, None, now())
4785 .unwrap()
4786 .model_pools
4787 .is_empty()
4788 );
4789
4790 let mut torn = snap_to_json(&snap);
4792 torn["model_pools"][0]["window"] = "monthly".into();
4793 let torn = serde_json::to_vec(&torn).unwrap();
4794 assert!(parse_cache_at(&torn, None, now()).is_err());
4795
4796 let mut damaged = snap_to_json(&snap);
4799 damaged["model_pools"][0]["name"] = "Argon\u{001b}[31m".into();
4800 let damaged = serde_json::to_vec(&damaged).unwrap();
4801 let restored = parse_cache_at(&damaged, None, now()).unwrap();
4802 assert_eq!(restored.model_pools[0].name, "Argon[31m");
4803 }
4804
4805 #[test]
4807 fn a_cached_model_pool_past_its_reset_refetches() {
4808 let mut snap = parsed();
4809 snap.model_pools.push(argon(60, "2026-07-22T11:00:00Z"));
4810 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
4811 let err = parse_cache_at(&bytes, None, now()).unwrap_err();
4812 assert!(err.to_string().contains("Gemini 4 Argon weekly"), "{err}");
4813 }
4814
4815 #[test]
4816 fn source_round_trips_through_the_cache_and_defaults_to_local() {
4817 let mut snap = parsed();
4818 snap.source = AntigravitySource::Remote;
4819 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
4820 assert_eq!(
4821 parse_cache_at(&bytes, None, now()).unwrap().source,
4822 AntigravitySource::Remote
4823 );
4824
4825 snap.source = AntigravitySource::Statusline;
4826 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
4827 assert_eq!(
4828 parse_cache_at(&bytes, None, now()).unwrap().source,
4829 AntigravitySource::Statusline
4830 );
4831
4832 snap.source = AntigravitySource::Agy;
4833 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
4834 assert_eq!(
4835 parse_cache_at(&bytes, None, now()).unwrap().source,
4836 AntigravitySource::Agy
4837 );
4838
4839 let mut legacy = snap_to_json(&snap);
4841 legacy.as_object_mut().unwrap().remove("source");
4842 let legacy = serde_json::to_vec(&legacy).unwrap();
4843 assert_eq!(
4844 parse_cache_at(&legacy, None, now()).unwrap().source,
4845 AntigravitySource::Local
4846 );
4847 }
4848}