Skip to main content

ai_usagebar/antigravity/
fetch.rs

1//! Fetch a Google Antigravity usage snapshot from a local language server.
2//!
3//! Google ships three separate Antigravity products — Antigravity 2.0, the
4//! `agy` CLI, and the Antigravity IDE — and they all draw on the **same**
5//! account-wide quota. A machine may have any combination of them installed and
6//! running, so this module probes every local server it can find and trusts the
7//! first that answers; there is no need to prefer one product over another.
8//!
9//! Each exposes a CSRF-guarded JSON-RPC surface on a **dynamically assigned**
10//! loopback port (`--https_server_port 0`), so the port cannot be hardcoded.
11//! Quota lives behind `RetrieveUserQuotaSummary`, which reports two shared model
12//! groups — Gemini, and Claude/GPT — each holding a 5-hour and a weekly bucket,
13//! plus plan-dependent per-model pools such as Gemini 4 Argon.
14//! `GetUserStatus` carries only the plan name; its per-model `quotaInfo` mirrors
15//! whichever bucket is scarcest and must not be read as a window in its own
16//! right.
17//!
18//! When no usable product is running there is still a way to answer:
19//! Antigravity keeps the Google session it signed in with in the OS keyring,
20//! and the same quota summary is served by the Cloud Code API. That is the
21//! *fallback*, taken when no local server was found or when `agy` reports that
22//! its undiscoverable CSRF token is required. Every other local rejection — a
23//! server that is signed out or answering on the wrong protocol — keeps its
24//! own diagnosis.
25//!
26//! The fallback asks `agy` before Google when `agy` is installed: its
27//! `-p "/quota"` reading is what its own Models & Quota page shows, which the
28//! Cloud Code request cannot reproduce for an `agy` account (#450). Only when
29//! `agy` cannot answer does the saved session go to the Cloud Code API.
30//!
31//! `agy` not answering *now* is not the same as having nothing of its own to
32//! show. A cold `agy` start runs past the reading's timeout, and the failure
33//! holds the next attempt off for ten minutes; the Cloud Code figure that
34//! filled that gap is the other tracker — an untouched week, 0% used — shown
35//! as current. So while `agy` is installed, a reading it gave earlier is kept,
36//! stale, for as long as the cache would serve it anyway, and the Cloud Code
37//! API answers only when there is no reading of `agy`'s own to keep.
38//!
39//! That ranking has one sharp edge (#405): a signed-out product's 401/403 is
40//! actionable, so it outranks the `agy` answer that would unlock the fallback
41//! — the running product masks a working saved session. When that product is
42//! the IDE and the user signs it in separately, `[antigravity]
43//! ide_language_server = false` removes the IDE's language server from
44//! discovery, leaving `agy` (and the fallback) to answer as if the IDE were
45//! closed.
46
47use 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
72/// Appended to [`NO_LOCAL_SERVER`] once the remote fallback has also come up
73/// empty: the user has a second way out that the local-only message does not
74/// mention.
75const 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
81/// This vendor's [`Outcome`](crate::outcome::Outcome) — the shared shape,
82/// specialised to its snapshot.
83pub 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/// The keyring blob, or a stand-in for it. `Absent` exists so a test can
92/// exercise "nothing saved" without asking the real keyring, which is what
93/// `None` would have to mean otherwise.
94#[derive(Debug, Clone, Copy, Default)]
95pub enum SavedCredential<'a> {
96    /// Read the OS keyring, as production does.
97    #[default]
98    Keyring,
99    /// Use this raw blob.
100    Blob(&'a str),
101    /// Read the blob from this file each time, so a test can model a
102    /// credential that another program rewrites between two reads.
103    File(&'a std::path::Path),
104    /// Behave as if the keyring held nothing.
105    Absent,
106}
107
108/// Test seam for the remote fallback. Production passes
109/// [`RemoteOverride::default`], which reads the OS keyring, talks to Google,
110/// and probes the local ports discovery finds; a test supplies each of those
111/// instead so it never touches the real keyring, the network, or `/proc`.
112pub struct RemoteOverride<'a> {
113    /// Where the saved Google session comes from.
114    pub credential: SavedCredential<'a>,
115    /// Cloud Code endpoints, in place of [`cloud::Endpoints::default`].
116    pub endpoints: Option<&'a cloud::Endpoints>,
117    /// Local candidates to probe, in place of discovery. `Some(vec![])` means
118    /// "no local server", which is what sends the fetch down the remote path.
119    /// `pid` marks which process owns each listener; tests that model one
120    /// product's two listeners give them the same pid.
121    pub local_bases: Option<Vec<Candidate>>,
122    /// How an expired session is renewed through the `agy` CLI. Off by
123    /// default, so only [`fetch_snapshot`] — which asks for discovery — ever
124    /// runs the real one.
125    pub agy: super::agy::AgyCommand,
126    /// Probe the Antigravity IDE's language server as a candidate. Production
127    /// passes `[antigravity] ide_language_server`; the default keeps it,
128    /// preserving discovery as it has always been.
129    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/// One probe target: the listener's base URL and, when it came from
145/// discovery, the process that owns it. A `missing CSRF` answer spares only
146/// the *same process's* remaining listeners, so the pid is what keeps a
147/// second Antigravity product's ports reachable.
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub struct Candidate {
150    /// Normalized `scheme://authority` URL the RPC paths are appended to.
151    pub base: String,
152    /// Owning pid when the listener was discovered; `None` for a bare test
153    /// seam or an override whose port discovery did not see.
154    pub pid: Option<u32>,
155    /// The Antigravity IDE's language server, which [`open_session`] never
156    /// probes. A signed-out IDE answers an actionable 401/403 that outranks a
157    /// working `agy` session (#405), so a user who signs the IDE in
158    /// separately skips it. Discovery sets this only when `[antigravity]
159    /// ide_language_server` is false; an explicit `ANTIGRAVITY_LS_ADDRESS`
160    /// stays a candidate regardless.
161    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
174/// `[antigravity] ide_language_server`: probe the IDE's language server as a
175/// candidate (`true`, the default) or leave it out of discovery entirely.
176pub 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
201/// Clock seam for [`fetch_snapshot`], so window expiry can be exercised at
202/// fixed instants instead of against the wall clock, and the seam for
203/// everything the remote fallback would otherwise read from the machine.
204pub 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    // Resolve the signed-in account first so a fresh cache can be attributed.
216    // Unlike Grok — where the same check would cost a remote round-trip on
217    // every poll — this is loopback, and it is the call that would supply the
218    // plan name anyway, so verification is effectively free.
219    //
220    // With no local server at all, the saved Google session identifies the
221    // account just as cheaply: reading the keyring is local, and only the
222    // quota call itself goes to the network — after the fresh-cache check,
223    // like the local RPC.
224    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    // The remote path starts `agy` when there is one; see
240    // [`super::agy::QUOTA_INTERVAL`] for why it is not run on every TTL.
241    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
291/// A local server that answered `GetUserStatus`: where it lives, how to talk to
292/// it, and whose account it is signed in as.
293struct Session {
294    base: String,
295    csrf: Option<String>,
296    plan: String,
297    account: String,
298}
299
300/// Which source this fetch will draw on, decided before the cache is consulted
301/// so a fresh payload can be checked against the right account.
302enum Origin {
303    Local(Result<Session>),
304    Remote(Result<StoredToken>),
305}
306
307impl Origin {
308    /// The account fingerprint, when the source identified one.
309    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
337/// Local failures the saved Google session is allowed to answer. The `agy`
338/// CSRF refusal is matched on status, code, and the `missing CSRF token`
339/// wording (including the `unauthenticated:` prefix `agy` 1.3.1 added). An
340/// arbitrary local `401` remains a signed-out diagnosis and never triggers
341/// remote traffic.
342fn 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
352/// Walk every candidate language server until one identifies itself. A machine
353/// can host more than one — the desktop app, the IDE and an interactive `agy`
354/// session each run their own — and only some of them are signed in.
355///
356/// `bases` replaces discovery when given; see [`RemoteOverride::local_bases`].
357/// Candidates marked as the IDE's language server are excluded before
358/// anything is probed, so an IDE-only machine with `[antigravity]
359/// ide_language_server = false` falls through to the saved session exactly as
360/// if the IDE were closed — its signed-out 401/403 must not mask a working
361/// `agy` session (#405).
362async 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    // Products already proved unusable. Skipping *their* remaining ports —
378    // not the whole list — is what keeps a second product's RPC listener
379    // reachable: the candidates are flattened rank by rank, so the port after
380    // an `agy` answer can belong to a different process.
381    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                // agy exposes no token-discovery route and requires CSRF. Once
398                // confirmed, probing this process's other ports only triggers
399                // the same rejection or spurious Go TLS handshake error logs.
400                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
412/// Which failure to report when no candidate answered.
413///
414/// A server that replies `401`/`403` is normally running and reachable but
415/// signed out — the user can act on that, so it outranks the connection
416/// refusals from products that simply are not up. `agy`'s exact missing-CSRF
417/// response is ranked separately because it has no token discovery route.
418/// Without this, a stale
419/// `ANTIGRAVITY_LS_ADDRESS` (or a second product on another port) would mask
420/// the one message worth reading behind transport noise.
421///
422/// Note that this also decides *visibility*: transport errors are transient and
423/// fall back silently to cache, while the `401` surfaces in the widget. That is
424/// why a TLS listener's echo is ranked below everything else rather than merely
425/// tie-breaking — see [`is_tls_echo`]. Being an `Http`, it is not transient, so
426/// letting it stand as "the last failure" turns a product that simply is not
427/// serving RPC into a visible error about a protocol the user never chose.
428fn 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
453/// `agy` currently serves no page containing its CSRF token, then returns this
454/// structured response from the status RPC. Matching the status, code, and
455/// the `missing CSRF token` wording avoids treating an unrelated local service
456/// or a genuinely signed-out Antigravity product as permission to use the
457/// cloud fallback. `agy` 1.3.1 prefixes that wording with `unauthenticated:`.
458fn 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
473/// Historical wording, or that wording as the last colon-separated field so
474/// `agy` 1.3.1's `unauthenticated: missing CSRF token` still matches.
475fn 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
482/// An error the user can do something about, as opposed to "that product is not
483/// running". `post_rpc` only ever yields `Http`/`Transport`/`Other`, so the
484/// authentication statuses are the whole set, apart from `agy`'s precise
485/// missing-CSRF response.
486fn is_actionable(e: &AppError) -> bool {
487    matches!(e, AppError::Http { status, .. } if *status == 401 || *status == 403)
488        && !is_missing_csrf(e)
489}
490
491/// A TLS listener answering the plaintext JSON-RPC probe.
492///
493/// Each Antigravity product binds two ports: JSON-RPC in the clear on one and
494/// HTTPS on another. `probe_order` deliberately tries the RPC listener first
495/// and leaves the TLS one behind it, so reaching the TLS port at all means the
496/// RPC port already had its say. Go's `net/http.Server` answers cleartext on a
497/// TLS listener with this fixed `400`, which describes our own probe rather
498/// than anything wrong with the product — as a diagnosis it is noise that
499/// always arrives last and therefore always wins.
500///
501/// Matched on the body, not the status alone: a real `400` from the language
502/// server is a genuine complaint about the request and must keep outranking it.
503fn 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(&quota, session.plan)?;
517    snap.account = session.account;
518    Ok(snap)
519}
520
521// ---------------------------------------------------------------------------
522// Remote fallback — the saved Google session against the Cloud Code API
523// ---------------------------------------------------------------------------
524
525/// The saved Google session, or why there is none. `credential` is the test
526/// seam for the keyring blob.
527///
528/// No saved session leaves the user exactly where the local probe left them,
529/// plus the one thing they can now do about it.
530fn 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
549/// Cache attribution for a remote snapshot. Deliberately not the local
550/// fingerprint's format: the two are computed from different inputs and a
551/// collision would let one source's cache stand in for the other's.
552fn remote_account(fingerprint: &str) -> String {
553    format!("acct:{fingerprint}")
554}
555
556fn session_expired() -> AppError {
557    AppError::Credentials(SESSION_EXPIRED.into())
558}
559
560/// The saved session can only be renewed with Antigravity's OAuth client,
561/// which this program does not ship (a secret-shaped literal in source trips
562/// every secret scanner); the config names the two keys that provide it.
563fn 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
582/// An access token to present, and whether it was minted just now — a `401`
583/// against a token this fresh is the session itself being gone, not a stale
584/// token worth refreshing again.
585struct AccessToken {
586    value: String,
587    just_refreshed: bool,
588}
589
590/// The freshest usable access token there is without going to the network:
591/// the one this program persisted after its last refresh, if it outlives the
592/// keyring's and is not about to expire; else the keyring's own while it
593/// lasts.
594fn 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
610/// [`current_access_token`], and only when both are spent, a refresh.
611async 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
628/// Mint a new access token off the saved refresh token and remember it, so
629/// the next poll does not spend another round-trip on the same refresh. A
630/// session with no refresh token cannot be renewed here at all.
631async 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
659/// What the remote path needs to renew an expired session through `agy`:
660/// the command to run, and where to read the credential again afterwards.
661struct Renewal<'a> {
662    agy: &'a super::agy::AgyCommand,
663    credential: SavedCredential<'a>,
664}
665
666impl Renewal<'_> {
667    /// Run `agy` to renew the saved session, then read it again. `None` when
668    /// `agy` did not run or left nothing readable, so the caller keeps the
669    /// error that sent it here.
670    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
684/// What the remote path produced: a reading taken on this poll, to be cached
685/// as fresh, or `agy`'s earlier one served in place of a Cloud Code figure,
686/// already an outcome because it is stale by construction.
687enum RemoteReading {
688    Live(AntigravitySnapshot),
689    Kept(FetchOutcome),
690}
691
692/// Quota as the signed-in Google account: `agy`'s own reading when it can
693/// give one, its last one when it cannot, else the Cloud Code API.
694///
695/// `agy` answers first (#450). It reads as the saved session's account — the
696/// session is `agy`'s own login — so its snapshot is attributed the same way.
697/// Its output names no plan; a plan is looked up only with a token that is
698/// still good, never at the price of a refresh.
699///
700/// When `agy` is installed but gives no reading on this poll — it ran past
701/// its timeout, exited nonzero, or a failure a moment ago is still holding
702/// the next attempt off — the reading it gave last is served stale rather
703/// than replaced: the Cloud Code figure is another tracker for an `agy`
704/// account, and a 65 s `agy` start had turned into ten minutes of "0% used".
705/// Only a reading of `agy`'s own is kept, from the same account, and only
706/// while [`parse_cache_at`] still accepts it: one whose session window has
707/// rolled over is refused there, so a persistently silent `agy` yields to
708/// the Cloud Code API within five hours.
709///
710/// On the Cloud Code path, a rejected token gets one refresh and one retry,
711/// unless it was refreshed a moment ago — then the rejection is Google's
712/// verdict on the session, and the user has to sign in again. A plan-verdict
713/// 403 (`SUBSCRIPTION_REQUIRED`, #405) is not a rejection: it is passed through
714/// untouched, without spending a refresh or an `agy` run on a session that is
715/// fine. The plan name is best-effort: the summary is the figure, and a
716/// missing name must not cost it.
717async 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            // Without an OAuth client this program cannot renew the session
750            // itself, but the `agy` CLI can.
751            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        // The plan is the problem, not the session: refreshing or running
763        // `agy` would only repeat the same verdict.
764        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(&quota, plan)?;
795    snap.account = remote_account(&token.fingerprint);
796    snap.source = AntigravitySource::Remote;
797    Ok(RemoteReading::Live(snap))
798}
799
800/// `agy`'s last reading for `account`, served stale, or `None` when the cache
801/// holds nothing of the kind: no payload, one past [`MAX_STALE`], another
802/// account's, one whose window has rolled over, or a Cloud Code figure — the
803/// very thing this exists not to repeat. The caller then goes on to the Cloud
804/// Code API as before, so the error this hands the shared policy is never
805/// shown; it only names the branch.
806///
807/// [`MAX_STALE`]: crate::cache::MAX_STALE
808fn 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
819/// Identity of the signed-in account, fingerprinted rather than stored in
820/// clear — the cache only needs a change detector, not the address itself.
821/// An unidentifiable response yields a stable "unknown" bucket so two such
822/// responses still compare equal.
823fn 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
838/// The desktop products embed a CSRF token in the HTML served at `/`. The
839/// `agy` CLI serves no such page, so a missing token is recorded as `None` and
840/// its precise rejection is classified after the RPC probe.
841async fn fetch_csrf(client: &reqwest::Client, base: &str) -> Option<String> {
842    let resp = client.get(base).timeout(HTTP_TIMEOUT).send().await.ok()?;
843    // Bounded like every other response this crate reads: a local server is
844    // still an untrusted source of unbounded bytes.
845    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    // Cap error bodies too. A local endpoint is still untrusted, and reading a
874    // non-2xx response with `text()` would bypass the invariant enforced for
875    // successful JSON responses.
876    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
898// ---------------------------------------------------------------------------
899// Quota parsing
900// ---------------------------------------------------------------------------
901
902/// Map a `RetrieveUserQuotaSummary` payload onto the four usage windows.
903///
904/// Buckets are keyed by `bucketId` (`gemini-5h`, `gemini-weekly`, `3p-5h`,
905/// `3p-weekly`), falling back to the group display name plus the `window`
906/// discriminator so a renamed bucket id still lands in the right slot. The
907/// fallback only runs when no canonical id arrived at all.
908/// One bucket, named the way the response named it.
909fn 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
922/// A quota summary with nothing we can render. Naming the buckets that *were*
923/// present turns a report of this into something actionable — the alternative
924/// says only what we wanted, which tells neither the user nor a maintainer
925/// whether the plan has no such pool, the product renamed one, or a new
926/// cadence appeared.
927fn 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    // Slot order: Gemini 5h, Gemini weekly, Claude/GPT 5h, Claude/GPT weekly.
949    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    // Besides the two shared groups, the summary carries pools of their own,
965    // such as "Gemini 4 Argon" (`gemini-argon-wtu-weekly`), whose ids look like
966    // a shared bucket to the prefix/suffix heuristic. While any canonical id is
967    // present, a group holding none of them is such a pool and is shown under
968    // its own name. Only when no canonical id arrived at all — the ids were
969    // renamed — are slots inferred from id prefixes and group names.
970    let renamed = !groups.iter().any(shares_a_slot);
971
972    // Per slot, the buckets whose id is the canonical one and the buckets that
973    // only look like it. A canonical id always wins.
974    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    // What the response actually offered, so a summary we cannot use says so
978    // instead of only naming what it wanted. Bucket ids and group names are
979    // quota vocabulary, not account data.
980    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                // A new cadence is not a 5-hour bucket by default. Ignore it
1002                // so it cannot overwrite a known slot.
1003                continue;
1004            };
1005            if own_pool {
1006                pools.push((group_name, bucket, is_weekly));
1007                continue;
1008            }
1009            // A noncanonical bucket inside a shared group cannot supply a
1010            // missing shared window, even when its prefix and cadence match.
1011            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                // An unrelated group is not implicitly the third-party pool;
1024                // it is one of its own.
1025                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    // Infer slots only when the whole response has no canonical ids. Two
1035    // candidates at the same rank cannot be told apart, so that is an error
1036    // rather than a guess.
1037    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    // A pool that will not parse is left out rather than failing the parse:
1058    // it must not take the shared windows down with it, and showing nothing
1059    // for it is not showing a figure that did not arrive.
1060    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    // Not every product offers every window: Antigravity CLI 1.1.22 returns
1072    // weekly buckets only. One recognised window is enough to render — what
1073    // must never happen is showing a figure for a window that did not arrive.
1074    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        // Stamped by the caller, which is what knows the session's identity
1086        // and which path it came through.
1087        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
1097/// A pool's label: the group's display name, as Antigravity's own Model Quota
1098/// screen shows it, else the bucket's, else its id. It is API text bound for
1099/// every frontend, so control and bidi characters are stripped here.
1100fn 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
1112/// `remainingFraction` is required and must be finite: defaulting a missing or
1113/// drifted value to 1.0 would report a reassuring "0% used" for a window whose
1114/// real state is unknown, and cache it.
1115fn 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
1136/// The API reports how much is *left*; every other vendor here reports how much
1137/// is *spent*.
1138fn 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
1155// ---------------------------------------------------------------------------
1156// Language server discovery
1157// ---------------------------------------------------------------------------
1158
1159/// Base URLs worth probing, most specific first. With `ide_language_server`
1160/// false, listeners owned by the Antigravity IDE's language server are marked
1161/// so [`open_session`] skips them (#405).
1162fn candidate_bases(ide_language_server: bool) -> Vec<Candidate> {
1163    let discovered = discover_ls_ports();
1164    // Identifying the IDE's listeners costs an extra lookup per pid, so it
1165    // runs only when they are actually unwanted.
1166    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
1178/// Test seam for [`candidate_bases`] — takes the address override, the
1179/// discovered `(pid, port)` pairs, and the pids discovery identified as the
1180/// IDE's language server instead of reading the environment and `/proc`.
1181fn 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        // An override that names a port discovery also saw belongs to that
1189        // process: its missing-CSRF answer must spare the process's sibling
1190        // listeners too. It is never marked as the IDE's — an explicit
1191        // address is the user's target and stays a candidate regardless.
1192        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    // No hardcoded fallback port on purpose: the server always binds with
1201    // `--https_server_port 0`, so its port is drawn from the ephemeral range
1202    // and cannot be guessed. Probing a fixed one would just poke whatever
1203    // unrelated process happens to own it. Discovered ports follow any
1204    // explicit override as fallback, with duplicates omitted.
1205    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/// Does this executable belong to the Antigravity IDE rather than the
1220/// Antigravity 2.0 desktop app or the `agy` CLI? The IDE's install tree is
1221/// the only one that names itself: `antigravity-ide` (Linux packages) or
1222/// `Antigravity IDE` (macOS and Windows).
1223#[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/// The pids, among `pids`, that run the IDE's language server.
1230#[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/// `lsof` reports only command names, so the IDE is identified by asking `ps`
1242/// for the owning executables — on macOS `comm` is the full path.
1243#[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    // By path, like every other `ps` here: the menu bar app puts user
1254    // directories ahead of the system ones on `PATH` (#472).
1255    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/// `ps -o pid=,comm=` rows, `<pid> <executable path>`; the path can hold
1269/// spaces (`Antigravity IDE.app`), so only the pid is split off.
1270#[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/// ToolHelp reports only the executable's file name, never its install path,
1282/// so the IDE's language server cannot be told apart from the 2.0 app's here:
1283/// `ide_language_server = false` currently has no effect on Windows.
1284#[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
1294/// The port an already-normalized base targets, when it names one.
1295fn base_port(base: &str) -> Option<u16> {
1296    let (_, authority) = base.split_once("://")?;
1297    authority.rsplit_once(':')?.1.parse().ok()
1298}
1299
1300/// The one process discovery attributes to `port`; `None` when it is not
1301/// listed or more than one process claims it.
1302fn 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
1311/// Turn a configured address into a base URL: trim surrounding whitespace,
1312/// supply the default scheme when it is missing, and drop trailing slashes so
1313/// the RPC paths built on top do not come out with a double slash.
1314///
1315/// Returns `None` when nothing but a scheme survives. `ANTIGRAVITY_LS_ADDRESS`
1316/// is user input, and a value like `"/"` carries no authority to connect to;
1317/// admitting it as a candidate would spend a probe to learn what is already
1318/// knowable here.
1319fn 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
1329/// Does this process look like one of the three Antigravity products?
1330///
1331/// Antigravity 2.0 and the IDE spawn a separate `language_server` child, while
1332/// the `agy` CLI embeds the same CSRF/RPC surface in its own process — so
1333/// matching on the server binary alone would miss a CLI-only install. `comm` is
1334/// truncated to 15 bytes by the kernel, which `language_server` exactly fills.
1335fn 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/// Flatten per-process listener ports into the order they should be probed,
1349/// keeping each port's owner so `open_session` can spare the rest of a
1350/// process's listeners once its RPC answer proves the product unusable.
1351///
1352/// Each Antigravity product binds an HTTPS/TLS listener and the unencrypted
1353/// HTTP JSON-RPC listener that serves `GetUserStatus` and
1354/// `RetrieveUserQuotaSummary`. Both are ephemeral, but they are bound in that
1355/// order, so in practice the RPC listener draws the higher number and probing
1356/// high-to-low reaches it first — which keeps Go's `net/http.Server` from
1357/// logging a TLS handshake error for every unencrypted request that lands on
1358/// its HTTPS listener.
1359///
1360/// Sorting every discovered port as one descending set only gets that right
1361/// for a single process, because the high/low tendency holds *within* a
1362/// product and says nothing across two of them: with Antigravity 2.0 and an
1363/// `agy` session both up, one product's TLS port can sort above the other's
1364/// RPC port. Ports are therefore grouped per pid, sorted high-to-low inside
1365/// each group, and taken rank by rank — every product's highest port, then
1366/// every product's second-highest, and so on. Where each product shows both
1367/// listeners that puts every RPC one ahead of every TLS one.
1368///
1369/// This stays a preference, not a guarantee, and the two-listener shape is the
1370/// assumption it rests on: a product caught mid-startup, with only its TLS port
1371/// bound so far, sits alone at rank 0 and is probed first. A mis-ranked one
1372/// costs an extra round-trip, nothing more — unless its answer proves it
1373/// unusable, in which case its remaining ports are skipped rather than probed.
1374///
1375/// Order among products is arbitrary — all of them report the same
1376/// account-wide quota, so whichever answers first is authoritative — and pid
1377/// order is used only to keep the result reproducible, since `/proc`, `lsof`
1378/// and the Windows TCP table each enumerate in their own order.
1379#[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            // Within a product a port is one listener however many rows named
1386            // it — a dual-stack bind reports the same port from both
1387            // `/proc/net/tcp` and `tcp6`. Collapsing them here keeps a rank
1388            // meaning "the Nth listener" rather than "the Nth row".
1389            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            // Two processes cannot share one loopback port; dedup by port so a
1401            // pathological double-claim stays a single probe.
1402            if !ports.iter().any(|(_, p)| *p == port) {
1403                ports.push((pid, port));
1404            }
1405        }
1406    }
1407    ports
1408}
1409
1410/// Loopback `(pid, port)` pairs listened on by any running Antigravity
1411/// product.
1412///
1413/// Reads `/proc` directly rather than shelling out to `ss`/`lsof`: find the
1414/// candidate pids, collect their socket inodes, then keep the listening TCP
1415/// entries owning one of those inodes. All three products report the *same*
1416/// shared quota, so whichever answers first is authoritative.
1417#[cfg(target_os = "linux")]
1418pub(crate) fn discover_ls_ports() -> Vec<(u32, u16)> {
1419    use std::collections::{BTreeMap, HashMap};
1420
1421    // Socket inode -> owning pid, so the ports found in `/proc/net` can be
1422    // grouped back per process for `probe_order`.
1423    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/// macOS has no `/proc`, so fall back to `lsof` (present on every macOS
1482/// install by default, unlike Linux where shelling out was deliberately
1483/// avoided — see the doc comment above). `-F pcn` asks for machine-parsable
1484/// output: one `p<pid>` line per process, one `c<command>` line for its name,
1485/// then an `n<address>` line per matching socket already filtered down to
1486/// listening TCP sockets by `-iTCP -sTCP:LISTEN`.
1487#[cfg(target_os = "macos")]
1488pub(crate) fn discover_ls_ports() -> Vec<(u32, u16)> {
1489    // By path, for the reason `ide_ls_pids` gives for `ps` (#472).
1490    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    // A non-zero exit still emits usable output for the fds it *could* read,
1497    // so parse regardless of status — an empty/garbled stdout just parses to
1498    // an empty port list.
1499    parse_lsof_pcn(&String::from_utf8_lossy(&output.stdout))
1500}
1501
1502/// Pure parser for `lsof -F pcn` output, kept separate from process spawning
1503/// so the parsing logic is unit-testable without shelling out. Compiled under
1504/// `test` on every platform, like [`matching_windows_ports`], so its tests are
1505/// not macOS-only.
1506#[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    // The pid arrives on the `p` line and the command name on the `c` line
1510    // right after it, so hold the pid until the name confirms it is ours.
1511    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/// Loopback ports owned by the matching processes, grouped per pid and handed
1558/// to [`probe_order`], which explains why the grouping matters.
1559#[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/// Pull `(local_port, inode)` out of a listening row of `/proc/net/tcp`.
1751/// Columns: `sl local_address rem_address st ... uid timeout inode`.
1752#[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    // 0x0A == TCP_LISTEN. Anything else is an established/closing socket.
1759    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
1767// ---------------------------------------------------------------------------
1768// Cache
1769// ---------------------------------------------------------------------------
1770
1771fn 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
1777/// Serve the stale cache when there is one. With no cache to fall back on,
1778/// surface `reason` — the actual diagnosis, e.g. "no local language server
1779/// found" — rather than a generic cache-miss that tells the user nothing about
1780/// what to do. This is the first-run path: no cache yet and Antigravity closed.
1781fn 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
1803/// `account` is the fingerprint of the currently signed-in account, or `None`
1804/// when no local server answered. A payload belonging to a different account is
1805/// rejected so a Google-account switch cannot show the previous account's
1806/// quota. With `None` we cannot verify — but nothing is consuming quota while
1807/// Antigravity is down, so the last known figures are the best available truth.
1808pub fn parse_cache(bytes: &[u8], account: Option<&str>) -> Result<AntigravitySnapshot> {
1809    parse_cache_at(bytes, account, Utc::now())
1810}
1811
1812/// A cached window whose reset has already passed describes a period that has
1813/// since rolled over: the real figure is back near zero while the payload still
1814/// carries the old one, and its countdown is pinned at "now". Serving that is
1815/// presenting a known-obsolete number as current, so the payload is refused and
1816/// the caller reports that Antigravity needs to be running.
1817///
1818/// This matters more here than for other vendors: "nothing running" is the
1819/// normal state for Antigravity, and `MAX_STALE` is seven days — far past the
1820/// five hours after which the session window is guaranteed wrong.
1821fn 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
1840/// The cadence a cached pool window was written with. Pools reuse the shared
1841/// windows' two lengths, so anything else is a document we did not write.
1842fn 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    // The Gemini windows are required. Defaulting a missing or truncated field
1863    // to 0 would render a confident "0% used" and keep serving it for the rest
1864    // of the TTL; returning an error makes the caller fall through to a live
1865    // fetch instead of displaying a fabricated snapshot.
1866    // An absent window and a truncated payload look alike unless we insist on
1867    // the difference: `snap_to_json` always writes every key, so an explicit
1868    // `null` means "this product reported no such window" while a *missing*
1869    // key means the document is not one we wrote whole. Only the first is a
1870    // snapshot; the second must refetch rather than render a window short.
1871    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        // Payloads written before the remote path existed were all local.
1908        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    // A cache with no window left is not a snapshot; refetch rather than draw
1920    // an empty panel from it. Mirrors the live parse.
1921    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
1940/// The per-model pools of a cached payload. Payloads written before pools
1941/// existed have no `model_pools` key and carry none; an entry that is present
1942/// but malformed means the document is not one we wrote whole, so it refetches.
1943fn 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                // Cache files are local, but keeping the same display-field
1965                // invariant as live responses prevents terminal/control text
1966                // from re-entering renderers through a damaged file.
1967                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    /// Captured from a real `RetrieveUserQuotaSummary` response on 2026-07-22
2016    /// (Antigravity 2.0 build 2.3.1, `agy` 1.1.5), then trimmed. Percentages
2017    /// were edited to distinct non-zero values so a slot mix-up cannot pass.
2018    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    /// Fixed instant, earlier than every reset in the fixture. Using the wall
2046    /// clock here would make the suite start failing once those resets pass.
2047    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        // remainingFraction is inverted into "used".
2063        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        // Regression: weekly must never be a copy of the 5h window.
2089        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    /// The summary as Antigravity CLI served it in October 2026: a per-model
2132    /// "Gemini 4 Argon" pool beside the two shared ones. Its id looks like a
2133    /// Gemini weekly bucket to the heuristic, and used to fail the whole parse
2134    /// as a duplicate of `gemini-weekly`. It is a separate limit on Pro plans,
2135    /// so it is reported as a pool of its own.
2136    #[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    /// A pool that will not parse is left out; it must not take the shared
2173    /// windows down with it.
2174    #[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    /// A pool's label is API text bound for every frontend.
2191    #[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        // A bucket that only resembles a canonical one never displaces it, and
2229        // is not even parsed: its missing fraction would otherwise be an error.
2230        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        // Two buckets at the same rank cannot be told apart: canonical ids
2242        // repeated, or two lookalikes competing for a slot nobody named.
2243        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        // A future cadence is ignored, and a future group is a pool of its
2258        // own, not silently treated as the Claude/GPT 5h slot.
2259        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    /// A drifted bucket must fail the parse rather than report a reassuring
2283    /// "0% used" for a window whose real state is unknown.
2284    #[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    /// The cache must round-trip a product that has no 5h window, and must
2309    /// still reject a document it did not write whole — an explicit `null`
2310    /// means "no such window", a missing key means truncation.
2311    #[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    /// Issue #139: Antigravity CLI 1.1.22 on a paid account returns weekly
2328    /// buckets and no 5-hour ones. Two usable windows arrived, so requiring a
2329    /// Gemini 5h bucket threw both away and failed the whole vendor. This is
2330    /// the reporter's payload.
2331    #[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    /// The opposite shape must work for the same reason — the fix is "at least
2364    /// one window", not "weekly is the required one now".
2365    #[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    /// Nothing recognisable is still an error, and still names what arrived so
2383    /// the next report is diagnosable.
2384    #[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    /// A summary with groups but no buckets at all is a different situation
2408    /// from a summary whose buckets we did not recognise, and says so.
2409    #[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    /// An unnamed bucket must still be listed — a summary of nothing but
2424    /// unnamed buckets is itself the finding.
2425    #[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    /// A truncated payload must fail so the caller refetches. Defaulting the
2452    /// missing field to 0 would serve a confident "0% used" for the rest of the
2453    /// TTL — the fabricated-placeholder defect corrected in PR #26.
2454    #[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        // A wholly empty object is not a zero-usage snapshot either.
2465        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    /// With Antigravity closed the cache is served for up to `MAX_STALE`, but a
2495    /// window whose reset has passed has since rolled over — the real figure is
2496    /// back near zero while the payload still carries the old one. Serving that
2497    /// would present a known-obsolete number as current.
2498    #[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        // Before every reset: served.
2504        assert!(parse_cache_at(&bytes, None, now()).is_ok());
2505        // One second before the earliest (the two 5h windows, 17:47:00Z).
2506        assert!(parse_cache_at(&bytes, None, at("2026-07-22T17:46:59Z")).is_ok());
2507
2508        // The reset instant itself already counts as rolled over.
2509        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        // Well past it — this is the reboot-with-nothing-running case.
2513        assert!(parse_cache_at(&bytes, None, at("2026-07-23T09:00:00Z")).is_err());
2514    }
2515
2516    /// The weekly windows outlive the 5-hour ones, so expiry must be reported
2517    /// per window rather than assuming the shortest one speaks for all four.
2518    #[test]
2519    fn expiry_names_the_window_that_rolled_over() {
2520        let mut snap = parsed();
2521        // Drop the 5h windows so only the weeklies can expire.
2522        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        // Past the 5h resets but before either weekly: still usable.
2528        assert!(parse_cache_at(&bytes, None, at("2026-07-23T09:00:00Z")).is_ok());
2529
2530        // Past the Gemini weekly (28th) but not the third-party one (29th).
2531        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    /// A window with no reset time is unknown, not expired.
2536    #[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    /// Switching Google accounts must not show the previous account's quota.
2552    #[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        // A payload written before the account was recorded is unattributable.
2565        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    /// With no local server there is nothing to compare against — and nothing
2572    /// is consuming quota either, so the last known figures still stand.
2573    #[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        // An unidentifiable response still compares equal to itself.
2590        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    /// The third-party pool is genuinely optional — a plan without it caches a
2596    /// null and must still read back, unlike the required Gemini windows.
2597    #[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        // Trailing slashes are trimmed.
2668        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        // Duplicate base URL is omitted — and the override inherits the pid of
2676        // the process discovery saw on that port.
2677        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        // Duplicate discovered ports are omitted.
2682        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        // An address that already carries a scheme is left alone.
2687        assert_eq!(
2688            candidate_bases_with(Some("https://host:9"), vec![], &no_ide()),
2689            vec![candidate("https://host:9", None)]
2690        );
2691    }
2692
2693    /// No IDE-owned pids — what discovery hands over whenever
2694    /// `ide_language_server` is left at its default.
2695    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    /// A signed-out server is worth reporting even when a later candidate only
2767    /// refused the connection — that is the whole point of probing on past the
2768    /// first failure.
2769    #[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    /// The *first* actionable failure wins, so the explicit override's message
2785    /// survives a second signed-out product further down the list.
2786    #[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    /// With nothing actionable, the last failure stands in for "nothing
2793    /// answered" — and stays transient, so the widget falls back silently
2794    /// instead of shouting about a product that simply is not running.
2795    #[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    /// A 5xx is a server that answered but broke; the user cannot act on it, so
2810    /// it must not outrank a later real failure the way a 401 does.
2811    #[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    /// Verbatim from Go's `net/http`: this is what every Antigravity product's
2827    /// HTTPS listener replies to the cleartext probe, so it is the tail of a
2828    /// normal discovery run rather than a symptom.
2829    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    /// The RPC listener is probed first, so whatever it said is the diagnosis.
2837    /// The TLS port is reached only afterwards and always "fails", so without
2838    /// demoting it, it overwrites the one error that came from the server the
2839    /// user actually cares about.
2840    #[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    /// The regression that motivated this: an `Http` is not transient, so an
2856    /// echo standing in as "the last failure" costs the silent cache fallback
2857    /// that `without_an_auth_failure_the_last_error_stands` exists to protect.
2858    /// A product that is merely not serving RPC must stay quiet.
2859    #[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    /// Demoted, not discarded. When the TLS listener is genuinely all that
2876    /// answered, its reply is still better than a generic "nothing answered".
2877    #[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    /// The demotion keys on the body, so the language server's own `400` — a
2884    /// real complaint about a real request — keeps its normal rank.
2885    #[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    /// Ranking the echo last must not disturb the top of the order.
2901    #[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    /// The server's port is drawn from the ephemeral range, so there is nothing
2927    /// sensible to guess when discovery comes up empty. Probing a hardcoded
2928    /// port would contact an unrelated process; callers get the "start
2929    /// Antigravity or set ANTIGRAVITY_LS_ADDRESS" error instead.
2930    #[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        // Antigravity 2.0 / IDE: a separate language_server child.
2939        assert!(is_antigravity_process(
2940            "language_server\n",
2941            Some("/opt/antigravity/resources/bin/language_server")
2942        ));
2943        // agy CLI: embeds the RPC surface in its own process.
2944        assert!(is_antigravity_process(
2945            "agy\n",
2946            Some("/home/u/.local/bin/agy")
2947        ));
2948        // Recognised by path even when the process name says nothing.
2949        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        // "legacy" ends in a substring of "/agy" but is not the CLI.
2972        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    /// Antigravity 2.0 and an interactive `agy` session at once. Their port
3040    /// pairs must not be flattened into one set: sorting all four descending
3041    /// would put pid 20's TLS listener ahead of pid 10's RPC listener.
3042    #[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    /// The high-to-low preference only means something per product, so the
3063    /// grouping is what keeps a second product's TLS listener from being
3064    /// probed before the first product's RPC listener.
3065    #[test]
3066    fn probe_order_puts_every_rpc_listener_ahead_of_every_tls_listener() {
3067        use std::collections::BTreeMap;
3068
3069        // One process, the ordinary case: RPC (higher) before TLS (lower).
3070        assert_eq!(
3071            probe_order(BTreeMap::from([(10, vec![59868, 59870])])),
3072            vec![(10, 59870), (10, 59868)]
3073        );
3074        // Two products. A plain descending sort would yield 50001, 50000,
3075        // 40001, 40000 and reach pid 20's TLS listener second; taking the
3076        // ports rank by rank leaves both TLS listeners at the back, where they
3077        // are touched only if no RPC listener answered.
3078        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        // Uneven groups: the extra port of the deeper group trails everything
3086        // it ranks below, and a port claimed by two pids is probed once.
3087        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    /// A dual-stack bind names the same port from both `/proc/net/tcp` and
3098    /// `tcp6`. Those rows are one listener, so they must not consume two ranks
3099    /// and push the product's real second listener down past another
3100    /// product's.
3101    #[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    /// The ordering rests on each product showing both listeners. A product
3116    /// caught mid-startup, with only its TLS port bound, sits alone at rank 0
3117    /// and is probed first — documented as the known cost, and harmless
3118    /// because every candidate is probed anyway.
3119    #[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    /// `ANTIGRAVITY_LS_ADDRESS` is user input. An entry that leaves no
3133    /// authority to connect to is dropped instead of probed, so it can neither
3134    /// spend a round-trip nor add a failure that competes with the real one in
3135    /// [`select_probe_error`].
3136    #[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        // `agy` (pid 74101) has three listening sockets; `sshd` (pid 200) has
3220        // one that must be excluded even though it sorts right after `c`.
3221        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    /// The pid on each `p` line has to survive to the `n` lines, or the ports
3229    /// of two running products collapse into one group and rank ordering can
3230    /// no longer keep the TLS listeners last.
3231    #[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    /// The menu bar puts user-writable directories before the macOS system
3258    /// directories. Every fixed system tool in the refresh and desktop-action
3259    /// boundary is therefore spawned by its canonical path (#472).
3260    #[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    /// First run with Antigravity closed: no cache to serve, so the user must
3294    /// be told what to start — not "no usable cache", which says nothing.
3295    #[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    /// The IDE's install tree is the only one that names itself; the 2.0
3355    /// desktop app and the `agy` CLI must never be mistaken for it.
3356    #[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    /// `ps -o pid=,comm=` output keeps paths with spaces in one piece.
3375    #[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    /// #405, the enumeration seam: discovery marks the IDE's listeners so
3397    /// `open_session` skips them; an explicit override that names the IDE's
3398    /// port is the user's own target and is probed regardless.
3399    #[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    // -----------------------------------------------------------------------
3421    // Remote fallback
3422    // -----------------------------------------------------------------------
3423
3424    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    /// A keyring blob as Antigravity writes it, expiring at `expiry`.
3440    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    /// Well before the fixture's `now()`.
3452    const EXPIRED: &str = "2026-07-22T11:00:00Z";
3453    /// Comfortably after it.
3454    const VALID: &str = "2026-07-22T13:00:00Z";
3455
3456    /// No local server, this blob, these endpoints.
3457    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    /// The bare summary the API returns: the RPC's payload without its
3467    /// `{"response": …}` envelope.
3468    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        // A token this fresh is used as it is.
3524        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        // What was cached is attributed to the remote path too.
3548        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    /// `agy` exposes the local RPC port but, unlike the desktop products, does
3562    /// not expose the CSRF token needed to use it. That one precise response is
3563    /// equivalent to having no usable local source, so the saved session may
3564    /// answer without weakening the normal signed-out-server rule below.
3565    #[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    /// `agy` 1.3.1 prefixes the historical message with `unauthenticated: `.
3619    /// That still means the local RPC is unusable, so the saved session must
3620    /// answer the same way as the unprefixed wording above.
3621    #[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    /// Both ports belong to the same `agy` process: once the RPC listener
3722    /// reports the missing-CSRF verdict, the companion TLS listener must never
3723    /// be probed — the cleartext request is exactly what makes Go log the
3724    /// handshake error.
3725    #[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        // server2 is the same process's other listener: must NEVER be probed.
3746        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    /// `agy` and a signed-in desktop product can run side by side, and the
3787    /// candidates reach `open_session` flattened rank by rank — the port after
3788    /// `agy`'s can be the other product's RPC listener, not `agy`'s TLS one.
3789    /// The missing-CSRF verdict covers only `agy`'s own remaining ports;
3790    /// skipping the whole list would sacrifice the usable session to spare
3791    /// one listener.
3792    #[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    /// The refreshed token is persisted under the session's fingerprint, so
3869    /// the next poll spends no round-trip on the same refresh.
3870    #[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        // A zero TTL forces the network again; the token endpoint stays at one hit.
3894        run(&cache, remote(&blob, &eps), Duration::ZERO)
3895            .await
3896            .expect("persisted token reused");
3897        token.assert_async().await;
3898    }
3899
3900    /// The API rejecting a token that was not just minted is the token being
3901    /// stale, not the session: one refresh, one retry.
3902    #[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    /// A refresh Google refuses is the session being gone. The error is
3932    /// actionable and carries no token material.
3933    #[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    /// A `401` against a token minted a moment ago is Google's verdict on the
3957    /// session; refreshing again would only repeat it.
3958    #[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    /// #405: a plan without Antigravity quota is Google's verdict on the plan,
3981    /// not on the session. The friendly message must reach the user as-is —
3982    /// no refresh round-trip, no `agy` run, no "sign in again" detour.
3983    #[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    /// #405: the pass-through is exactly the subscription verdict — any other
4041    /// 403 is still a rejected token: one refresh, one retry, then the session
4042    /// verdict.
4043    #[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    /// `run` with no OAuth client configured, the case `agy` exists for.
4079    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    /// An `agy` stand-in: appends to `runs`, then writes `blob` into `file`
4092    /// the way the real CLI rewrites the keyring entry. Its print mode fails
4093    /// without a trace, like an `agy` that predates `-p "/quota"`, so these
4094    /// tests exercise the Cloud Code path and count only renewals.
4095    #[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    /// The whole point: with no OAuth client and an expired session, running
4116    /// `agy` renews it and the quota comes back on the same poll.
4117    #[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    /// `agy` that leaves the session as it was changes nothing: the original
4154    /// error stands, and the failed attempt is not repeated on the next poll.
4155    #[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    /// The default seam is off: a test that never mentions `agy` cannot run
4183    /// the real CLI, even with an expired session and no OAuth client.
4184    #[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    /// What `agy -p "/quota" --output-format json` prints, cut down to the
4198    /// fields read, on the fixture's clock.
4199    #[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    /// An `agy` that appends its arguments to `runs` and answers print mode
4211    /// with [`AGY_QUOTA`]; any other command fails.
4212    #[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    /// #450: `agy`'s own reading answers before the Cloud Code API is asked,
4226    /// attributed to the saved session like any remote snapshot; the plan
4227    /// still comes from `loadCodeAssist` while the token is good.
4228    #[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    /// An expired session costs `agy`'s reading nothing: no refresh, no
4299    /// renewal run, and no plan lookup with a token that is no longer good.
4300    #[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    /// When `agy` cannot answer, the Cloud Code API still does, and the
4334    /// failed reading is not repeated on the next poll.
4335    #[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            // As if the reading floor had passed, so the next poll fetches.
4363            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    /// Move the payload past the reading floor without discarding it, as the
4370    /// clock would: the next poll fetches, and the payload is still there to
4371    /// fall back on.
4372    #[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    /// `agy` answered once, then cannot — a cold start past the timeout, and
4384    /// the cooldown that follows. Its reading is kept, stale, rather than
4385    /// replaced by the Cloud Code API's other tracker (#450): unpinned, the
4386    /// entry read "0% used" for ten minutes at a time while `agy` read 90%.
4387    #[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        // The floor passes, and `agy` stops answering.
4418        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        // One reading, one failed attempt: the cooldown held the second poll's
4436        // attempt off, and the kept reading answered it all the same.
4437        assert_eq!(std::fs::read_to_string(runs).unwrap().lines().count(), 2);
4438    }
4439
4440    /// Only a reading of `agy`'s own is kept. A Cloud Code figure is the one
4441    /// to replace, so with that in the cache the Cloud Code API is asked again.
4442    #[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    /// A reading starts a process, so it is taken at most every
4472    /// [`super::super::agy::QUOTA_INTERVAL`], however short the TTL.
4473    #[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    /// A session saved without a refresh token cannot be renewed here.
4499    #[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    /// Nothing running and nothing saved: the local diagnosis stands, plus
4517    /// the one thing the user can now do about it.
4518    #[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    /// A local server that is up but signed out is its own diagnosis; the
4549    /// saved session must not paper over it.
4550    #[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    /// #405: the reporter's exact setup. The IDE is open but its language
4585    /// server is signed out (a 403 that would outrank `agy` and mask the
4586    /// saved session); `ide_language_server = false` takes the IDE out of
4587    /// discovery, so `agy`'s missing-CSRF answer unlocks the saved session —
4588    /// which is what closing the IDE did by hand.
4589    #[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        // The IDE must never be probed: its 403 is what masked `agy`.
4595        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        // `agy`: no CSRF page, then its precise missing-CSRF verdict.
4604        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    /// With the IDE skipped and nothing else running, the machine behaves as
4668    /// if the IDE were closed: "no local server" sends the fetch down the
4669    /// saved-session path, never a generic "nothing answered".
4670    #[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    /// The remote path falls back exactly like the local one: the last good
4721    /// payload is served stale, with the failure attached.
4722    #[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        // A payload from before pools existed carries none.
4780        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        // A pool entry we did not write whole refetches.
4791        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        // A locally damaged cache cannot reintroduce control text into any
4797        // renderer even though the rest of the entry remains usable.
4798        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    /// A pool past its reset is as obsolete as a shared window past its own.
4806    #[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        // A payload from before the field existed is a local one.
4840        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}