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 model groups
12//! — Gemini, and Claude/GPT — each holding a 5-hour and a weekly bucket.
13//! `GetUserStatus` carries only the plan name; its per-model `quotaInfo` mirrors
14//! whichever bucket is scarcest and must not be read as a window in its own
15//! right.
16//!
17//! When no usable product is running there is still a way to answer:
18//! Antigravity keeps the Google session it signed in with in the OS keyring,
19//! and the same quota summary is served by the Cloud Code API. That is the
20//! *fallback*, taken when no local server was found or when `agy` reports that
21//! its undiscoverable CSRF token is required. Every other local rejection — a
22//! server that is signed out or answering on the wrong protocol — keeps its
23//! own diagnosis.
24//!
25//! That ranking has one sharp edge (#405): a signed-out product's 401/403 is
26//! actionable, so it outranks the `agy` answer that would unlock the fallback
27//! — the running product masks a working saved session. When that product is
28//! the IDE and the user signs it in separately, `[antigravity]
29//! ide_language_server = false` removes the IDE's language server from
30//! discovery, leaving `agy` (and the fallback) to answer as if the IDE were
31//! closed.
32
33use std::time::Duration;
34
35use chrono::{DateTime, Utc};
36
37use super::cloud;
38use super::credential::{self, StoredToken};
39use crate::cache::{Cache, acquire_lock_async};
40use crate::error::{AppError, Result};
41use crate::usage::{AntigravitySnapshot, AntigravitySource, UsageWindow};
42
43const HTTP_TIMEOUT: Duration = Duration::from_secs(5);
44const LOCK_TIMEOUT: Duration = Duration::from_secs(15);
45
46const QUOTA_RPC: &str = "exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary";
47const STATUS_RPC: &str = "exa.language_server_pb.LanguageServerService/GetUserStatus";
48
49const DEFAULT_PLAN: &str = "Antigravity";
50
51const NO_LOCAL_SERVER: &str = "Antigravity: no local server found. Quota is only served while \
52                               Antigravity is running — open the Antigravity app, or an interactive \
53                               `agy` session, or point ANTIGRAVITY_LS_ADDRESS at a host:port.";
54
55const AGY_CSRF_UNAVAILABLE: &str = "Antigravity: the running `agy` server requires a CSRF token \
56                                   that it does not publish.";
57
58/// Appended to [`NO_LOCAL_SERVER`] once the remote fallback has also come up
59/// empty: the user has a second way out that the local-only message does not
60/// mention.
61const NO_SAVED_SESSION: &str = "Or sign in to Antigravity once, so its saved Google session can \
62                                be used while it is closed.";
63
64const SESSION_EXPIRED: &str =
65    "Antigravity's saved Google session expired; open Antigravity to sign in again";
66
67/// This vendor's [`Outcome`](crate::outcome::Outcome) — the shared shape,
68/// specialised to its snapshot.
69pub type FetchOutcome = crate::outcome::Outcome<AntigravitySnapshot>;
70
71impl From<FetchOutcome> for crate::vendor::VendorOutcome {
72    fn from(o: FetchOutcome) -> Self {
73        o.map(crate::usage::VendorSnapshot::Antigravity)
74    }
75}
76
77/// The keyring blob, or a stand-in for it. `Absent` exists so a test can
78/// exercise "nothing saved" without asking the real keyring, which is what
79/// `None` would have to mean otherwise.
80#[derive(Debug, Clone, Copy, Default)]
81pub enum SavedCredential<'a> {
82    /// Read the OS keyring, as production does.
83    #[default]
84    Keyring,
85    /// Use this raw blob.
86    Blob(&'a str),
87    /// Read the blob from this file each time, so a test can model a
88    /// credential that another program rewrites between two reads.
89    File(&'a std::path::Path),
90    /// Behave as if the keyring held nothing.
91    Absent,
92}
93
94/// Test seam for the remote fallback. Production passes
95/// [`RemoteOverride::default`], which reads the OS keyring, talks to Google,
96/// and probes the local ports discovery finds; a test supplies each of those
97/// instead so it never touches the real keyring, the network, or `/proc`.
98pub struct RemoteOverride<'a> {
99    /// Where the saved Google session comes from.
100    pub credential: SavedCredential<'a>,
101    /// Cloud Code endpoints, in place of [`cloud::Endpoints::default`].
102    pub endpoints: Option<&'a cloud::Endpoints>,
103    /// Local candidates to probe, in place of discovery. `Some(vec![])` means
104    /// "no local server", which is what sends the fetch down the remote path.
105    /// `pid` marks which process owns each listener; tests that model one
106    /// product's two listeners give them the same pid.
107    pub local_bases: Option<Vec<Candidate>>,
108    /// How an expired session is renewed through the `agy` CLI. Off by
109    /// default, so only [`fetch_snapshot`] — which asks for discovery — ever
110    /// runs the real one.
111    pub agy: super::agy::AgyCommand,
112    /// Probe the Antigravity IDE's language server as a candidate. Production
113    /// passes `[antigravity] ide_language_server`; the default keeps it,
114    /// preserving discovery as it has always been.
115    pub ide_language_server: bool,
116}
117
118impl Default for RemoteOverride<'_> {
119    fn default() -> Self {
120        Self {
121            credential: SavedCredential::default(),
122            endpoints: None,
123            local_bases: None,
124            agy: super::agy::AgyCommand::default(),
125            ide_language_server: true,
126        }
127    }
128}
129
130/// One probe target: the listener's base URL and, when it came from
131/// discovery, the process that owns it. A `missing CSRF` answer spares only
132/// the *same process's* remaining listeners, so the pid is what keeps a
133/// second Antigravity product's ports reachable.
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct Candidate {
136    /// Normalized `scheme://authority` URL the RPC paths are appended to.
137    pub base: String,
138    /// Owning pid when the listener was discovered; `None` for a bare test
139    /// seam or an override whose port discovery did not see.
140    pub pid: Option<u32>,
141    /// The Antigravity IDE's language server, which [`open_session`] never
142    /// probes. A signed-out IDE answers an actionable 401/403 that outranks a
143    /// working `agy` session (#405), so a user who signs the IDE in
144    /// separately skips it. Discovery sets this only when `[antigravity]
145    /// ide_language_server` is false; an explicit `ANTIGRAVITY_LS_ADDRESS`
146    /// stays a candidate regardless.
147    pub ide: bool,
148}
149
150impl From<String> for Candidate {
151    fn from(base: String) -> Self {
152        Self {
153            base,
154            pid: None,
155            ide: false,
156        }
157    }
158}
159
160/// `[antigravity] ide_language_server`: probe the IDE's language server as a
161/// candidate (`true`, the default) or leave it out of discovery entirely.
162pub async fn fetch_snapshot(
163    client: &reqwest::Client,
164    cache: &Cache,
165    cache_ttl: Duration,
166    oauth: Option<&cloud::OauthClient>,
167    ide_language_server: bool,
168) -> Result<FetchOutcome> {
169    fetch_snapshot_at(
170        client,
171        cache,
172        cache_ttl,
173        oauth,
174        RemoteOverride {
175            agy: super::agy::AgyCommand::Discover,
176            ide_language_server,
177            ..Default::default()
178        },
179        Utc::now(),
180    )
181    .await
182}
183
184/// Clock seam for [`fetch_snapshot`], so window expiry can be exercised at
185/// fixed instants instead of against the wall clock, and the seam for
186/// everything the remote fallback would otherwise read from the machine.
187pub async fn fetch_snapshot_at(
188    client: &reqwest::Client,
189    cache: &Cache,
190    cache_ttl: Duration,
191    oauth: Option<&cloud::OauthClient>,
192    remote: RemoteOverride<'_>,
193    now: DateTime<Utc>,
194) -> Result<FetchOutcome> {
195    cache.ensure_dir()?;
196    let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
197
198    // Resolve the signed-in account first so a fresh cache can be attributed.
199    // Unlike Grok — where the same check would cost a remote round-trip on
200    // every poll — this is loopback, and it is the call that would supply the
201    // plan name anyway, so verification is effectively free.
202    //
203    // With no local server at all, the saved Google session identifies the
204    // account just as cheaply: reading the keyring is local, and only the
205    // quota call itself goes to the network — after the fresh-cache check,
206    // like the local RPC.
207    let origin = match open_session(
208        client,
209        remote.local_bases.as_deref(),
210        remote.ide_language_server,
211    )
212    .await
213    {
214        Ok(session) => Origin::Local(Ok(session)),
215        Err(error) => match remote_fallback_reason(&error) {
216            Some(reason) => Origin::Remote(saved_session(remote.credential, reason)),
217            None => Origin::Local(Err(error)),
218        },
219    };
220    let account = origin.account();
221
222    if let Some(bytes) = cache.fresh_payload(cache_ttl)?
223        && let Ok(outcome) = reuse_cache(bytes, cache, false, account.as_deref(), now)
224    {
225        return Ok(outcome);
226    }
227
228    let default_endpoints = cloud::Endpoints::default();
229    let endpoints = remote.endpoints.unwrap_or(&default_endpoints);
230    let live = match origin {
231        Origin::Local(session) => fetch_live(client, session).await,
232        Origin::Remote(token) => {
233            let renewal = Renewal {
234                agy: &remote.agy,
235                credential: remote.credential,
236            };
237            fetch_remote(client, cache, oauth, endpoints, token, renewal, now).await
238        }
239    };
240
241    match live {
242        Ok(snap) => {
243            let bytes = serde_json::to_vec(&snap_to_json(&snap))?;
244            cache.write_payload(&bytes)?;
245            Ok(crate::outcome::Outcome::fresh(snap))
246        }
247        Err(e) if e.is_transient() => fallback_silent(cache, now, e),
248        Err(AppError::Http { status, body }) => {
249            cache.mark_stale();
250            let last_error = Some(cache.write_last_error(status, &body));
251            let reason = AppError::Http { status, body };
252            fallback_with_error(cache, last_error, reason, now)
253        }
254        Err(e) => {
255            cache.mark_stale();
256            let last_error = Some(cache.write_last_error(0, &e.to_string()));
257            fallback_with_error(cache, last_error, e, now)
258        }
259    }
260}
261
262/// A local server that answered `GetUserStatus`: where it lives, how to talk to
263/// it, and whose account it is signed in as.
264struct Session {
265    base: String,
266    csrf: Option<String>,
267    plan: String,
268    account: String,
269}
270
271/// Which source this fetch will draw on, decided before the cache is consulted
272/// so a fresh payload can be checked against the right account.
273enum Origin {
274    Local(Result<Session>),
275    Remote(Result<StoredToken>),
276}
277
278impl Origin {
279    /// The account fingerprint, when the source identified one.
280    fn account(&self) -> Option<String> {
281        match self {
282            Origin::Local(Ok(session)) => Some(session.account.clone()),
283            Origin::Remote(Ok(token)) => Some(remote_account(&token.fingerprint)),
284            Origin::Local(Err(_)) | Origin::Remote(Err(_)) => None,
285        }
286    }
287}
288
289fn no_local_server() -> AppError {
290    AppError::Credentials(NO_LOCAL_SERVER.into())
291}
292
293#[derive(Clone, Copy)]
294enum RemoteFallbackReason {
295    NoLocalServer,
296    AgyMissingCsrf,
297}
298
299impl RemoteFallbackReason {
300    fn message(self) -> &'static str {
301        match self {
302            Self::NoLocalServer => NO_LOCAL_SERVER,
303            Self::AgyMissingCsrf => AGY_CSRF_UNAVAILABLE,
304        }
305    }
306}
307
308/// Local failures the saved Google session is allowed to answer. The `agy`
309/// CSRF refusal is matched on status, code, and the `missing CSRF token`
310/// wording (including the `unauthenticated:` prefix `agy` 1.3.1 added). An
311/// arbitrary local `401` remains a signed-out diagnosis and never triggers
312/// remote traffic.
313fn remote_fallback_reason(error: &AppError) -> Option<RemoteFallbackReason> {
314    if matches!(error, AppError::Credentials(message) if message == NO_LOCAL_SERVER) {
315        Some(RemoteFallbackReason::NoLocalServer)
316    } else if is_missing_csrf(error) {
317        Some(RemoteFallbackReason::AgyMissingCsrf)
318    } else {
319        None
320    }
321}
322
323/// Walk every candidate language server until one identifies itself. A machine
324/// can host more than one — the desktop app, the IDE and an interactive `agy`
325/// session each run their own — and only some of them are signed in.
326///
327/// `bases` replaces discovery when given; see [`RemoteOverride::local_bases`].
328/// Candidates marked as the IDE's language server are excluded before
329/// anything is probed, so an IDE-only machine with `[antigravity]
330/// ide_language_server = false` falls through to the saved session exactly as
331/// if the IDE were closed — its signed-out 401/403 must not mask a working
332/// `agy` session (#405).
333async fn open_session(
334    client: &reqwest::Client,
335    bases: Option<&[Candidate]>,
336    ide_language_server: bool,
337) -> Result<Session> {
338    let candidates = bases.map_or_else(
339        || candidate_bases(ide_language_server),
340        <[Candidate]>::to_vec,
341    );
342    let candidates: Vec<Candidate> = candidates.into_iter().filter(|c| !c.ide).collect();
343    if candidates.is_empty() {
344        return Err(no_local_server());
345    }
346
347    let mut errors = Vec::new();
348    // Products already proved unusable. Skipping *their* remaining ports —
349    // not the whole list — is what keeps a second product's RPC listener
350    // reachable: the candidates are flattened rank by rank, so the port after
351    // an `agy` answer can belong to a different process.
352    let mut blocked_pids = std::collections::HashSet::new();
353    for candidate in candidates {
354        if candidate.pid.is_some_and(|pid| blocked_pids.contains(&pid)) {
355            continue;
356        }
357        let csrf = fetch_csrf(client, &candidate.base).await;
358        match post_rpc(client, &candidate.base, csrf.as_deref(), STATUS_RPC).await {
359            Ok(v) => {
360                return Ok(Session {
361                    base: candidate.base,
362                    csrf,
363                    plan: plan_from_status(&v),
364                    account: account_key(&v),
365                });
366            }
367            Err(e) => {
368                // agy exposes no token-discovery route and requires CSRF. Once
369                // confirmed, probing this process's other ports only triggers
370                // the same rejection or spurious Go TLS handshake error logs.
371                if is_missing_csrf(&e)
372                    && let Some(pid) = candidate.pid
373                {
374                    blocked_pids.insert(pid);
375                }
376                errors.push(e);
377            }
378        }
379    }
380    Err(select_probe_error(errors))
381}
382
383/// Which failure to report when no candidate answered.
384///
385/// A server that replies `401`/`403` is normally running and reachable but
386/// signed out — the user can act on that, so it outranks the connection
387/// refusals from products that simply are not up. `agy`'s exact missing-CSRF
388/// response is ranked separately because it has no token discovery route.
389/// Without this, a stale
390/// `ANTIGRAVITY_LS_ADDRESS` (or a second product on another port) would mask
391/// the one message worth reading behind transport noise.
392///
393/// Note that this also decides *visibility*: transport errors are transient and
394/// fall back silently to cache, while the `401` surfaces in the widget. That is
395/// why a TLS listener's echo is ranked below everything else rather than merely
396/// tie-breaking — see [`is_tls_echo`]. Being an `Http`, it is not transient, so
397/// letting it stand as "the last failure" turns a product that simply is not
398/// serving RPC into a visible error about a protocol the user never chose.
399fn select_probe_error(errors: Vec<AppError>) -> AppError {
400    let mut actionable = None;
401    let mut missing_csrf = None;
402    let mut last = None;
403    let mut echo = None;
404    for e in errors {
405        if missing_csrf.is_none() && is_missing_csrf(&e) {
406            missing_csrf = Some(e);
407        } else if actionable.is_none() && is_actionable(&e) {
408            actionable = Some(e);
409        } else if is_tls_echo(&e) {
410            echo = Some(e);
411        } else {
412            last = Some(e);
413        }
414    }
415    actionable
416        .or(missing_csrf)
417        .or(last)
418        .or(echo)
419        .unwrap_or_else(|| {
420            AppError::Other("antigravity: no local server answered GetUserStatus".into())
421        })
422}
423
424/// `agy` currently serves no page containing its CSRF token, then returns this
425/// structured response from the status RPC. Matching the status, code, and
426/// the `missing CSRF token` wording avoids treating an unrelated local service
427/// or a genuinely signed-out Antigravity product as permission to use the
428/// cloud fallback. `agy` 1.3.1 prefixes that wording with `unauthenticated:`.
429fn is_missing_csrf(error: &AppError) -> bool {
430    let AppError::Http { status: 401, body } = error else {
431        return false;
432    };
433    let Ok(body) = serde_json::from_str::<serde_json::Value>(body) else {
434        return false;
435    };
436    matches!(
437        (body["code"].as_str(), body["message"].as_str()),
438        (Some(code), Some(message))
439            if code.eq_ignore_ascii_case("unauthenticated")
440                && is_missing_csrf_message(message)
441    )
442}
443
444/// Historical wording, or that wording as the last colon-separated field so
445/// `agy` 1.3.1's `unauthenticated: missing CSRF token` still matches.
446fn is_missing_csrf_message(message: &str) -> bool {
447    message
448        .rsplit(':')
449        .next()
450        .is_some_and(|part| part.trim().eq_ignore_ascii_case("missing CSRF token"))
451}
452
453/// An error the user can do something about, as opposed to "that product is not
454/// running". `post_rpc` only ever yields `Http`/`Transport`/`Other`, so the
455/// authentication statuses are the whole set, apart from `agy`'s precise
456/// missing-CSRF response.
457fn is_actionable(e: &AppError) -> bool {
458    matches!(e, AppError::Http { status, .. } if *status == 401 || *status == 403)
459        && !is_missing_csrf(e)
460}
461
462/// A TLS listener answering the plaintext JSON-RPC probe.
463///
464/// Each Antigravity product binds two ports: JSON-RPC in the clear on one and
465/// HTTPS on another. `probe_order` deliberately tries the RPC listener first
466/// and leaves the TLS one behind it, so reaching the TLS port at all means the
467/// RPC port already had its say. Go's `net/http.Server` answers cleartext on a
468/// TLS listener with this fixed `400`, which describes our own probe rather
469/// than anything wrong with the product — as a diagnosis it is noise that
470/// always arrives last and therefore always wins.
471///
472/// Matched on the body, not the status alone: a real `400` from the language
473/// server is a genuine complaint about the request and must keep outranking it.
474fn is_tls_echo(e: &AppError) -> bool {
475    matches!(
476        e,
477        AppError::Http { status: 400, body } if body.contains("HTTP request to an HTTPS server")
478    )
479}
480
481async fn fetch_live(
482    client: &reqwest::Client,
483    session: Result<Session>,
484) -> Result<AntigravitySnapshot> {
485    let session = session?;
486    let quota = post_rpc(client, &session.base, session.csrf.as_deref(), QUOTA_RPC).await?;
487    let mut snap = parse_quota_summary(&quota, session.plan)?;
488    snap.account = session.account;
489    Ok(snap)
490}
491
492// ---------------------------------------------------------------------------
493// Remote fallback — the saved Google session against the Cloud Code API
494// ---------------------------------------------------------------------------
495
496/// The saved Google session, or why there is none. `credential` is the test
497/// seam for the keyring blob.
498///
499/// No saved session leaves the user exactly where the local probe left them,
500/// plus the one thing they can now do about it.
501fn saved_session(
502    credential: SavedCredential<'_>,
503    reason: RemoteFallbackReason,
504) -> Result<StoredToken> {
505    let raw = match credential {
506        SavedCredential::Keyring => credential::read()?,
507        SavedCredential::Blob(blob) => Some(blob.to_string()),
508        SavedCredential::File(path) => std::fs::read_to_string(path).ok(),
509        SavedCredential::Absent => None,
510    };
511    let Some(raw) = raw else {
512        return Err(AppError::Credentials(format!(
513            "{} {NO_SAVED_SESSION}",
514            reason.message()
515        )));
516    };
517    credential::parse_keyring_blob(&raw)
518}
519
520/// Cache attribution for a remote snapshot. Deliberately not the local
521/// fingerprint's format: the two are computed from different inputs and a
522/// collision would let one source's cache stand in for the other's.
523fn remote_account(fingerprint: &str) -> String {
524    format!("acct:{fingerprint}")
525}
526
527fn session_expired() -> AppError {
528    AppError::Credentials(SESSION_EXPIRED.into())
529}
530
531/// The saved session can only be renewed with Antigravity's OAuth client,
532/// which this program does not ship (a secret-shaped literal in source trips
533/// every secret scanner); the config names the two keys that provide it.
534fn refresh_unconfigured() -> AppError {
535    AppError::Credentials(
536        "Antigravity's saved Google session expired and ai-usagebar has no OAuth client to \
537         refresh it; open Antigravity (or run `agy`) to sign in again, or set [antigravity] oauth_client_id \
538         and oauth_client_secret in config.toml"
539            .into(),
540    )
541}
542
543fn is_auth_rejection(e: &AppError) -> bool {
544    matches!(
545        e,
546        AppError::Http {
547            status: 401 | 403,
548            ..
549        }
550    )
551}
552
553/// An access token to present, and whether it was minted just now — a `401`
554/// against a token this fresh is the session itself being gone, not a stale
555/// token worth refreshing again.
556struct AccessToken {
557    value: String,
558    just_refreshed: bool,
559}
560
561/// Pick the freshest usable access token without going to the network: the
562/// one this program persisted after its last refresh, if it outlives the
563/// keyring's and is not about to expire; else the keyring's own while it
564/// lasts. Only when both are spent does this refresh.
565async fn resolve_access_token(
566    client: &reqwest::Client,
567    oauth: Option<&cloud::OauthClient>,
568    endpoints: &cloud::Endpoints,
569    oauth_path: &std::path::Path,
570    token: &StoredToken,
571    now: DateTime<Utc>,
572) -> Result<AccessToken> {
573    if let Some(persisted) = cloud::read_persisted(oauth_path, &token.fingerprint)
574        && token
575            .expires_at
576            .is_none_or(|keyring| persisted.expires_at > keyring)
577        && !cloud::needs_refresh(Some(persisted.expires_at), now)
578    {
579        return Ok(AccessToken {
580            value: persisted.access_token,
581            just_refreshed: false,
582        });
583    }
584    if !cloud::needs_refresh(token.expires_at, now) {
585        return Ok(AccessToken {
586            value: token.access_token.clone(),
587            just_refreshed: false,
588        });
589    }
590    refresh_and_persist(client, oauth, endpoints, oauth_path, token).await
591}
592
593/// Mint a new access token off the saved refresh token and remember it, so
594/// the next poll does not spend another round-trip on the same refresh. A
595/// session with no refresh token cannot be renewed here at all.
596async fn refresh_and_persist(
597    client: &reqwest::Client,
598    oauth: Option<&cloud::OauthClient>,
599    endpoints: &cloud::Endpoints,
600    oauth_path: &std::path::Path,
601    token: &StoredToken,
602) -> Result<AccessToken> {
603    let Some(refresh_token) = token.refresh_token.as_deref() else {
604        return Err(session_expired());
605    };
606    let Some(oauth) = oauth else {
607        return Err(refresh_unconfigured());
608    };
609    let refreshed = cloud::refresh(client, &endpoints.token, oauth, refresh_token).await?;
610    cloud::write_persisted(
611        oauth_path,
612        &cloud::PersistedOAuth {
613            fingerprint: token.fingerprint.clone(),
614            access_token: refreshed.access_token.clone(),
615            expires_at: refreshed.expires_at,
616        },
617    )?;
618    Ok(AccessToken {
619        value: refreshed.access_token,
620        just_refreshed: true,
621    })
622}
623
624/// What the remote path needs to renew an expired session through `agy`:
625/// the command to run, and where to read the credential again afterwards.
626struct Renewal<'a> {
627    agy: &'a super::agy::AgyCommand,
628    credential: SavedCredential<'a>,
629}
630
631impl Renewal<'_> {
632    /// Run `agy` to renew the saved session, then read it again. `None` when
633    /// `agy` did not run or left nothing readable, so the caller keeps the
634    /// error that sent it here.
635    async fn renewed(&self, cache: &Cache, now: DateTime<Utc>) -> Option<StoredToken> {
636        if !super::agy::renew_session(self.agy, cache, now).await {
637            return None;
638        }
639        let raw = match self.credential {
640            SavedCredential::Keyring => credential::read().ok().flatten()?,
641            SavedCredential::Blob(blob) => blob.to_string(),
642            SavedCredential::File(path) => std::fs::read_to_string(path).ok()?,
643            SavedCredential::Absent => return None,
644        };
645        credential::parse_keyring_blob(&raw).ok()
646    }
647}
648
649/// Quota through the Cloud Code API, as the signed-in Google account.
650///
651/// A rejected token gets one refresh and one retry, unless it was refreshed a
652/// moment ago — then the rejection is Google's verdict on the session, and
653/// the user has to sign in again. A plan-verdict 403 (`SUBSCRIPTION_REQUIRED`,
654/// #405) is not a rejection: it is passed through untouched, without spending
655/// a refresh or an `agy` run on a session that is fine. The plan name is
656/// best-effort: the summary is the figure, and a missing name must not cost it.
657async fn fetch_remote(
658    client: &reqwest::Client,
659    cache: &Cache,
660    oauth: Option<&cloud::OauthClient>,
661    endpoints: &cloud::Endpoints,
662    token: Result<StoredToken>,
663    renewal: Renewal<'_>,
664    now: DateTime<Utc>,
665) -> Result<AntigravitySnapshot> {
666    let mut token = token?;
667    let oauth_path = cloud::oauth_cache_path(cache);
668    let mut access =
669        match resolve_access_token(client, oauth, endpoints, &oauth_path, &token, now).await {
670            Ok(access) => access,
671            // Without an OAuth client this program cannot renew the session
672            // itself, but the `agy` CLI can.
673            Err(e) if oauth.is_none() => {
674                let Some(renewed) = renewal.renewed(cache, now).await else {
675                    return Err(e);
676                };
677                token = renewed;
678                resolve_access_token(client, oauth, endpoints, &oauth_path, &token, now).await?
679            }
680            Err(e) => return Err(e),
681        };
682
683    let quota = match cloud::fetch_quota(client, endpoints, &access.value).await {
684        // The plan is the problem, not the session: refreshing or running
685        // `agy` would only repeat the same verdict.
686        Err(e) if cloud::is_no_plan_quota(&e) => return Err(e),
687        Err(e) if is_auth_rejection(&e) && !access.just_refreshed => {
688            access = if oauth.is_none() {
689                let Some(renewed) = renewal.renewed(cache, now).await else {
690                    return Err(refresh_unconfigured());
691                };
692                token = renewed;
693                resolve_access_token(client, oauth, endpoints, &oauth_path, &token, now).await?
694            } else {
695                refresh_and_persist(client, oauth, endpoints, &oauth_path, &token).await?
696            };
697            cloud::fetch_quota(client, endpoints, &access.value)
698                .await
699                .map_err(|e| {
700                    if cloud::is_no_plan_quota(&e) {
701                        e
702                    } else if is_auth_rejection(&e) {
703                        session_expired()
704                    } else {
705                        e
706                    }
707                })?
708        }
709        Err(e) if is_auth_rejection(&e) => return Err(session_expired()),
710        other => other?,
711    };
712
713    let plan = cloud::fetch_plan(client, endpoints, &access.value)
714        .await
715        .unwrap_or_else(|| DEFAULT_PLAN.to_string());
716    let mut snap = parse_quota_summary(&quota, plan)?;
717    snap.account = remote_account(&token.fingerprint);
718    snap.source = AntigravitySource::Remote;
719    Ok(snap)
720}
721
722/// Identity of the signed-in account, fingerprinted rather than stored in
723/// clear — the cache only needs a change detector, not the address itself.
724/// An unidentifiable response yields a stable "unknown" bucket so two such
725/// responses still compare equal.
726fn account_key(user_status: &serde_json::Value) -> String {
727    let email = user_status["userStatus"]["email"]
728        .as_str()
729        .filter(|s| !s.is_empty());
730    match email {
731        Some(e) => {
732            use std::hash::{Hash, Hasher};
733            let mut h = std::collections::hash_map::DefaultHasher::new();
734            e.hash(&mut h);
735            format!("acct:{:016x}", h.finish())
736        }
737        None => "acct:unknown".to_string(),
738    }
739}
740
741/// The desktop products embed a CSRF token in the HTML served at `/`. The
742/// `agy` CLI serves no such page, so a missing token is recorded as `None` and
743/// its precise rejection is classified after the RPC probe.
744async fn fetch_csrf(client: &reqwest::Client, base: &str) -> Option<String> {
745    let resp = client.get(base).timeout(HTTP_TIMEOUT).send().await.ok()?;
746    // Bounded like every other response this crate reads: a local server is
747    // still an untrusted source of unbounded bytes.
748    let bytes = crate::vendor::read_body_capped(resp, crate::vendor::MAX_BODY_BYTES)
749        .await
750        .ok()?;
751    let html = String::from_utf8_lossy(&bytes);
752    html.split("csrfToken\":\"")
753        .nth(1)
754        .and_then(|s| s.split('"').next())
755        .filter(|t| !t.is_empty())
756        .map(|t| t.to_string())
757}
758
759async fn post_rpc(
760    client: &reqwest::Client,
761    base: &str,
762    csrf: Option<&str>,
763    rpc: &str,
764) -> Result<serde_json::Value> {
765    let mut req = client
766        .post(format!("{base}/{rpc}"))
767        .header("Content-Type", "application/json")
768        .body("{}")
769        .timeout(HTTP_TIMEOUT);
770    if let Some(token) = csrf {
771        req = req.header("x-codeium-csrf-token", token);
772    }
773    let resp = req.send().await?;
774
775    let status = resp.status();
776    // Cap error bodies too. A local endpoint is still untrusted, and reading a
777    // non-2xx response with `text()` would bypass the invariant enforced for
778    // successful JSON responses.
779    let bytes = crate::vendor::read_body_capped(resp, crate::vendor::MAX_BODY_BYTES).await?;
780    if !status.is_success() {
781        let body = String::from_utf8_lossy(&bytes).into_owned();
782        return Err(AppError::Http {
783            status: status.as_u16(),
784            body,
785        });
786    }
787
788    Ok(serde_json::from_slice(&bytes)?)
789}
790
791pub fn plan_from_status(v: &serde_json::Value) -> String {
792    v["userStatus"]["userTier"]["name"]
793        .as_str()
794        .or_else(|| v["userStatus"]["userTier"]["description"].as_str())
795        .or_else(|| v["userStatus"]["planStatus"]["planInfo"]["planName"].as_str())
796        .filter(|s| !s.is_empty())
797        .unwrap_or(DEFAULT_PLAN)
798        .to_string()
799}
800
801// ---------------------------------------------------------------------------
802// Quota parsing
803// ---------------------------------------------------------------------------
804
805/// Map a `RetrieveUserQuotaSummary` payload onto the four usage windows.
806///
807/// Buckets are keyed by `bucketId` (`gemini-5h`, `gemini-weekly`, `3p-5h`,
808/// `3p-weekly`), falling back to the group display name plus the `window`
809/// discriminator so a renamed bucket id still lands in the right slot.
810/// One bucket, named the way the response named it.
811fn describe_bucket(group_name: &str, id: &str, window: Option<&str>) -> String {
812    let id = if id.is_empty() { "<unnamed>" } else { id };
813    let group = if group_name.is_empty() {
814        String::new()
815    } else {
816        format!(" in {group_name:?}")
817    };
818    match window {
819        Some(window) if !window.is_empty() => format!("{id} (window {window}){group}"),
820        _ => format!("{id}{group}"),
821    }
822}
823
824/// A quota summary with nothing we can render. Naming the buckets that *were*
825/// present turns a report of this into something actionable — the alternative
826/// says only what we wanted, which tells neither the user nor a maintainer
827/// whether the plan has no such pool, the product renamed one, or a new
828/// cadence appeared.
829fn no_usable_bucket(seen: &[String]) -> AppError {
830    if seen.is_empty() {
831        return AppError::Other(
832            "antigravity: quota summary has no buckets at all — the running product may \
833             not have a quota for this account yet"
834                .into(),
835        );
836    }
837    AppError::Other(format!(
838        "antigravity: quota summary has no bucket in a window we recognise (5h or \
839         weekly, Gemini or Claude/GPT); it offered: {}",
840        seen.join(", ")
841    ))
842}
843
844pub fn parse_quota_summary(v: &serde_json::Value, plan: String) -> Result<AntigravitySnapshot> {
845    let groups = v["response"]["groups"]
846        .as_array()
847        .or_else(|| v["groups"].as_array())
848        .ok_or_else(|| AppError::Other("antigravity: quota summary has no groups".into()))?;
849
850    let mut gemini_5h = None;
851    let mut gemini_weekly = None;
852    let mut tp_5h = None;
853    let mut tp_weekly = None;
854    // What the response actually offered, so a summary we cannot use says so
855    // instead of only naming what it wanted. Bucket ids and group names are
856    // quota vocabulary, not account data.
857    let mut seen: Vec<String> = Vec::new();
858
859    for group in groups {
860        let group_name = group["displayName"].as_str().unwrap_or_default();
861        let Some(buckets) = group["buckets"].as_array() else {
862            continue;
863        };
864        for bucket in buckets {
865            let id = bucket["bucketId"].as_str().unwrap_or_default();
866            seen.push(describe_bucket(group_name, id, bucket["window"].as_str()));
867            let window = bucket["window"].as_str().unwrap_or_default();
868            let is_weekly = if id.ends_with("weekly") || window == "weekly" {
869                true
870            } else if id.ends_with("5h") || window == "5h" {
871                false
872            } else {
873                // A new cadence is not a 5-hour bucket by default. Ignore it
874                // so it cannot overwrite a known slot.
875                continue;
876            };
877            let is_gemini = if id.starts_with("gemini") {
878                true
879            } else if id.starts_with("3p") {
880                false
881            } else if group_name.contains("Gemini") {
882                true
883            } else if group_name.contains("Claude") || group_name.contains("GPT") {
884                false
885            } else {
886                // Likewise, an unrelated future group is not implicitly the
887                // third-party pool.
888                continue;
889            };
890
891            let (slot, slot_name) = match (is_gemini, is_weekly) {
892                (true, false) => (&mut gemini_5h, "Gemini 5h"),
893                (true, true) => (&mut gemini_weekly, "Gemini weekly"),
894                (false, false) => (&mut tp_5h, "Claude/GPT 5h"),
895                (false, true) => (&mut tp_weekly, "Claude/GPT weekly"),
896            };
897            let parsed = usage_window(bucket, is_weekly)?;
898            if slot.replace(parsed).is_some() {
899                return Err(AppError::Schema(format!(
900                    "antigravity: duplicate {slot_name} bucket"
901                )));
902            }
903        }
904    }
905
906    // Not every product offers every window: Antigravity CLI 1.1.22 returns
907    // weekly buckets only. One recognised window is enough to render — what
908    // must never happen is showing a figure for a window that did not arrive.
909    if gemini_5h.is_none() && gemini_weekly.is_none() && tp_5h.is_none() && tp_weekly.is_none() {
910        return Err(no_usable_bucket(&seen));
911    }
912
913    Ok(AntigravitySnapshot {
914        plan,
915        // Stamped by the caller, which is what knows the session's identity
916        // and which path it came through.
917        account: String::new(),
918        source: AntigravitySource::Local,
919        session: gemini_5h,
920        weekly: gemini_weekly,
921        third_party_session: tp_5h,
922        third_party_weekly: tp_weekly,
923    })
924}
925
926/// `remainingFraction` is required and must be finite: defaulting a missing or
927/// drifted value to 1.0 would report a reassuring "0% used" for a window whose
928/// real state is unknown, and cache it.
929fn usage_window(bucket: &serde_json::Value, is_weekly: bool) -> Result<UsageWindow> {
930    let remaining = bucket["remainingFraction"]
931        .as_f64()
932        .filter(|f| f.is_finite() && (0.0..=1.0).contains(f))
933        .ok_or_else(|| {
934            AppError::Schema(format!(
935                "antigravity: bucket {} has no valid remainingFraction in 0..=1",
936                bucket["bucketId"].as_str().unwrap_or("<unnamed>")
937            ))
938        })?;
939    Ok(UsageWindow {
940        utilization_pct: pct_used(remaining),
941        resets_at: parse_reset(&bucket["resetTime"], "quota resetTime")?,
942        window_duration: if is_weekly {
943            chrono::Duration::days(7)
944        } else {
945            chrono::Duration::hours(5)
946        },
947    })
948}
949
950/// The API reports how much is *left*; every other vendor here reports how much
951/// is *spent*.
952fn pct_used(remaining_fraction: f64) -> i32 {
953    let used = (1.0 - remaining_fraction) * 100.0;
954    used.round() as i32
955}
956
957fn parse_reset(value: &serde_json::Value, field: &str) -> Result<Option<DateTime<Utc>>> {
958    match value {
959        serde_json::Value::Null => Ok(None),
960        serde_json::Value::String(s) => DateTime::parse_from_rfc3339(s)
961            .map(|dt| Some(dt.with_timezone(&Utc)))
962            .map_err(|_| AppError::Schema(format!("antigravity: invalid {field}"))),
963        _ => Err(AppError::Schema(format!(
964            "antigravity: {field} must be a timestamp or null"
965        ))),
966    }
967}
968
969// ---------------------------------------------------------------------------
970// Language server discovery
971// ---------------------------------------------------------------------------
972
973/// Base URLs worth probing, most specific first. With `ide_language_server`
974/// false, listeners owned by the Antigravity IDE's language server are marked
975/// so [`open_session`] skips them (#405).
976fn candidate_bases(ide_language_server: bool) -> Vec<Candidate> {
977    let discovered = discover_ls_ports();
978    // Identifying the IDE's listeners costs an extra lookup per pid, so it
979    // runs only when they are actually unwanted.
980    let ide_pids = if ide_language_server {
981        std::collections::HashSet::new()
982    } else {
983        ide_ls_pids(discovered.iter().map(|&(pid, _)| pid).collect())
984    };
985    candidate_bases_with(
986        std::env::var("ANTIGRAVITY_LS_ADDRESS").ok().as_deref(),
987        discovered,
988        &ide_pids,
989    )
990}
991
992/// Test seam for [`candidate_bases`] — takes the address override, the
993/// discovered `(pid, port)` pairs, and the pids discovery identified as the
994/// IDE's language server instead of reading the environment and `/proc`.
995fn candidate_bases_with(
996    override_addr: Option<&str>,
997    discovered: Vec<(u32, u16)>,
998    ide_pids: &std::collections::HashSet<u32>,
999) -> Vec<Candidate> {
1000    let mut candidates = Vec::new();
1001    if let Some(base) = override_addr.and_then(normalize_base) {
1002        // An override that names a port discovery also saw belongs to that
1003        // process: its missing-CSRF answer must spare the process's sibling
1004        // listeners too. It is never marked as the IDE's — an explicit
1005        // address is the user's target and stays a candidate regardless.
1006        let pid = base_port(&base).and_then(|port| unique_owner(&discovered, port));
1007        candidates.push(Candidate {
1008            base,
1009            pid,
1010            ide: false,
1011        });
1012    }
1013
1014    // No hardcoded fallback port on purpose: the server always binds with
1015    // `--https_server_port 0`, so its port is drawn from the ephemeral range
1016    // and cannot be guessed. Probing a fixed one would just poke whatever
1017    // unrelated process happens to own it. Discovered ports follow any
1018    // explicit override as fallback, with duplicates omitted.
1019    for (pid, port) in discovered {
1020        let base = format!("http://127.0.0.1:{port}");
1021        if !candidates.iter().any(|c| c.base == base) {
1022            candidates.push(Candidate {
1023                base,
1024                pid: Some(pid),
1025                ide: ide_pids.contains(&pid),
1026            });
1027        }
1028    }
1029
1030    candidates
1031}
1032
1033/// Does this executable belong to the Antigravity IDE rather than the
1034/// Antigravity 2.0 desktop app or the `agy` CLI? The IDE's install tree is
1035/// the only one that names itself: `antigravity-ide` (Linux packages) or
1036/// `Antigravity IDE` (macOS and Windows).
1037#[cfg(any(test, target_os = "linux", target_os = "macos"))]
1038fn is_ide_install(exe: &str) -> bool {
1039    let exe = exe.to_lowercase().replace('\\', "/");
1040    exe.contains("antigravity-ide") || exe.contains("antigravity ide")
1041}
1042
1043/// The pids, among `pids`, that run the IDE's language server.
1044#[cfg(target_os = "linux")]
1045fn ide_ls_pids(pids: std::collections::HashSet<u32>) -> std::collections::HashSet<u32> {
1046    pids.into_iter()
1047        .filter(|pid| {
1048            std::fs::read_link(format!("/proc/{pid}/exe"))
1049                .map(|path| path.to_string_lossy().into_owned())
1050                .is_ok_and(|exe| is_ide_install(&exe))
1051        })
1052        .collect()
1053}
1054
1055/// `lsof` reports only command names, so the IDE is identified by asking `ps`
1056/// for the owning executables — on macOS `comm` is the full path.
1057#[cfg(target_os = "macos")]
1058fn ide_ls_pids(pids: std::collections::HashSet<u32>) -> std::collections::HashSet<u32> {
1059    if pids.is_empty() {
1060        return std::collections::HashSet::new();
1061    }
1062    let list = pids
1063        .iter()
1064        .map(u32::to_string)
1065        .collect::<Vec<_>>()
1066        .join(",");
1067    let Ok(output) = std::process::Command::new("ps")
1068        .args(["-o", "pid=,comm=", "-p", &list])
1069        .output()
1070    else {
1071        return std::collections::HashSet::new();
1072    };
1073    parse_ps_comm(&String::from_utf8_lossy(&output.stdout))
1074        .into_iter()
1075        .filter(|(pid, exe)| pids.contains(pid) && is_ide_install(exe))
1076        .map(|(pid, _)| pid)
1077        .collect()
1078}
1079
1080/// `ps -o pid=,comm=` rows, `<pid> <executable path>`; the path can hold
1081/// spaces (`Antigravity IDE.app`), so only the pid is split off.
1082#[cfg(any(test, target_os = "macos"))]
1083fn parse_ps_comm(output: &str) -> Vec<(u32, String)> {
1084    output
1085        .lines()
1086        .filter_map(|line| {
1087            let (pid, exe) = line.trim().split_once(char::is_whitespace)?;
1088            Some((pid.parse().ok()?, exe.trim().to_string()))
1089        })
1090        .collect()
1091}
1092
1093/// ToolHelp reports only the executable's file name, never its install path,
1094/// so the IDE's language server cannot be told apart from the 2.0 app's here:
1095/// `ide_language_server = false` currently has no effect on Windows.
1096#[cfg(target_os = "windows")]
1097fn ide_ls_pids(_: std::collections::HashSet<u32>) -> std::collections::HashSet<u32> {
1098    std::collections::HashSet::new()
1099}
1100
1101#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
1102fn ide_ls_pids(_: std::collections::HashSet<u32>) -> std::collections::HashSet<u32> {
1103    std::collections::HashSet::new()
1104}
1105
1106/// The port an already-normalized base targets, when it names one.
1107fn base_port(base: &str) -> Option<u16> {
1108    let (_, authority) = base.split_once("://")?;
1109    authority.rsplit_once(':')?.1.parse().ok()
1110}
1111
1112/// The one process discovery attributes to `port`; `None` when it is not
1113/// listed or more than one process claims it.
1114fn unique_owner(discovered: &[(u32, u16)], port: u16) -> Option<u32> {
1115    let mut owners = discovered
1116        .iter()
1117        .filter(|(_, p)| *p == port)
1118        .map(|(pid, _)| *pid);
1119    let first = owners.next()?;
1120    owners.all(|pid| pid == first).then_some(first)
1121}
1122
1123/// Turn a configured address into a base URL: trim surrounding whitespace,
1124/// supply the default scheme when it is missing, and drop trailing slashes so
1125/// the RPC paths built on top do not come out with a double slash.
1126///
1127/// Returns `None` when nothing but a scheme survives. `ANTIGRAVITY_LS_ADDRESS`
1128/// is user input, and a value like `"/"` carries no authority to connect to;
1129/// admitting it as a candidate would spend a probe to learn what is already
1130/// knowable here.
1131fn normalize_base(addr: &str) -> Option<String> {
1132    let trimmed = addr.trim();
1133    let (scheme, authority) = match trimmed.split_once("://") {
1134        Some((scheme @ ("http" | "https"), rest)) => (scheme, rest),
1135        _ => ("http", trimmed),
1136    };
1137    let authority = authority.trim_end_matches('/');
1138    (!authority.is_empty()).then(|| format!("{scheme}://{authority}"))
1139}
1140
1141/// Does this process look like one of the three Antigravity products?
1142///
1143/// Antigravity 2.0 and the IDE spawn a separate `language_server` child, while
1144/// the `agy` CLI embeds the same CSRF/RPC surface in its own process — so
1145/// matching on the server binary alone would miss a CLI-only install. `comm` is
1146/// truncated to 15 bytes by the kernel, which `language_server` exactly fills.
1147fn is_antigravity_process(comm: &str, exe: Option<&str>) -> bool {
1148    let comm = comm.trim().to_lowercase();
1149    let comm = comm.strip_suffix(".exe").unwrap_or(&comm);
1150    if comm.contains("language_server") || comm == "agy" || comm == "antigravity" {
1151        return true;
1152    }
1153    exe.is_some_and(|p| {
1154        let p = p.to_lowercase().replace('\\', "/");
1155        let p = p.strip_suffix(".exe").unwrap_or(&p);
1156        p.contains("antigravity") || p.ends_with("/agy")
1157    })
1158}
1159
1160/// Flatten per-process listener ports into the order they should be probed,
1161/// keeping each port's owner so `open_session` can spare the rest of a
1162/// process's listeners once its RPC answer proves the product unusable.
1163///
1164/// Each Antigravity product binds an HTTPS/TLS listener and the unencrypted
1165/// HTTP JSON-RPC listener that serves `GetUserStatus` and
1166/// `RetrieveUserQuotaSummary`. Both are ephemeral, but they are bound in that
1167/// order, so in practice the RPC listener draws the higher number and probing
1168/// high-to-low reaches it first — which keeps Go's `net/http.Server` from
1169/// logging a TLS handshake error for every unencrypted request that lands on
1170/// its HTTPS listener.
1171///
1172/// Sorting every discovered port as one descending set only gets that right
1173/// for a single process, because the high/low tendency holds *within* a
1174/// product and says nothing across two of them: with Antigravity 2.0 and an
1175/// `agy` session both up, one product's TLS port can sort above the other's
1176/// RPC port. Ports are therefore grouped per pid, sorted high-to-low inside
1177/// each group, and taken rank by rank — every product's highest port, then
1178/// every product's second-highest, and so on. Where each product shows both
1179/// listeners that puts every RPC one ahead of every TLS one.
1180///
1181/// This stays a preference, not a guarantee, and the two-listener shape is the
1182/// assumption it rests on: a product caught mid-startup, with only its TLS port
1183/// bound so far, sits alone at rank 0 and is probed first. A mis-ranked one
1184/// costs an extra round-trip, nothing more — unless its answer proves it
1185/// unusable, in which case its remaining ports are skipped rather than probed.
1186///
1187/// Order among products is arbitrary — all of them report the same
1188/// account-wide quota, so whichever answers first is authoritative — and pid
1189/// order is used only to keep the result reproducible, since `/proc`, `lsof`
1190/// and the Windows TCP table each enumerate in their own order.
1191#[cfg(any(test, target_os = "linux", target_os = "macos", target_os = "windows"))]
1192fn probe_order(per_pid: std::collections::BTreeMap<u32, Vec<u16>>) -> Vec<(u32, u16)> {
1193    let groups: Vec<(u32, Vec<u16>)> = per_pid
1194        .into_iter()
1195        .map(|(pid, mut group)| {
1196            group.sort_unstable_by(|a, b| b.cmp(a));
1197            // Within a product a port is one listener however many rows named
1198            // it — a dual-stack bind reports the same port from both
1199            // `/proc/net/tcp` and `tcp6`. Collapsing them here keeps a rank
1200            // meaning "the Nth listener" rather than "the Nth row".
1201            group.dedup();
1202            (pid, group)
1203        })
1204        .collect();
1205
1206    let mut ports: Vec<(u32, u16)> = Vec::new();
1207    for rank in 0..groups.iter().map(|(_, g)| g.len()).max().unwrap_or(0) {
1208        for (pid, port) in groups
1209            .iter()
1210            .filter_map(|(pid, group)| group.get(rank).map(|port| (*pid, *port)))
1211        {
1212            // Two processes cannot share one loopback port; dedup by port so a
1213            // pathological double-claim stays a single probe.
1214            if !ports.iter().any(|(_, p)| *p == port) {
1215                ports.push((pid, port));
1216            }
1217        }
1218    }
1219    ports
1220}
1221
1222/// Loopback `(pid, port)` pairs listened on by any running Antigravity
1223/// product.
1224///
1225/// Reads `/proc` directly rather than shelling out to `ss`/`lsof`: find the
1226/// candidate pids, collect their socket inodes, then keep the listening TCP
1227/// entries owning one of those inodes. All three products report the *same*
1228/// shared quota, so whichever answers first is authoritative.
1229#[cfg(target_os = "linux")]
1230pub(crate) fn discover_ls_ports() -> Vec<(u32, u16)> {
1231    use std::collections::{BTreeMap, HashMap};
1232
1233    // Socket inode -> owning pid, so the ports found in `/proc/net` can be
1234    // grouped back per process for `probe_order`.
1235    let mut owners: HashMap<u64, u32> = HashMap::new();
1236    let Ok(entries) = std::fs::read_dir("/proc") else {
1237        return Vec::new();
1238    };
1239    for entry in entries.flatten() {
1240        let pid_dir = entry.path();
1241        let Some(pid) = pid_dir
1242            .file_name()
1243            .and_then(|name| name.to_str())
1244            .and_then(|name| name.parse::<u32>().ok())
1245        else {
1246            continue;
1247        };
1248        let Ok(comm) = std::fs::read_to_string(pid_dir.join("comm")) else {
1249            continue;
1250        };
1251        let exe = std::fs::read_link(pid_dir.join("exe")).ok();
1252        if !is_antigravity_process(&comm, exe.as_deref().and_then(|p| p.to_str())) {
1253            continue;
1254        }
1255        let Ok(fds) = std::fs::read_dir(pid_dir.join("fd")) else {
1256            continue;
1257        };
1258        for fd in fds.flatten() {
1259            let Ok(target) = std::fs::read_link(fd.path()) else {
1260                continue;
1261            };
1262            if let Some(ino) = target
1263                .to_str()
1264                .and_then(|s| s.strip_prefix("socket:["))
1265                .and_then(|s| s.strip_suffix(']'))
1266                .and_then(|s| s.parse::<u64>().ok())
1267            {
1268                owners.insert(ino, pid);
1269            }
1270        }
1271    }
1272
1273    if owners.is_empty() {
1274        return Vec::new();
1275    }
1276
1277    let mut per_pid: BTreeMap<u32, Vec<u16>> = BTreeMap::new();
1278    for table in ["/proc/net/tcp", "/proc/net/tcp6"] {
1279        let Ok(contents) = std::fs::read_to_string(table) else {
1280            continue;
1281        };
1282        for line in contents.lines().skip(1) {
1283            if let Some((port, ino)) = parse_proc_net_line(line)
1284                && let Some(&pid) = owners.get(&ino)
1285            {
1286                per_pid.entry(pid).or_default().push(port);
1287            }
1288        }
1289    }
1290    probe_order(per_pid)
1291}
1292
1293/// macOS has no `/proc`, so fall back to `lsof` (present on every macOS
1294/// install by default, unlike Linux where shelling out was deliberately
1295/// avoided — see the doc comment above). `-F pcn` asks for machine-parsable
1296/// output: one `p<pid>` line per process, one `c<command>` line for its name,
1297/// then an `n<address>` line per matching socket already filtered down to
1298/// listening TCP sockets by `-iTCP -sTCP:LISTEN`.
1299#[cfg(target_os = "macos")]
1300pub(crate) fn discover_ls_ports() -> Vec<(u32, u16)> {
1301    let Ok(output) = std::process::Command::new("lsof")
1302        .args(["-nP", "-iTCP", "-sTCP:LISTEN", "-F", "pcn"])
1303        .output()
1304    else {
1305        return Vec::new();
1306    };
1307    // A non-zero exit still emits usable output for the fds it *could* read,
1308    // so parse regardless of status — an empty/garbled stdout just parses to
1309    // an empty port list.
1310    parse_lsof_pcn(&String::from_utf8_lossy(&output.stdout))
1311}
1312
1313/// Pure parser for `lsof -F pcn` output, kept separate from process spawning
1314/// so the parsing logic is unit-testable without shelling out. Compiled under
1315/// `test` on every platform, like [`matching_windows_ports`], so its tests are
1316/// not macOS-only.
1317#[cfg(any(test, target_os = "macos"))]
1318fn parse_lsof_pcn(output: &str) -> Vec<(u32, u16)> {
1319    let mut per_pid: std::collections::BTreeMap<u32, Vec<u16>> = std::collections::BTreeMap::new();
1320    // The pid arrives on the `p` line and the command name on the `c` line
1321    // right after it, so hold the pid until the name confirms it is ours.
1322    let mut pid = None;
1323    let mut owner = None;
1324    for line in output.lines() {
1325        let Some(rest) = line.get(1..) else { continue };
1326        match line.as_bytes().first() {
1327            Some(b'p') => {
1328                pid = rest.parse::<u32>().ok();
1329                owner = None;
1330            }
1331            Some(b'c') => owner = pid.filter(|_| is_antigravity_process(rest, None)),
1332            Some(b'n') => {
1333                if let Some(pid) = owner
1334                    && let Some(port) = rest.rsplit(':').next().and_then(|p| p.parse::<u16>().ok())
1335                {
1336                    per_pid.entry(pid).or_default().push(port);
1337                }
1338            }
1339            _ => {}
1340        }
1341    }
1342    probe_order(per_pid)
1343}
1344
1345#[cfg(any(test, target_os = "windows"))]
1346#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1347struct WindowsTcpRow {
1348    local_addr: [u8; 4],
1349    local_port: u32,
1350    pid: u32,
1351}
1352
1353#[cfg(any(test, target_os = "windows"))]
1354fn decode_windows_process_name(raw: &[u16]) -> String {
1355    let end = raw.iter().position(|&unit| unit == 0).unwrap_or(raw.len());
1356    String::from_utf16_lossy(&raw[..end])
1357}
1358
1359#[cfg(any(test, target_os = "windows"))]
1360fn matching_windows_process_ids(processes: &[(u32, String)]) -> std::collections::HashSet<u32> {
1361    processes
1362        .iter()
1363        .filter(|(_, name)| is_antigravity_process(name, None))
1364        .map(|(pid, _)| *pid)
1365        .collect()
1366}
1367
1368/// Loopback ports owned by the matching processes, grouped per pid and handed
1369/// to [`probe_order`], which explains why the grouping matters.
1370#[cfg(any(test, target_os = "windows"))]
1371fn matching_windows_ports(
1372    pids: &std::collections::HashSet<u32>,
1373    rows: &[WindowsTcpRow],
1374) -> Vec<(u32, u16)> {
1375    let mut per_pid: std::collections::BTreeMap<u32, Vec<u16>> = std::collections::BTreeMap::new();
1376    for row in rows {
1377        if !pids.contains(&row.pid) || row.local_addr != [127, 0, 0, 1] {
1378            continue;
1379        }
1380        let port = u16::from_be((row.local_port & u32::from(u16::MAX)) as u16);
1381        if port != 0 {
1382            per_pid.entry(row.pid).or_default().push(port);
1383        }
1384    }
1385    probe_order(per_pid)
1386}
1387
1388#[cfg(any(test, target_os = "windows"))]
1389fn checked_windows_row_count(
1390    buffer_len: usize,
1391    rows_offset: usize,
1392    row_size: usize,
1393    declared: usize,
1394) -> Option<usize> {
1395    let rows_len = row_size.checked_mul(declared)?;
1396    let end = rows_offset.checked_add(rows_len)?;
1397    (row_size != 0 && end <= buffer_len).then_some(declared)
1398}
1399
1400#[cfg(target_os = "windows")]
1401struct WindowsHandle(windows_sys::Win32::Foundation::HANDLE);
1402
1403#[cfg(target_os = "windows")]
1404impl Drop for WindowsHandle {
1405    fn drop(&mut self) {
1406        unsafe {
1407            windows_sys::Win32::Foundation::CloseHandle(self.0);
1408        }
1409    }
1410}
1411
1412#[cfg(target_os = "windows")]
1413fn windows_processes() -> Vec<(u32, String)> {
1414    use std::mem::size_of;
1415    use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE;
1416    use windows_sys::Win32::System::Diagnostics::ToolHelp::{
1417        CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW,
1418        TH32CS_SNAPPROCESS,
1419    };
1420
1421    let handle = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
1422    if handle == INVALID_HANDLE_VALUE {
1423        return Vec::new();
1424    }
1425    let snapshot = WindowsHandle(handle);
1426    let mut entry = PROCESSENTRY32W::default();
1427    let Ok(entry_size) = u32::try_from(size_of::<PROCESSENTRY32W>()) else {
1428        return Vec::new();
1429    };
1430    entry.dwSize = entry_size;
1431    if unsafe { Process32FirstW(snapshot.0, &mut entry) } == 0 {
1432        return Vec::new();
1433    }
1434
1435    let mut processes = Vec::new();
1436    loop {
1437        processes.push((
1438            entry.th32ProcessID,
1439            decode_windows_process_name(&entry.szExeFile),
1440        ));
1441        if unsafe { Process32NextW(snapshot.0, &mut entry) } == 0 {
1442            break;
1443        }
1444    }
1445    processes
1446}
1447
1448#[cfg(target_os = "windows")]
1449fn parse_windows_tcp_rows(buffer: &[u32], used_bytes: usize) -> Vec<WindowsTcpRow> {
1450    use std::mem::{offset_of, size_of, size_of_val};
1451    use windows_sys::Win32::NetworkManagement::IpHelper::{
1452        MIB_TCPROW_OWNER_PID, MIB_TCPTABLE_OWNER_PID,
1453    };
1454
1455    let available = used_bytes.min(size_of_val(buffer));
1456    let rows_offset = offset_of!(MIB_TCPTABLE_OWNER_PID, table);
1457    if available < size_of::<u32>() || available < rows_offset {
1458        return Vec::new();
1459    }
1460    let base = buffer.as_ptr().cast::<u8>();
1461    let declared = unsafe { base.cast::<u32>().read_unaligned() } as usize;
1462    if checked_windows_row_count(
1463        available,
1464        rows_offset,
1465        size_of::<MIB_TCPROW_OWNER_PID>(),
1466        declared,
1467    )
1468    .is_none()
1469    {
1470        return Vec::new();
1471    }
1472
1473    let rows = unsafe { base.add(rows_offset).cast::<MIB_TCPROW_OWNER_PID>() };
1474    (0..declared)
1475        .map(|index| unsafe { rows.add(index).read_unaligned() })
1476        .map(|row| WindowsTcpRow {
1477            local_addr: row.dwLocalAddr.to_ne_bytes(),
1478            local_port: row.dwLocalPort,
1479            pid: row.dwOwningPid,
1480        })
1481        .collect()
1482}
1483
1484#[cfg(target_os = "windows")]
1485fn windows_tcp_rows() -> Vec<WindowsTcpRow> {
1486    use std::mem::size_of;
1487    use std::ptr::null_mut;
1488    use windows_sys::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER;
1489    use windows_sys::Win32::NetworkManagement::IpHelper::{
1490        GetExtendedTcpTable, TCP_TABLE_OWNER_PID_LISTENER,
1491    };
1492    use windows_sys::Win32::Networking::WinSock::AF_INET;
1493
1494    let mut size = 0u32;
1495    let status = unsafe {
1496        GetExtendedTcpTable(
1497            null_mut(),
1498            &mut size,
1499            0,
1500            u32::from(AF_INET),
1501            TCP_TABLE_OWNER_PID_LISTENER,
1502            0,
1503        )
1504    };
1505    if status != ERROR_INSUFFICIENT_BUFFER {
1506        return Vec::new();
1507    }
1508
1509    for _ in 0..3 {
1510        let Some(words) = (size as usize)
1511            .checked_add(size_of::<u32>() - 1)
1512            .map(|bytes| bytes / size_of::<u32>())
1513        else {
1514            return Vec::new();
1515        };
1516        if words == 0 {
1517            return Vec::new();
1518        }
1519        let mut buffer = Vec::<u32>::new();
1520        if buffer.try_reserve_exact(words).is_err() {
1521            return Vec::new();
1522        }
1523        buffer.resize(words, 0);
1524        let mut used = size;
1525        let status = unsafe {
1526            GetExtendedTcpTable(
1527                buffer.as_mut_ptr().cast(),
1528                &mut used,
1529                0,
1530                u32::from(AF_INET),
1531                TCP_TABLE_OWNER_PID_LISTENER,
1532                0,
1533            )
1534        };
1535        if status == ERROR_INSUFFICIENT_BUFFER {
1536            size = used;
1537            continue;
1538        }
1539        if status != 0 {
1540            return Vec::new();
1541        }
1542        return parse_windows_tcp_rows(&buffer, used as usize);
1543    }
1544    Vec::new()
1545}
1546
1547#[cfg(target_os = "windows")]
1548pub(crate) fn discover_ls_ports() -> Vec<(u32, u16)> {
1549    let pids = matching_windows_process_ids(&windows_processes());
1550    if pids.is_empty() {
1551        return Vec::new();
1552    }
1553    matching_windows_ports(&pids, &windows_tcp_rows())
1554}
1555
1556#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
1557pub(crate) fn discover_ls_ports() -> Vec<(u32, u16)> {
1558    Vec::new()
1559}
1560
1561/// Pull `(local_port, inode)` out of a listening row of `/proc/net/tcp`.
1562/// Columns: `sl local_address rem_address st ... uid timeout inode`.
1563#[cfg(target_os = "linux")]
1564fn parse_proc_net_line(line: &str) -> Option<(u16, u64)> {
1565    let cols: Vec<&str> = line.split_whitespace().collect();
1566    if cols.len() < 10 {
1567        return None;
1568    }
1569    // 0x0A == TCP_LISTEN. Anything else is an established/closing socket.
1570    if cols[3] != "0A" {
1571        return None;
1572    }
1573    let port = u16::from_str_radix(cols[1].split(':').nth(1)?, 16).ok()?;
1574    let inode = cols[9].parse::<u64>().ok()?;
1575    Some((port, inode))
1576}
1577
1578// ---------------------------------------------------------------------------
1579// Cache
1580// ---------------------------------------------------------------------------
1581
1582fn fallback_silent(cache: &Cache, now: DateTime<Utc>, original: AppError) -> Result<FetchOutcome> {
1583    crate::outcome::fallback(cache, None, original, |bytes| {
1584        parse_cache_at(bytes, None, now)
1585    })
1586}
1587
1588/// Serve the stale cache when there is one. With no cache to fall back on,
1589/// surface `reason` — the actual diagnosis, e.g. "no local language server
1590/// found" — rather than a generic cache-miss that tells the user nothing about
1591/// what to do. This is the first-run path: no cache yet and Antigravity closed.
1592fn fallback_with_error(
1593    cache: &Cache,
1594    last_error: Option<(u16, String)>,
1595    reason: AppError,
1596    now: DateTime<Utc>,
1597) -> Result<FetchOutcome> {
1598    crate::outcome::fallback(cache, last_error, reason, |bytes| {
1599        parse_cache_at(bytes, None, now)
1600    })
1601}
1602
1603fn reuse_cache(
1604    bytes: Vec<u8>,
1605    cache: &Cache,
1606    stale: bool,
1607    account: Option<&str>,
1608    now: DateTime<Utc>,
1609) -> Result<FetchOutcome> {
1610    let snap = parse_cache_at(&bytes, account, now)?;
1611    Ok(crate::outcome::Outcome::cached(snap, cache, stale))
1612}
1613
1614/// `account` is the fingerprint of the currently signed-in account, or `None`
1615/// when no local server answered. A payload belonging to a different account is
1616/// rejected so a Google-account switch cannot show the previous account's
1617/// quota. With `None` we cannot verify — but nothing is consuming quota while
1618/// Antigravity is down, so the last known figures are the best available truth.
1619pub fn parse_cache(bytes: &[u8], account: Option<&str>) -> Result<AntigravitySnapshot> {
1620    parse_cache_at(bytes, account, Utc::now())
1621}
1622
1623/// A cached window whose reset has already passed describes a period that has
1624/// since rolled over: the real figure is back near zero while the payload still
1625/// carries the old one, and its countdown is pinned at "now". Serving that is
1626/// presenting a known-obsolete number as current, so the payload is refused and
1627/// the caller reports that Antigravity needs to be running.
1628///
1629/// This matters more here than for other vendors: "nothing running" is the
1630/// normal state for Antigravity, and `MAX_STALE` is seven days — far past the
1631/// five hours after which the session window is guaranteed wrong.
1632fn expired_window(snap: &AntigravitySnapshot, now: DateTime<Utc>) -> Option<&'static str> {
1633    [
1634        ("Gemini 5h", snap.session.as_ref()),
1635        ("Gemini weekly", snap.weekly.as_ref()),
1636        ("Claude & GPT OSS 5h", snap.third_party_session.as_ref()),
1637        ("Claude & GPT OSS weekly", snap.third_party_weekly.as_ref()),
1638    ]
1639    .into_iter()
1640    .find(|(_, w)| w.and_then(|w| w.resets_at).is_some_and(|r| r <= now))
1641    .map(|(name, _)| name)
1642}
1643
1644pub fn parse_cache_at(
1645    bytes: &[u8],
1646    account: Option<&str>,
1647    now: DateTime<Utc>,
1648) -> Result<AntigravitySnapshot> {
1649    let v: serde_json::Value = serde_json::from_slice(bytes)?;
1650
1651    let cached_account = v.get("account").and_then(serde_json::Value::as_str);
1652    if let Some(expected) = account
1653        && cached_account != Some(expected)
1654    {
1655        return Err(AppError::Schema(
1656            "antigravity cache belongs to a different account; refetching".into(),
1657        ));
1658    }
1659
1660    // The Gemini windows are required. Defaulting a missing or truncated field
1661    // to 0 would render a confident "0% used" and keep serving it for the rest
1662    // of the TTL; returning an error makes the caller fall through to a live
1663    // fetch instead of displaying a fabricated snapshot.
1664    // An absent window and a truncated payload look alike unless we insist on
1665    // the difference: `snap_to_json` always writes every key, so an explicit
1666    // `null` means "this product reported no such window" while a *missing*
1667    // key means the document is not one we wrote whole. Only the first is a
1668    // snapshot; the second must refetch rather than render a window short.
1669    let cached_pct = |pct_key: &'static str| -> Result<Option<i32>> {
1670        match v.get(pct_key) {
1671            None => Err(AppError::Schema(format!(
1672                "antigravity: cached payload is missing {pct_key}"
1673            ))),
1674            Some(serde_json::Value::Null) => Ok(None),
1675            Some(value) => value
1676                .as_i64()
1677                .filter(|pct| (0..=100).contains(pct))
1678                .map(|pct| Some(pct as i32))
1679                .ok_or_else(|| {
1680                    AppError::Schema(format!(
1681                        "antigravity: cached {pct_key} must be an integer in 0..=100"
1682                    ))
1683                }),
1684        }
1685    };
1686
1687    let optional = |pct_key: &'static str, reset_key: &str, weekly: bool| {
1688        let Some(pct) = cached_pct(pct_key)? else {
1689            return Ok(None);
1690        };
1691        Ok::<_, AppError>(Some(UsageWindow {
1692            utilization_pct: pct,
1693            resets_at: parse_reset(&v[reset_key], reset_key)?,
1694            window_duration: if weekly {
1695                chrono::Duration::days(7)
1696            } else {
1697                chrono::Duration::hours(5)
1698            },
1699        }))
1700    };
1701
1702    let snap = AntigravitySnapshot {
1703        plan: v["plan"].as_str().unwrap_or(DEFAULT_PLAN).to_string(),
1704        account: cached_account.unwrap_or_default().to_string(),
1705        // Payloads written before the remote path existed were all local.
1706        source: v["source"]
1707            .as_str()
1708            .and_then(AntigravitySource::parse)
1709            .unwrap_or_default(),
1710        session: optional("session_pct", "session_reset", false)?,
1711        weekly: optional("weekly_pct", "weekly_reset", true)?,
1712        third_party_session: optional("tp_session_pct", "tp_session_reset", false)?,
1713        third_party_weekly: optional("tp_weekly_pct", "tp_weekly_reset", true)?,
1714    };
1715
1716    // A cache with no window left is not a snapshot; refetch rather than draw
1717    // an empty panel from it. Mirrors the live parse.
1718    if snap.session.is_none()
1719        && snap.weekly.is_none()
1720        && snap.third_party_session.is_none()
1721        && snap.third_party_weekly.is_none()
1722    {
1723        return Err(AppError::Schema(
1724            "antigravity cache holds no usable window; refetching".into(),
1725        ));
1726    }
1727
1728    if let Some(window) = expired_window(&snap, now) {
1729        return Err(AppError::Schema(format!(
1730            "antigravity cache is past its {window} reset; refetching"
1731        )));
1732    }
1733    Ok(snap)
1734}
1735
1736pub fn snap_to_json(snap: &AntigravitySnapshot) -> serde_json::Value {
1737    serde_json::json!({
1738        "plan": snap.plan,
1739        "account": snap.account,
1740        "source": snap.source.as_str(),
1741        "session_pct": snap.session.as_ref().map(|w| w.utilization_pct),
1742        "session_reset": snap.session.as_ref().and_then(|w| w.resets_at.map(|dt| dt.to_rfc3339())),
1743        "weekly_pct": snap.weekly.as_ref().map(|w| w.utilization_pct),
1744        "weekly_reset": snap.weekly.as_ref().and_then(|w| w.resets_at.map(|dt| dt.to_rfc3339())),
1745        "tp_session_pct": snap.third_party_session.as_ref().map(|w| w.utilization_pct),
1746        "tp_session_reset": snap.third_party_session.as_ref().and_then(|w| w.resets_at.map(|dt| dt.to_rfc3339())),
1747        "tp_weekly_pct": snap.third_party_weekly.as_ref().map(|w| w.utilization_pct),
1748        "tp_weekly_reset": snap.third_party_weekly.as_ref().and_then(|w| w.resets_at.map(|dt| dt.to_rfc3339())),
1749    })
1750}
1751
1752#[cfg(test)]
1753mod tests {
1754    use super::*;
1755
1756    /// Captured from a real `RetrieveUserQuotaSummary` response on 2026-07-22
1757    /// (Antigravity 2.0 build 2.3.1, `agy` 1.1.5), then trimmed. Percentages
1758    /// were edited to distinct non-zero values so a slot mix-up cannot pass.
1759    const QUOTA_JSON: &str = r#"{
1760      "response": {
1761        "groups": [
1762          {
1763            "displayName": "Gemini Models",
1764            "buckets": [
1765              {"bucketId": "gemini-weekly", "displayName": "Weekly Limit",
1766               "window": "weekly", "remainingFraction": 0.9191212,
1767               "resetTime": "2026-07-28T17:39:58Z"},
1768              {"bucketId": "gemini-5h", "displayName": "Five Hour Limit",
1769               "window": "5h", "remainingFraction": 0.5672253,
1770               "resetTime": "2026-07-22T17:47:00Z"}
1771            ]
1772          },
1773          {
1774            "displayName": "Claude and GPT models",
1775            "buckets": [
1776              {"bucketId": "3p-weekly", "window": "weekly",
1777               "remainingFraction": 1, "resetTime": "2026-07-29T12:47:00Z"},
1778              {"bucketId": "3p-5h", "window": "5h",
1779               "remainingFraction": 0.25, "resetTime": "2026-07-22T17:47:00Z"}
1780            ]
1781          }
1782        ]
1783      }
1784    }"#;
1785
1786    /// Fixed instant, earlier than every reset in the fixture. Using the wall
1787    /// clock here would make the suite start failing once those resets pass.
1788    fn now() -> DateTime<Utc> {
1789        DateTime::parse_from_rfc3339("2026-07-22T12:00:00Z")
1790            .unwrap()
1791            .with_timezone(&Utc)
1792    }
1793
1794    fn parsed() -> AntigravitySnapshot {
1795        let v: serde_json::Value = serde_json::from_str(QUOTA_JSON).unwrap();
1796        parse_quota_summary(&v, "Google AI Pro".into()).unwrap()
1797    }
1798
1799    #[test]
1800    fn quota_summary_maps_four_distinct_windows() {
1801        let snap = parsed();
1802        assert_eq!(snap.plan, "Google AI Pro");
1803        // remainingFraction is inverted into "used".
1804        assert_eq!(snap.session.as_ref().unwrap().utilization_pct, 43);
1805        assert_eq!(snap.weekly.as_ref().unwrap().utilization_pct, 8);
1806        assert_eq!(
1807            snap.third_party_session.as_ref().unwrap().utilization_pct,
1808            75
1809        );
1810        assert_eq!(snap.third_party_weekly.as_ref().unwrap().utilization_pct, 0);
1811    }
1812
1813    #[test]
1814    fn each_window_keeps_its_own_reset_time() {
1815        let snap = parsed();
1816        let at = |s: &str| Some(DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc));
1817        assert_eq!(
1818            snap.session.as_ref().unwrap().resets_at,
1819            at("2026-07-22T17:47:00Z")
1820        );
1821        assert_eq!(
1822            snap.weekly.as_ref().unwrap().resets_at,
1823            at("2026-07-28T17:39:58Z")
1824        );
1825        assert_eq!(
1826            snap.third_party_weekly.as_ref().unwrap().resets_at,
1827            at("2026-07-29T12:47:00Z")
1828        );
1829        // Regression: weekly must never be a copy of the 5h window.
1830        assert_ne!(
1831            snap.session.as_ref().unwrap().resets_at,
1832            snap.weekly.as_ref().unwrap().resets_at
1833        );
1834    }
1835
1836    #[test]
1837    fn window_durations_match_their_bucket() {
1838        let snap = parsed();
1839        assert_eq!(
1840            snap.session.as_ref().unwrap().window_duration,
1841            chrono::Duration::hours(5)
1842        );
1843        assert_eq!(
1844            snap.weekly.as_ref().unwrap().window_duration,
1845            chrono::Duration::days(7)
1846        );
1847        assert_eq!(
1848            snap.third_party_weekly.as_ref().unwrap().window_duration,
1849            chrono::Duration::days(7)
1850        );
1851    }
1852
1853    #[test]
1854    fn groups_are_matched_by_display_name_when_bucket_ids_change() {
1855        let v: serde_json::Value = serde_json::from_str(
1856            r#"{"response":{"groups":[
1857              {"displayName":"Gemini Models","buckets":[
1858                {"bucketId":"x1","window":"5h","remainingFraction":0.5,"resetTime":"2026-07-22T17:47:00Z"},
1859                {"bucketId":"x2","window":"weekly","remainingFraction":0.9,"resetTime":"2026-07-28T17:39:58Z"}]},
1860              {"displayName":"Claude and GPT models","buckets":[
1861                {"bucketId":"y1","window":"5h","remainingFraction":0.0,"resetTime":"2026-07-22T17:47:00Z"}]}
1862            ]}}"#,
1863        )
1864        .unwrap();
1865        let snap = parse_quota_summary(&v, "Pro".into()).unwrap();
1866        assert_eq!(snap.session.as_ref().unwrap().utilization_pct, 50);
1867        assert_eq!(snap.weekly.as_ref().unwrap().utilization_pct, 10);
1868        assert_eq!(snap.third_party_session.unwrap().utilization_pct, 100);
1869        assert!(snap.third_party_weekly.is_none());
1870    }
1871
1872    #[test]
1873    fn duplicate_or_unclassified_buckets_cannot_overwrite_a_slot() {
1874        let duplicate: serde_json::Value = serde_json::from_str(
1875            r#"{"response":{"groups":[{"displayName":"Gemini Models","buckets":[
1876              {"bucketId":"gemini-5h","window":"5h","remainingFraction":0.9},
1877              {"bucketId":"gemini-5h-copy","window":"5h","remainingFraction":0.1},
1878              {"bucketId":"gemini-weekly","window":"weekly","remainingFraction":0.8}
1879            ]}]}}"#,
1880        )
1881        .unwrap();
1882        let err = parse_quota_summary(&duplicate, "Pro".into()).unwrap_err();
1883        assert!(err.to_string().contains("duplicate Gemini 5h"), "{err}");
1884
1885        // A future pool or cadence is ignored, not silently treated as the
1886        // Claude/GPT 5h slot.
1887        let unrelated: serde_json::Value = serde_json::from_str(
1888            r#"{"response":{"groups":[
1889              {"displayName":"Gemini Models","buckets":[
1890                {"bucketId":"gemini-5h","window":"5h","remainingFraction":0.9},
1891                {"bucketId":"gemini-weekly","window":"weekly","remainingFraction":0.8},
1892                {"bucketId":"gemini-monthly","window":"monthly","remainingFraction":0.7}
1893              ]},
1894              {"displayName":"Future Models","buckets":[
1895                {"bucketId":"future-5h","window":"5h","remainingFraction":0.1}
1896              ]}
1897            ]}}"#,
1898        )
1899        .unwrap();
1900        let snap = parse_quota_summary(&unrelated, "Pro".into()).unwrap();
1901        assert!(snap.third_party_session.is_none());
1902        assert!(snap.third_party_weekly.is_none());
1903    }
1904
1905    /// A drifted bucket must fail the parse rather than report a reassuring
1906    /// "0% used" for a window whose real state is unknown.
1907    #[test]
1908    fn a_bucket_without_a_usable_fraction_is_rejected() {
1909        for bad in [r#""oops""#, "null", "-0.01", "1.01"] {
1910            let v: serde_json::Value = serde_json::from_str(&format!(
1911                r#"{{"response":{{"groups":[{{"displayName":"Gemini Models","buckets":[
1912                  {{"bucketId":"gemini-5h","window":"5h","remainingFraction":{bad}}},
1913                  {{"bucketId":"gemini-weekly","window":"weekly","remainingFraction":0.9}}]}}]}}}}"#
1914            ))
1915            .unwrap();
1916            let err = parse_quota_summary(&v, "Pro".into()).unwrap_err();
1917            assert!(err.to_string().contains("gemini-5h"), "{bad}: {err}");
1918        }
1919    }
1920
1921    #[test]
1922    fn malformed_present_reset_is_rejected_instead_of_disabling_expiry() {
1923        for bad in [serde_json::json!("not-a-time"), serde_json::json!(42)] {
1924            let mut v: serde_json::Value = serde_json::from_str(QUOTA_JSON).unwrap();
1925            v["response"]["groups"][0]["buckets"][0]["resetTime"] = bad;
1926            let err = parse_quota_summary(&v, "Pro".into()).unwrap_err();
1927            assert!(err.to_string().contains("resetTime"), "{err}");
1928        }
1929    }
1930
1931    /// The cache must round-trip a product that has no 5h window, and must
1932    /// still reject a document it did not write whole — an explicit `null`
1933    /// means "no such window", a missing key means truncation.
1934    #[test]
1935    fn a_weekly_only_snapshot_round_trips_through_the_cache() {
1936        let mut snap = parsed();
1937        snap.session = None;
1938        snap.third_party_session = None;
1939
1940        let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1941        let back = parse_cache_at(&bytes, None, now()).expect("weekly-only cache is usable");
1942
1943        assert!(back.session.is_none());
1944        assert_eq!(
1945            back.weekly.as_ref().unwrap().utilization_pct,
1946            snap.weekly.as_ref().unwrap().utilization_pct
1947        );
1948    }
1949
1950    /// Issue #139: Antigravity CLI 1.1.22 on a paid account returns weekly
1951    /// buckets and no 5-hour ones. Two usable windows arrived, so requiring a
1952    /// Gemini 5h bucket threw both away and failed the whole vendor. This is
1953    /// the reporter's payload.
1954    #[test]
1955    fn a_product_reporting_only_weekly_buckets_still_renders_them() {
1956        let summary = serde_json::json!({
1957            "groups": [
1958                {
1959                    "displayName": "Gemini Models",
1960                    "buckets": [{
1961                        "bucketId": "gemini-weekly", "window": "weekly",
1962                        "remainingFraction": 0.42,
1963                    }],
1964                },
1965                {
1966                    "displayName": "Claude and GPT models",
1967                    "buckets": [{
1968                        "bucketId": "3p-weekly", "window": "weekly",
1969                        "remainingFraction": 0.9,
1970                    }],
1971                },
1972            ],
1973        });
1974
1975        let snap = parse_quota_summary(&summary, "Pro".into()).expect("weekly-only is usable");
1976
1977        assert!(snap.session.is_none(), "no 5h bucket arrived");
1978        assert!(snap.third_party_session.is_none());
1979        assert_eq!(snap.weekly.as_ref().unwrap().utilization_pct, 58);
1980        assert_eq!(
1981            snap.third_party_weekly.as_ref().unwrap().utilization_pct,
1982            10
1983        );
1984    }
1985
1986    /// The opposite shape must work for the same reason — the fix is "at least
1987    /// one window", not "weekly is the required one now".
1988    #[test]
1989    fn a_product_reporting_only_five_hour_buckets_still_renders_them() {
1990        let summary = serde_json::json!({
1991            "groups": [{
1992                "displayName": "Gemini Models",
1993                "buckets": [{
1994                    "bucketId": "gemini-5h", "window": "5h", "remainingFraction": 0.25,
1995                }],
1996            }],
1997        });
1998
1999        let snap = parse_quota_summary(&summary, "Pro".into()).expect("5h-only is usable");
2000
2001        assert_eq!(snap.session.as_ref().unwrap().utilization_pct, 75);
2002        assert!(snap.weekly.is_none());
2003    }
2004
2005    /// Nothing recognisable is still an error, and still names what arrived so
2006    /// the next report is diagnosable.
2007    #[test]
2008    fn a_summary_with_no_recognisable_bucket_errors_and_names_what_it_had() {
2009        let summary = serde_json::json!({
2010            "groups": [{
2011                "displayName": "Gemini Models",
2012                "buckets": [{
2013                    "bucketId": "gemini-daily", "window": "daily", "remainingFraction": 0.9,
2014                }],
2015            }],
2016        });
2017
2018        let rendered = parse_quota_summary(&summary, "Pro".into())
2019            .expect_err("an unrecognised cadence alone is not a snapshot")
2020            .to_string();
2021
2022        assert!(
2023            rendered.contains("no bucket in a window we recognise"),
2024            "{rendered}"
2025        );
2026        assert!(rendered.contains("gemini-daily"), "{rendered}");
2027        assert!(rendered.contains("window daily"), "{rendered}");
2028    }
2029
2030    /// A summary with groups but no buckets at all is a different situation
2031    /// from a summary whose buckets we did not recognise, and says so.
2032    #[test]
2033    fn a_summary_with_no_buckets_at_all_says_that_rather_than_listing_nothing() {
2034        let summary = serde_json::json!({
2035            "groups": [{"displayName": "Gemini", "buckets": []}],
2036        });
2037
2038        let rendered = parse_quota_summary(&summary, "Pro".into())
2039            .expect_err("no buckets is an error")
2040            .to_string();
2041
2042        assert!(rendered.contains("no buckets at all"), "{rendered}");
2043        assert!(!rendered.contains("it offered:"), "{rendered}");
2044    }
2045
2046    /// An unnamed bucket must still be listed — a summary of nothing but
2047    /// unnamed buckets is itself the finding.
2048    #[test]
2049    fn buckets_without_an_id_are_still_named_in_the_error() {
2050        let summary = serde_json::json!({
2051            "groups": [{"displayName": "", "buckets": [{"remainingFraction": 0.5}]}],
2052        });
2053
2054        let rendered = parse_quota_summary(&summary, "Pro".into())
2055            .expect_err("an unusable summary is an error")
2056            .to_string();
2057
2058        assert!(rendered.contains("<unnamed>"), "{rendered}");
2059    }
2060
2061    #[test]
2062    fn missing_gemini_buckets_is_an_error_not_a_zero_bar() {
2063        let v: serde_json::Value = serde_json::from_str(r#"{"response":{"groups":[]}}"#).unwrap();
2064        assert!(parse_quota_summary(&v, "Pro".into()).is_err());
2065    }
2066
2067    #[test]
2068    fn cache_round_trip_preserves_every_window() {
2069        let snap = parsed();
2070        let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
2071        assert_eq!(parse_cache_at(&bytes, None, now()).unwrap(), snap);
2072    }
2073
2074    /// A truncated payload must fail so the caller refetches. Defaulting the
2075    /// missing field to 0 would serve a confident "0% used" for the rest of the
2076    /// TTL — the fabricated-placeholder defect corrected in PR #26.
2077    #[test]
2078    fn a_truncated_cached_payload_is_rejected_not_zeroed() {
2079        let full = snap_to_json(&parsed());
2080        for missing in ["session_pct", "weekly_pct"] {
2081            let mut v = full.clone();
2082            v.as_object_mut().unwrap().remove(missing);
2083            let bytes = serde_json::to_vec(&v).unwrap();
2084            let err = parse_cache_at(&bytes, None, now()).unwrap_err();
2085            assert!(err.to_string().contains(missing), "{missing}: {err}");
2086        }
2087        // A wholly empty object is not a zero-usage snapshot either.
2088        assert!(parse_cache_at(b"{}", None, now()).is_err());
2089    }
2090
2091    #[test]
2092    fn cached_percentages_are_range_checked_before_narrowing() {
2093        let full = snap_to_json(&parsed());
2094        for (key, bad) in [
2095            ("session_pct", serde_json::json!(-1)),
2096            ("weekly_pct", serde_json::json!(101)),
2097            ("session_pct", serde_json::json!(i64::MAX)),
2098            ("tp_session_pct", serde_json::json!("75")),
2099        ] {
2100            let mut v = full.clone();
2101            v[key] = bad;
2102            let bytes = serde_json::to_vec(&v).unwrap();
2103            let err = parse_cache_at(&bytes, None, now()).unwrap_err();
2104            assert!(err.to_string().contains(key), "{key}: {err}");
2105        }
2106    }
2107
2108    #[test]
2109    fn malformed_cached_reset_is_rejected_instead_of_served_for_a_week() {
2110        let mut v = snap_to_json(&parsed());
2111        v["session_reset"] = serde_json::json!("not-a-time");
2112        let bytes = serde_json::to_vec(&v).unwrap();
2113        let err = parse_cache_at(&bytes, None, now()).unwrap_err();
2114        assert!(err.to_string().contains("session_reset"), "{err}");
2115    }
2116
2117    /// With Antigravity closed the cache is served for up to `MAX_STALE`, but a
2118    /// window whose reset has passed has since rolled over — the real figure is
2119    /// back near zero while the payload still carries the old one. Serving that
2120    /// would present a known-obsolete number as current.
2121    #[test]
2122    fn a_cache_past_its_reset_is_refused() {
2123        let bytes = serde_json::to_vec(&snap_to_json(&parsed())).unwrap();
2124        let at = |s: &str| DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc);
2125
2126        // Before every reset: served.
2127        assert!(parse_cache_at(&bytes, None, now()).is_ok());
2128        // One second before the earliest (the two 5h windows, 17:47:00Z).
2129        assert!(parse_cache_at(&bytes, None, at("2026-07-22T17:46:59Z")).is_ok());
2130
2131        // The reset instant itself already counts as rolled over.
2132        let err = parse_cache_at(&bytes, None, at("2026-07-22T17:47:00Z")).unwrap_err();
2133        assert!(err.to_string().contains("5h"), "{err}");
2134
2135        // Well past it — this is the reboot-with-nothing-running case.
2136        assert!(parse_cache_at(&bytes, None, at("2026-07-23T09:00:00Z")).is_err());
2137    }
2138
2139    /// The weekly windows outlive the 5-hour ones, so expiry must be reported
2140    /// per window rather than assuming the shortest one speaks for all four.
2141    #[test]
2142    fn expiry_names_the_window_that_rolled_over() {
2143        let mut snap = parsed();
2144        // Drop the 5h windows so only the weeklies can expire.
2145        snap.session.as_mut().unwrap().resets_at = None;
2146        snap.third_party_session = None;
2147        let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
2148        let at = |s: &str| DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc);
2149
2150        // Past the 5h resets but before either weekly: still usable.
2151        assert!(parse_cache_at(&bytes, None, at("2026-07-23T09:00:00Z")).is_ok());
2152
2153        // Past the Gemini weekly (28th) but not the third-party one (29th).
2154        let err = parse_cache_at(&bytes, None, at("2026-07-28T18:00:00Z")).unwrap_err();
2155        assert!(err.to_string().contains("Gemini weekly"), "{err}");
2156    }
2157
2158    /// A window with no reset time is unknown, not expired.
2159    #[test]
2160    fn a_window_without_a_reset_never_expires() {
2161        let mut snap = parsed();
2162        for w in [&mut snap.session, &mut snap.weekly].into_iter().flatten() {
2163            w.resets_at = None;
2164        }
2165        snap.third_party_session = None;
2166        snap.third_party_weekly = None;
2167        let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
2168        let far_future = DateTime::parse_from_rfc3339("2030-01-01T00:00:00Z")
2169            .unwrap()
2170            .with_timezone(&Utc);
2171        assert!(parse_cache_at(&bytes, None, far_future).is_ok());
2172    }
2173
2174    /// Switching Google accounts must not show the previous account's quota.
2175    #[test]
2176    fn a_cache_from_another_account_is_rejected() {
2177        let mut snap = parsed();
2178        snap.account = "acct:aaaa".into();
2179        let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
2180
2181        assert!(parse_cache_at(&bytes, Some("acct:bbbb"), now()).is_err());
2182        assert_eq!(
2183            parse_cache_at(&bytes, Some("acct:aaaa"), now()).unwrap(),
2184            snap
2185        );
2186
2187        // A payload written before the account was recorded is unattributable.
2188        let mut legacy = snap_to_json(&snap);
2189        legacy.as_object_mut().unwrap().remove("account");
2190        let legacy = serde_json::to_vec(&legacy).unwrap();
2191        assert!(parse_cache_at(&legacy, Some("acct:aaaa"), now()).is_err());
2192    }
2193
2194    /// With no local server there is nothing to compare against — and nothing
2195    /// is consuming quota either, so the last known figures still stand.
2196    #[test]
2197    fn an_unverifiable_cache_is_served_rather_than_discarded() {
2198        let mut snap = parsed();
2199        snap.account = "acct:aaaa".into();
2200        let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
2201        assert_eq!(parse_cache_at(&bytes, None, now()).unwrap(), snap);
2202    }
2203
2204    #[test]
2205    fn account_key_fingerprints_rather_than_storing_the_address() {
2206        let with = |email: &str| account_key(&serde_json::json!({"userStatus": {"email": email}}));
2207        let a = with("someone@example.com");
2208        assert!(!a.contains("someone"), "{a}");
2209        assert!(!a.contains('@'), "{a}");
2210        assert_eq!(a, with("someone@example.com"), "must be stable");
2211        assert_ne!(a, with("other@example.com"));
2212        // An unidentifiable response still compares equal to itself.
2213        let unknown = account_key(&serde_json::json!({}));
2214        assert_eq!(unknown, account_key(&serde_json::json!({"userStatus": {}})));
2215        assert_ne!(unknown, a);
2216    }
2217
2218    /// The third-party pool is genuinely optional — a plan without it caches a
2219    /// null and must still read back, unlike the required Gemini windows.
2220    #[test]
2221    fn absent_third_party_windows_are_not_treated_as_corruption() {
2222        let mut snap = parsed();
2223        snap.third_party_session = None;
2224        snap.third_party_weekly = None;
2225        let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
2226        assert_eq!(parse_cache_at(&bytes, None, now()).unwrap(), snap);
2227    }
2228
2229    #[test]
2230    fn cache_round_trip_preserves_absent_third_party_windows() {
2231        let mut snap = parsed();
2232        snap.third_party_session = None;
2233        snap.third_party_weekly = None;
2234        let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
2235        assert_eq!(parse_cache_at(&bytes, None, now()).unwrap(), snap);
2236    }
2237
2238    #[test]
2239    fn pct_used_inverts_valid_fractions() {
2240        assert_eq!(pct_used(1.0), 0);
2241        assert_eq!(pct_used(0.0), 100);
2242        assert_eq!(pct_used(0.5), 50);
2243    }
2244
2245    #[test]
2246    fn plan_falls_back_through_the_status_payload() {
2247        let tier: serde_json::Value =
2248            serde_json::from_str(r#"{"userStatus":{"userTier":{"name":"Google AI Pro"}}}"#)
2249                .unwrap();
2250        assert_eq!(plan_from_status(&tier), "Google AI Pro");
2251
2252        let plan_only: serde_json::Value = serde_json::from_str(
2253            r#"{"userStatus":{"planStatus":{"planInfo":{"planName":"Pro"}}}}"#,
2254        )
2255        .unwrap();
2256        assert_eq!(plan_from_status(&plan_only), "Pro");
2257
2258        let empty: serde_json::Value = serde_json::from_str("{}").unwrap();
2259        assert_eq!(plan_from_status(&empty), DEFAULT_PLAN);
2260    }
2261
2262    #[cfg(target_os = "linux")]
2263    #[test]
2264    fn proc_net_parser_keeps_only_listening_rows() {
2265        let listen = "   0: 0100007F:975B 00000000:0000 0A 00000000:00000000 \
2266                      00:00000000 00000000  1000        0 123456 1 0000 100 0";
2267        assert_eq!(parse_proc_net_line(listen), Some((38747, 123456)));
2268
2269        let established = "   1: 0100007F:975B 0100007F:A1B2 01 00000000:00000000 \
2270                           00:00000000 00000000  1000        0 123457 1 0000 100 0";
2271        assert_eq!(parse_proc_net_line(established), None);
2272
2273        assert_eq!(parse_proc_net_line("garbage"), None);
2274    }
2275
2276    #[test]
2277    fn explicit_address_comes_first_and_gets_a_scheme() {
2278        let candidate = |base: &str, pid| Candidate {
2279            base: base.to_string(),
2280            pid,
2281            ide: false,
2282        };
2283        assert_eq!(
2284            candidate_bases_with(Some("127.0.0.1:1234"), vec![(10, 5678)], &no_ide()),
2285            vec![
2286                candidate("http://127.0.0.1:1234", None),
2287                candidate("http://127.0.0.1:5678", Some(10)),
2288            ]
2289        );
2290        // Trailing slashes are trimmed.
2291        assert_eq!(
2292            candidate_bases_with(Some("127.0.0.1:1234/"), vec![(10, 5678)], &no_ide()),
2293            vec![
2294                candidate("http://127.0.0.1:1234", None),
2295                candidate("http://127.0.0.1:5678", Some(10)),
2296            ]
2297        );
2298        // Duplicate base URL is omitted — and the override inherits the pid of
2299        // the process discovery saw on that port.
2300        assert_eq!(
2301            candidate_bases_with(Some("127.0.0.1:5678"), vec![(10, 5678)], &no_ide()),
2302            vec![candidate("http://127.0.0.1:5678", Some(10))]
2303        );
2304        // Duplicate discovered ports are omitted.
2305        assert_eq!(
2306            candidate_bases_with(None, vec![(10, 5678), (10, 5678)], &no_ide()),
2307            vec![candidate("http://127.0.0.1:5678", Some(10))]
2308        );
2309        // An address that already carries a scheme is left alone.
2310        assert_eq!(
2311            candidate_bases_with(Some("https://host:9"), vec![], &no_ide()),
2312            vec![candidate("https://host:9", None)]
2313        );
2314    }
2315
2316    /// No IDE-owned pids — what discovery hands over whenever
2317    /// `ide_language_server` is left at its default.
2318    fn no_ide() -> std::collections::HashSet<u32> {
2319        std::collections::HashSet::new()
2320    }
2321
2322    fn http(status: u16) -> AppError {
2323        AppError::Http {
2324            status,
2325            body: String::new(),
2326        }
2327    }
2328
2329    fn missing_csrf() -> AppError {
2330        missing_csrf_with("missing CSRF token")
2331    }
2332
2333    fn missing_csrf_with(message: &str) -> AppError {
2334        AppError::Http {
2335            status: 401,
2336            body: serde_json::json!({
2337                "code": "unauthenticated",
2338                "message": message,
2339            })
2340            .to_string(),
2341        }
2342    }
2343
2344    #[test]
2345    fn only_agys_exact_missing_csrf_response_enables_remote_fallback() {
2346        assert!(is_missing_csrf(&missing_csrf()));
2347        assert!(is_missing_csrf(&missing_csrf_with(
2348            "unauthenticated: missing CSRF token"
2349        )));
2350        assert!(is_missing_csrf(&missing_csrf_with(
2351            " unauthenticated: missing CSRF token "
2352        )));
2353        assert!(!is_missing_csrf(&missing_csrf_with(
2354            "unauthenticated: invalid token"
2355        )));
2356        assert!(!is_missing_csrf(&AppError::Http {
2357            status: 403,
2358            body: r#"{"code":"unauthenticated","message":"missing CSRF token"}"#.into(),
2359        }));
2360        assert!(!is_missing_csrf(&AppError::Http {
2361            status: 401,
2362            body: r#"{"code":"other","message":"missing CSRF token"}"#.into(),
2363        }));
2364        assert!(!is_missing_csrf(&AppError::Http {
2365            status: 401,
2366            body: "prefix: missing CSRF token".into(),
2367        }));
2368    }
2369
2370    #[test]
2371    fn a_real_auth_failure_outranks_agys_missing_csrf_response() {
2372        for errors in [
2373            vec![missing_csrf(), http(403)],
2374            vec![http(401), missing_csrf()],
2375        ] {
2376            let err = select_probe_error(errors);
2377            assert!(is_actionable(&err), "{err}");
2378            assert!(!is_missing_csrf(&err), "{err}");
2379        }
2380    }
2381
2382    #[test]
2383    fn several_agy_sessions_still_select_the_remote_fallback_reason() {
2384        let err = select_probe_error(vec![missing_csrf(), missing_csrf()]);
2385        assert!(is_missing_csrf(&err), "{err}");
2386        assert!(remote_fallback_reason(&err).is_some(), "{err}");
2387    }
2388
2389    /// A signed-out server is worth reporting even when a later candidate only
2390    /// refused the connection — that is the whole point of probing on past the
2391    /// first failure.
2392    #[test]
2393    fn an_auth_failure_outranks_later_transport_noise() {
2394        let err = select_probe_error(vec![
2395            http(401),
2396            AppError::Transport("connection refused".into()),
2397        ]);
2398        assert!(matches!(err, AppError::Http { status: 401, .. }), "{err}");
2399
2400        let err = select_probe_error(vec![
2401            AppError::Transport("connection refused".into()),
2402            http(403),
2403        ]);
2404        assert!(matches!(err, AppError::Http { status: 403, .. }), "{err}");
2405    }
2406
2407    /// The *first* actionable failure wins, so the explicit override's message
2408    /// survives a second signed-out product further down the list.
2409    #[test]
2410    fn the_first_auth_failure_wins() {
2411        let err = select_probe_error(vec![http(401), http(403)]);
2412        assert!(matches!(err, AppError::Http { status: 401, .. }), "{err}");
2413    }
2414
2415    /// With nothing actionable, the last failure stands in for "nothing
2416    /// answered" — and stays transient, so the widget falls back silently
2417    /// instead of shouting about a product that simply is not running.
2418    #[test]
2419    fn without_an_auth_failure_the_last_error_stands() {
2420        let err = select_probe_error(vec![
2421            AppError::Transport("first".into()),
2422            http(500),
2423            AppError::Transport("last".into()),
2424        ]);
2425        assert!(
2426            matches!(&err, AppError::Transport(m) if m == "last"),
2427            "{err}"
2428        );
2429        assert!(err.is_transient());
2430    }
2431
2432    /// A 5xx is a server that answered but broke; the user cannot act on it, so
2433    /// it must not outrank a later real failure the way a 401 does.
2434    #[test]
2435    fn a_server_error_is_not_treated_as_actionable() {
2436        let err = select_probe_error(vec![http(500), http(401)]);
2437        assert!(matches!(err, AppError::Http { status: 401, .. }), "{err}");
2438    }
2439
2440    #[test]
2441    fn no_candidates_at_all_yields_a_generic_error() {
2442        let err = select_probe_error(Vec::new());
2443        assert!(
2444            err.to_string().contains("no local server answered"),
2445            "{err}"
2446        );
2447    }
2448
2449    /// Verbatim from Go's `net/http`: this is what every Antigravity product's
2450    /// HTTPS listener replies to the cleartext probe, so it is the tail of a
2451    /// normal discovery run rather than a symptom.
2452    fn tls_echo() -> AppError {
2453        AppError::Http {
2454            status: 400,
2455            body: "Client sent an HTTP request to an HTTPS server.\n".into(),
2456        }
2457    }
2458
2459    /// The RPC listener is probed first, so whatever it said is the diagnosis.
2460    /// The TLS port is reached only afterwards and always "fails", so without
2461    /// demoting it, it overwrites the one error that came from the server the
2462    /// user actually cares about.
2463    #[test]
2464    fn a_tls_echo_does_not_mask_what_the_rpc_listener_said() {
2465        let err = select_probe_error(vec![
2466            AppError::Http {
2467                status: 500,
2468                body: "GetUserStatus: internal".into(),
2469            },
2470            tls_echo(),
2471        ]);
2472        assert!(
2473            matches!(&err, AppError::Http { status: 500, body } if body.contains("internal")),
2474            "{err}"
2475        );
2476    }
2477
2478    /// The regression that motivated this: an `Http` is not transient, so an
2479    /// echo standing in as "the last failure" costs the silent cache fallback
2480    /// that `without_an_auth_failure_the_last_error_stands` exists to protect.
2481    /// A product that is merely not serving RPC must stay quiet.
2482    #[test]
2483    fn a_tls_echo_does_not_cost_the_silent_fallback() {
2484        let err = select_probe_error(vec![
2485            AppError::Transport("connection refused".into()),
2486            tls_echo(),
2487        ]);
2488        assert!(
2489            matches!(&err, AppError::Transport(m) if m == "connection refused"),
2490            "{err}"
2491        );
2492        assert!(
2493            err.is_transient(),
2494            "the echo must not make the run non-transient: {err}"
2495        );
2496    }
2497
2498    /// Demoted, not discarded. When the TLS listener is genuinely all that
2499    /// answered, its reply is still better than a generic "nothing answered".
2500    #[test]
2501    fn a_tls_echo_still_stands_when_it_is_the_only_thing_that_answered() {
2502        let err = select_probe_error(vec![tls_echo()]);
2503        assert!(matches!(err, AppError::Http { status: 400, .. }), "{err}");
2504    }
2505
2506    /// The demotion keys on the body, so the language server's own `400` — a
2507    /// real complaint about a real request — keeps its normal rank.
2508    #[test]
2509    fn a_genuine_bad_request_is_not_mistaken_for_a_tls_echo() {
2510        let err = select_probe_error(vec![
2511            AppError::Http {
2512                status: 400,
2513                body: "unknown method GetUserStatus".into(),
2514            },
2515            tls_echo(),
2516        ]);
2517        assert!(
2518            matches!(&err, AppError::Http { status: 400, body } if body.contains("unknown method")),
2519            "{err}"
2520        );
2521    }
2522
2523    /// Ranking the echo last must not disturb the top of the order.
2524    #[test]
2525    fn an_auth_failure_still_outranks_a_tls_echo() {
2526        let err = select_probe_error(vec![tls_echo(), http(401)]);
2527        assert!(matches!(err, AppError::Http { status: 401, .. }), "{err}");
2528    }
2529
2530    #[test]
2531    fn every_discovered_port_is_probed_in_order() {
2532        assert_eq!(
2533            candidate_bases_with(None, vec![(10, 33875), (10, 37435)], &no_ide()),
2534            vec![
2535                Candidate {
2536                    base: "http://127.0.0.1:33875".to_string(),
2537                    pid: Some(10),
2538                    ide: false,
2539                },
2540                Candidate {
2541                    base: "http://127.0.0.1:37435".to_string(),
2542                    pid: Some(10),
2543                    ide: false,
2544                },
2545            ]
2546        );
2547    }
2548
2549    /// The server's port is drawn from the ephemeral range, so there is nothing
2550    /// sensible to guess when discovery comes up empty. Probing a hardcoded
2551    /// port would contact an unrelated process; callers get the "start
2552    /// Antigravity or set ANTIGRAVITY_LS_ADDRESS" error instead.
2553    #[test]
2554    fn empty_discovery_yields_no_candidates() {
2555        assert!(candidate_bases_with(None, vec![], &no_ide()).is_empty());
2556        assert!(candidate_bases_with(Some(""), vec![], &no_ide()).is_empty());
2557    }
2558
2559    #[test]
2560    fn every_antigravity_product_is_recognised() {
2561        // Antigravity 2.0 / IDE: a separate language_server child.
2562        assert!(is_antigravity_process(
2563            "language_server\n",
2564            Some("/opt/antigravity/resources/bin/language_server")
2565        ));
2566        // agy CLI: embeds the RPC surface in its own process.
2567        assert!(is_antigravity_process(
2568            "agy\n",
2569            Some("/home/u/.local/bin/agy")
2570        ));
2571        // Recognised by path even when the process name says nothing.
2572        assert!(is_antigravity_process(
2573            "node",
2574            Some("/opt/antigravity/bin/helper")
2575        ));
2576        assert!(is_antigravity_process("antigravity", None));
2577        assert!(is_antigravity_process("agy.exe", None));
2578        assert!(is_antigravity_process("Antigravity.exe", None));
2579        assert!(is_antigravity_process("language_server.exe", None));
2580        assert!(is_antigravity_process(
2581            "language_server_windows_x64.exe",
2582            None
2583        ));
2584        assert!(is_antigravity_process(
2585            "node.exe",
2586            Some(r"C:\Users\u\AppData\Local\agy.exe")
2587        ));
2588    }
2589
2590    #[test]
2591    fn unrelated_processes_are_not_probed() {
2592        assert!(!is_antigravity_process("sshd", Some("/usr/sbin/sshd")));
2593        assert!(!is_antigravity_process("node", Some("/usr/bin/node")));
2594        // "legacy" ends in a substring of "/agy" but is not the CLI.
2595        assert!(!is_antigravity_process("legacy", Some("/usr/bin/legacy")));
2596        assert!(!is_antigravity_process("legacy.exe", None));
2597        assert!(!is_antigravity_process("not-agy.exe", None));
2598        assert!(!is_antigravity_process("", None));
2599    }
2600
2601    #[test]
2602    fn windows_process_names_decode_until_nul_and_tolerate_invalid_utf16() {
2603        let mut raw: Vec<u16> = "agy.exe".encode_utf16().collect();
2604        raw.extend([0, b'x' as u16]);
2605        assert_eq!(decode_windows_process_name(&raw), "agy.exe");
2606        assert_eq!(decode_windows_process_name(&[0xd800]), "�");
2607        assert_eq!(decode_windows_process_name(&[]), "");
2608    }
2609
2610    #[test]
2611    fn windows_process_filter_keeps_only_antigravity_pids() {
2612        let processes = vec![
2613            (10, "agy.exe".to_string()),
2614            (20, "language_server_windows_x64.exe".to_string()),
2615            (30, "sshd.exe".to_string()),
2616        ];
2617        let pids = matching_windows_process_ids(&processes);
2618        assert_eq!(pids, std::collections::HashSet::from([10, 20]));
2619    }
2620
2621    #[test]
2622    fn windows_listener_filter_joins_pid_loopback_and_port() {
2623        let pids = std::collections::HashSet::from([10]);
2624        let rows = [
2625            WindowsTcpRow {
2626                local_addr: [127, 0, 0, 1],
2627                local_port: u32::from(59870u16.to_be()),
2628                pid: 10,
2629            },
2630            WindowsTcpRow {
2631                local_addr: [127, 0, 0, 1],
2632                local_port: u32::from(59868u16.to_be()),
2633                pid: 10,
2634            },
2635            WindowsTcpRow {
2636                local_addr: [127, 0, 0, 1],
2637                local_port: u32::from(59870u16.to_be()),
2638                pid: 10,
2639            },
2640            WindowsTcpRow {
2641                local_addr: [0, 0, 0, 0],
2642                local_port: u32::from(50000u16.to_be()),
2643                pid: 10,
2644            },
2645            WindowsTcpRow {
2646                local_addr: [127, 0, 0, 1],
2647                local_port: u32::from(50001u16.to_be()),
2648                pid: 99,
2649            },
2650            WindowsTcpRow {
2651                local_addr: [127, 0, 0, 1],
2652                local_port: 0,
2653                pid: 10,
2654            },
2655        ];
2656        assert_eq!(
2657            matching_windows_ports(&pids, &rows),
2658            vec![(10, 59870), (10, 59868)]
2659        );
2660    }
2661
2662    /// Antigravity 2.0 and an interactive `agy` session at once. Their port
2663    /// pairs must not be flattened into one set: sorting all four descending
2664    /// would put pid 20's TLS listener ahead of pid 10's RPC listener.
2665    #[test]
2666    fn windows_ports_from_two_products_keep_tls_listeners_last() {
2667        let pids = std::collections::HashSet::from([10, 20]);
2668        let row = |port: u16, pid: u32| WindowsTcpRow {
2669            local_addr: [127, 0, 0, 1],
2670            local_port: u32::from(port.to_be()),
2671            pid,
2672        };
2673        let rows = [
2674            row(40000, 10),
2675            row(40001, 10),
2676            row(50000, 20),
2677            row(50001, 20),
2678        ];
2679        assert_eq!(
2680            matching_windows_ports(&pids, &rows),
2681            vec![(10, 40001), (20, 50001), (10, 40000), (20, 50000)]
2682        );
2683    }
2684
2685    /// The high-to-low preference only means something per product, so the
2686    /// grouping is what keeps a second product's TLS listener from being
2687    /// probed before the first product's RPC listener.
2688    #[test]
2689    fn probe_order_puts_every_rpc_listener_ahead_of_every_tls_listener() {
2690        use std::collections::BTreeMap;
2691
2692        // One process, the ordinary case: RPC (higher) before TLS (lower).
2693        assert_eq!(
2694            probe_order(BTreeMap::from([(10, vec![59868, 59870])])),
2695            vec![(10, 59870), (10, 59868)]
2696        );
2697        // Two products. A plain descending sort would yield 50001, 50000,
2698        // 40001, 40000 and reach pid 20's TLS listener second; taking the
2699        // ports rank by rank leaves both TLS listeners at the back, where they
2700        // are touched only if no RPC listener answered.
2701        assert_eq!(
2702            probe_order(BTreeMap::from([
2703                (10, vec![40000, 40001]),
2704                (20, vec![50000, 50001]),
2705            ])),
2706            vec![(10, 40001), (20, 50001), (10, 40000), (20, 50000)]
2707        );
2708        // Uneven groups: the extra port of the deeper group trails everything
2709        // it ranks below, and a port claimed by two pids is probed once.
2710        assert_eq!(
2711            probe_order(BTreeMap::from([
2712                (10, vec![6000, 5000, 4000]),
2713                (20, vec![6000, 7000]),
2714            ])),
2715            vec![(10, 6000), (20, 7000), (10, 5000), (10, 4000)]
2716        );
2717        assert!(probe_order(BTreeMap::new()).is_empty());
2718    }
2719
2720    /// A dual-stack bind names the same port from both `/proc/net/tcp` and
2721    /// `tcp6`. Those rows are one listener, so they must not consume two ranks
2722    /// and push the product's real second listener down past another
2723    /// product's.
2724    #[test]
2725    fn a_port_named_twice_by_one_product_still_occupies_one_rank() {
2726        use std::collections::BTreeMap;
2727
2728        assert_eq!(
2729            probe_order(BTreeMap::from([
2730                (10, vec![40001, 40001, 40000, 40000]),
2731                (20, vec![50001, 50000]),
2732            ])),
2733            vec![(10, 40001), (20, 50001), (10, 40000), (20, 50000)],
2734            "duplicate rows must not reorder the ranks below them"
2735        );
2736    }
2737
2738    /// The ordering rests on each product showing both listeners. A product
2739    /// caught mid-startup, with only its TLS port bound, sits alone at rank 0
2740    /// and is probed first — documented as the known cost, and harmless
2741    /// because every candidate is probed anyway.
2742    #[test]
2743    fn a_half_started_product_is_the_documented_exception() {
2744        use std::collections::BTreeMap;
2745
2746        assert_eq!(
2747            probe_order(BTreeMap::from([
2748                (10, vec![40000]),
2749                (20, vec![50001, 50000])
2750            ])),
2751            vec![(10, 40000), (20, 50001), (20, 50000)]
2752        );
2753    }
2754
2755    /// `ANTIGRAVITY_LS_ADDRESS` is user input. An entry that leaves no
2756    /// authority to connect to is dropped instead of probed, so it can neither
2757    /// spend a round-trip nor add a failure that competes with the real one in
2758    /// [`select_probe_error`].
2759    #[test]
2760    fn an_override_with_no_authority_is_dropped_not_probed() {
2761        for junk in ["/", "///", "http://", "https://", "  /  "] {
2762            assert_eq!(
2763                candidate_bases_with(Some(junk), vec![(10, 4242)], &no_ide()),
2764                vec![Candidate {
2765                    base: "http://127.0.0.1:4242".to_string(),
2766                    pid: Some(10),
2767                    ide: false,
2768                }],
2769                "{junk:?} should not survive as a candidate"
2770            );
2771        }
2772        assert!(candidate_bases_with(Some("/"), vec![], &no_ide()).is_empty());
2773    }
2774
2775    #[test]
2776    fn windows_table_bounds_reject_truncation_and_overflow() {
2777        assert_eq!(checked_windows_row_count(52, 4, 24, 2), Some(2));
2778        assert_eq!(checked_windows_row_count(51, 4, 24, 2), None);
2779        assert_eq!(checked_windows_row_count(4, 4, 24, 0), Some(0));
2780        assert_eq!(checked_windows_row_count(52, 4, 0, 2), None);
2781        assert_eq!(
2782            checked_windows_row_count(usize::MAX, 4, 24, usize::MAX),
2783            None
2784        );
2785    }
2786
2787    #[cfg(target_os = "windows")]
2788    #[test]
2789    fn windows_tcp_table_parser_copies_complete_rows_only() {
2790        use std::mem::{offset_of, size_of};
2791        use windows_sys::Win32::NetworkManagement::IpHelper::{
2792            MIB_TCPROW_OWNER_PID, MIB_TCPTABLE_OWNER_PID,
2793        };
2794
2795        let offset = offset_of!(MIB_TCPTABLE_OWNER_PID, table);
2796        let used = offset + 2 * size_of::<MIB_TCPROW_OWNER_PID>();
2797        let words = used.div_ceil(size_of::<u32>());
2798        let mut buffer = vec![0u32; words];
2799        let first = MIB_TCPROW_OWNER_PID {
2800            dwLocalAddr: u32::from_ne_bytes([127, 0, 0, 1]),
2801            dwLocalPort: u32::from(59868u16.to_be()),
2802            dwOwningPid: 10,
2803            ..Default::default()
2804        };
2805        let second = MIB_TCPROW_OWNER_PID {
2806            dwLocalAddr: u32::from_ne_bytes([127, 0, 0, 1]),
2807            dwLocalPort: u32::from(59870u16.to_be()),
2808            dwOwningPid: 10,
2809            ..Default::default()
2810        };
2811        unsafe {
2812            buffer.as_mut_ptr().write_unaligned(2);
2813            let rows = buffer
2814                .as_mut_ptr()
2815                .cast::<u8>()
2816                .add(offset)
2817                .cast::<MIB_TCPROW_OWNER_PID>();
2818            rows.write_unaligned(first);
2819            rows.add(1).write_unaligned(second);
2820        }
2821
2822        assert_eq!(
2823            parse_windows_tcp_rows(&buffer, used),
2824            vec![
2825                WindowsTcpRow {
2826                    local_addr: [127, 0, 0, 1],
2827                    local_port: u32::from(59868u16.to_be()),
2828                    pid: 10,
2829                },
2830                WindowsTcpRow {
2831                    local_addr: [127, 0, 0, 1],
2832                    local_port: u32::from(59870u16.to_be()),
2833                    pid: 10,
2834                },
2835            ]
2836        );
2837        assert!(parse_windows_tcp_rows(&buffer, used - 1).is_empty());
2838    }
2839
2840    #[test]
2841    fn lsof_parser_keeps_only_ports_owned_by_antigravity_processes() {
2842        // `agy` (pid 74101) has three listening sockets; `sshd` (pid 200) has
2843        // one that must be excluded even though it sorts right after `c`.
2844        let output = "p74101\ncagy\nf10\nn127.0.0.1:8829\nf11\nn127.0.0.1:61289\nf12\nn127.0.0.1:61290\np200\ncsshd\nf5\nn*:22\n";
2845        assert_eq!(
2846            parse_lsof_pcn(output),
2847            vec![(74101, 61290), (74101, 61289), (74101, 8829)]
2848        );
2849    }
2850
2851    /// The pid on each `p` line has to survive to the `n` lines, or the ports
2852    /// of two running products collapse into one group and rank ordering can
2853    /// no longer keep the TLS listeners last.
2854    #[test]
2855    fn lsof_parser_keeps_each_products_ports_in_its_own_group() {
2856        let output = concat!(
2857            "p100\ncagy\nf3\nn127.0.0.1:40000\nf4\nn127.0.0.1:40001\n",
2858            "p200\nclanguage_server\nf5\nn127.0.0.1:50000\nf6\nn127.0.0.1:50001\n",
2859        );
2860        assert_eq!(
2861            parse_lsof_pcn(output),
2862            vec![(100, 40001), (200, 50001), (100, 40000), (200, 50000)],
2863            "both HTTP listeners must precede both TLS listeners"
2864        );
2865    }
2866
2867    #[test]
2868    fn lsof_parser_matches_the_capitalised_macos_app_name() {
2869        let output = "p900\ncAntigravity\nf7\nn127.0.0.1:54321\n";
2870        assert_eq!(parse_lsof_pcn(output), vec![(900, 54321)]);
2871    }
2872
2873    #[test]
2874    fn lsof_parser_deduplicates_and_handles_empty_output() {
2875        let output = "p1\ncagy\nf3\nn127.0.0.1:9000\nf4\nn127.0.0.1:9000\n";
2876        assert_eq!(parse_lsof_pcn(output), vec![(1, 9000)]);
2877        assert!(parse_lsof_pcn("").is_empty());
2878    }
2879
2880    /// First run with Antigravity closed: no cache to serve, so the user must
2881    /// be told what to start — not "no usable cache", which says nothing.
2882    #[test]
2883    fn missing_cache_surfaces_the_diagnosis_not_a_cache_miss() {
2884        let dir = tempfile::tempdir().unwrap();
2885        let cache = Cache::at(dir.path().join("usage.json"));
2886        let reason = AppError::Credentials("Antigravity: no local language server found".into());
2887
2888        let err = fallback_with_error(&cache, None, reason, now()).unwrap_err();
2889        let msg = err.to_string();
2890        assert!(msg.contains("no local language server found"), "{msg}");
2891        assert!(!msg.contains("no usable cache"), "{msg}");
2892    }
2893
2894    #[test]
2895    fn unusable_cache_does_not_replace_the_live_diagnosis() {
2896        let dir = tempfile::tempdir().unwrap();
2897        let cache = Cache::at(dir.path().join("antigravity"));
2898        cache.write_payload(b"{}").unwrap();
2899
2900        let reason = AppError::Credentials("Antigravity must be running".into());
2901        let err = fallback_with_error(&cache, None, reason, now()).unwrap_err();
2902        assert!(err.to_string().contains("must be running"), "{err}");
2903
2904        let original = AppError::Transport("original loopback failure".into());
2905        let err = fallback_silent(&cache, now(), original).unwrap_err();
2906        assert!(
2907            err.to_string().contains("original loopback failure"),
2908            "{err}"
2909        );
2910    }
2911
2912    #[tokio::test]
2913    async fn rpc_error_bodies_are_bounded_too() {
2914        let mut server = mockito::Server::new_async().await;
2915        let path = format!("/{STATUS_RPC}");
2916        server
2917            .mock("POST", path.as_str())
2918            .with_status(500)
2919            .with_body("x".repeat(crate::vendor::MAX_BODY_BYTES + 1))
2920            .create_async()
2921            .await;
2922
2923        let err = post_rpc(&reqwest::Client::new(), &server.url(), None, STATUS_RPC)
2924            .await
2925            .unwrap_err();
2926        assert!(err.to_string().contains("exceeds"), "{err}");
2927    }
2928
2929    #[test]
2930    fn blank_override_falls_through_to_discovery() {
2931        assert_eq!(
2932            candidate_bases_with(Some("   "), vec![(10, 4242)], &no_ide()),
2933            vec![Candidate {
2934                base: "http://127.0.0.1:4242".to_string(),
2935                pid: Some(10),
2936                ide: false,
2937            }]
2938        );
2939    }
2940
2941    /// The IDE's install tree is the only one that names itself; the 2.0
2942    /// desktop app and the `agy` CLI must never be mistaken for it.
2943    #[test]
2944    fn only_the_ide_install_is_recognised() {
2945        assert!(is_ide_install(
2946            "/opt/antigravity-ide/resources/app/extensions/antigravity/bin/language_server_linux_x64"
2947        ));
2948        assert!(is_ide_install(
2949            "/Applications/Antigravity IDE.app/Contents/Resources/app/extensions/antigravity/bin/language_server_darwin_arm64"
2950        ));
2951        assert!(is_ide_install(
2952            r"C:\Users\u\AppData\Local\Programs\Antigravity IDE\language_server_windows_x64.exe"
2953        ));
2954        assert!(!is_ide_install(
2955            "/opt/antigravity/resources/bin/language_server"
2956        ));
2957        assert!(!is_ide_install("/home/u/.local/bin/agy"));
2958        assert!(!is_ide_install("/usr/bin/node"));
2959    }
2960
2961    /// `ps -o pid=,comm=` output keeps paths with spaces in one piece.
2962    #[test]
2963    fn ps_output_pairs_pids_with_full_paths() {
2964        let output = "  101 /opt/antigravity-ide/bin/language_server_linux_x64\n\
2965                      202 /Applications/Antigravity IDE.app/Contents/MacOS/Antigravity IDE\n";
2966        assert_eq!(
2967            parse_ps_comm(output),
2968            vec![
2969                (
2970                    101,
2971                    "/opt/antigravity-ide/bin/language_server_linux_x64".to_string()
2972                ),
2973                (
2974                    202,
2975                    "/Applications/Antigravity IDE.app/Contents/MacOS/Antigravity IDE".to_string()
2976                ),
2977            ]
2978        );
2979        assert!(parse_ps_comm("").is_empty());
2980        assert!(parse_ps_comm("not-a-pid /tmp/x").is_empty());
2981    }
2982
2983    /// #405, the enumeration seam: discovery marks the IDE's listeners so
2984    /// `open_session` skips them; an explicit override that names the IDE's
2985    /// port is the user's own target and is probed regardless.
2986    #[test]
2987    fn ide_owned_listeners_are_marked_but_an_explicit_override_is_not() {
2988        let ide: std::collections::HashSet<u32> = [10].into_iter().collect();
2989
2990        let candidates = candidate_bases_with(None, vec![(10, 40001), (20, 50001)], &ide);
2991        assert_eq!(candidates.len(), 2);
2992        assert!(candidates[0].ide, "pid 10 is the IDE's language server");
2993        assert!(!candidates[1].ide);
2994
2995        let candidates = candidate_bases_with(Some("127.0.0.1:40001"), vec![(10, 40001)], &ide);
2996        assert_eq!(
2997            candidates,
2998            vec![Candidate {
2999                base: "http://127.0.0.1:40001".to_string(),
3000                pid: Some(10),
3001                ide: false,
3002            }],
3003            "ANTIGRAVITY_LS_ADDRESS is an explicit target, never skipped"
3004        );
3005    }
3006
3007    // -----------------------------------------------------------------------
3008    // Remote fallback
3009    // -----------------------------------------------------------------------
3010
3011    fn fixture() -> (tempfile::TempDir, Cache) {
3012        let td = tempfile::TempDir::new().unwrap();
3013        let cache = Cache::at(td.path().join("antigravity"));
3014        (td, cache)
3015    }
3016
3017    fn endpoints(server: &mockito::Server) -> cloud::Endpoints {
3018        let base = server.url();
3019        cloud::Endpoints {
3020            quota: vec![format!("{base}/daily/quota"), format!("{base}/prod/quota")],
3021            load_code_assist: vec![format!("{base}/daily/plan"), format!("{base}/prod/plan")],
3022            token: format!("{base}/token"),
3023        }
3024    }
3025
3026    /// A keyring blob as Antigravity writes it, expiring at `expiry`.
3027    fn keyring_blob(expiry: &str, with_refresh: bool) -> String {
3028        let mut token = serde_json::json!({
3029            "access_token": "KEYRING-AT",
3030            "expiry": expiry,
3031        });
3032        if with_refresh {
3033            token["refresh_token"] = serde_json::json!("KEYRING-RT");
3034        }
3035        serde_json::json!({ "token": token }).to_string()
3036    }
3037
3038    /// Well before the fixture's `now()`.
3039    const EXPIRED: &str = "2026-07-22T11:00:00Z";
3040    /// Comfortably after it.
3041    const VALID: &str = "2026-07-22T13:00:00Z";
3042
3043    /// No local server, this blob, these endpoints.
3044    fn remote<'a>(blob: &'a str, eps: &'a cloud::Endpoints) -> RemoteOverride<'a> {
3045        RemoteOverride {
3046            credential: SavedCredential::Blob(blob),
3047            endpoints: Some(eps),
3048            local_bases: Some(vec![]),
3049            ..Default::default()
3050        }
3051    }
3052
3053    /// The bare summary the API returns: the RPC's payload without its
3054    /// `{"response": …}` envelope.
3055    fn bare_summary() -> String {
3056        let v: serde_json::Value = serde_json::from_str(QUOTA_JSON).unwrap();
3057        v["response"].to_string()
3058    }
3059
3060    fn quota_mock(server: &mut mockito::Server, bearer: &str) -> mockito::Mock {
3061        server
3062            .mock("POST", "/daily/quota")
3063            .match_header("authorization", format!("Bearer {bearer}").as_str())
3064            .with_status(200)
3065            .with_body(bare_summary())
3066    }
3067
3068    fn token_mock(server: &mut mockito::Server) -> mockito::Mock {
3069        server
3070            .mock("POST", "/token")
3071            .with_status(200)
3072            .with_body(r#"{"access_token":"NEW-AT","expires_in":3600}"#)
3073    }
3074
3075    fn test_oauth() -> cloud::OauthClient {
3076        cloud::OauthClient {
3077            id: "test-client".into(),
3078            secret: "test-client-secret".into(),
3079        }
3080    }
3081
3082    async fn run(cache: &Cache, remote: RemoteOverride<'_>, ttl: Duration) -> Result<FetchOutcome> {
3083        fetch_snapshot_at(
3084            &reqwest::Client::new(),
3085            cache,
3086            ttl,
3087            Some(&test_oauth()),
3088            remote,
3089            now(),
3090        )
3091        .await
3092    }
3093
3094    #[tokio::test]
3095    async fn with_no_local_server_the_saved_session_answers_from_the_api() {
3096        let mut server = mockito::Server::new_async().await;
3097        let eps = endpoints(&server);
3098        let quota = quota_mock(&mut server, "KEYRING-AT")
3099            .expect(1)
3100            .create_async()
3101            .await;
3102        let plan = server
3103            .mock("POST", "/daily/plan")
3104            .match_header("authorization", "Bearer KEYRING-AT")
3105            .with_status(200)
3106            .with_body(r#"{"currentTier":{"name":"google_ai_pro"}}"#)
3107            .expect(1)
3108            .create_async()
3109            .await;
3110        // A token this fresh is used as it is.
3111        let token = token_mock(&mut server).expect(0).create_async().await;
3112        let blob = keyring_blob(VALID, true);
3113        let (_td, cache) = fixture();
3114
3115        let outcome = run(&cache, remote(&blob, &eps), Duration::from_secs(60))
3116            .await
3117            .expect("remote path yields a snapshot");
3118
3119        quota.assert_async().await;
3120        plan.assert_async().await;
3121        token.assert_async().await;
3122        let snap = outcome.snapshot;
3123        assert!(!outcome.stale);
3124        assert_eq!(snap.source, AntigravitySource::Remote);
3125        assert_eq!(snap.plan, "Pro");
3126        assert_eq!(snap.session.as_ref().unwrap().utilization_pct, 43);
3127        assert_eq!(snap.weekly.as_ref().unwrap().utilization_pct, 8);
3128        assert_eq!(snap.third_party_session.unwrap().utilization_pct, 75);
3129        assert_eq!(snap.third_party_weekly.unwrap().utilization_pct, 0);
3130        let fingerprint = credential::parse_keyring_blob(&blob).unwrap().fingerprint;
3131        assert_eq!(snap.account, format!("acct:{fingerprint}"));
3132        assert!(!snap.account.contains("KEYRING"), "{}", snap.account);
3133
3134        // What was cached is attributed to the remote path too.
3135        let cached = parse_cache_at(
3136            &cache
3137                .fresh_payload(Duration::from_secs(60))
3138                .unwrap()
3139                .unwrap(),
3140            None,
3141            now(),
3142        )
3143        .unwrap();
3144        assert_eq!(cached.source, AntigravitySource::Remote);
3145        assert_eq!(cached.account, snap.account);
3146    }
3147
3148    /// `agy` exposes the local RPC port but, unlike the desktop products, does
3149    /// not expose the CSRF token needed to use it. That one precise response is
3150    /// equivalent to having no usable local source, so the saved session may
3151    /// answer without weakening the normal signed-out-server rule below.
3152    #[tokio::test]
3153    async fn agys_missing_csrf_response_uses_the_saved_session() {
3154        let mut server = mockito::Server::new_async().await;
3155        let eps = endpoints(&server);
3156        let root = server
3157            .mock("GET", "/")
3158            .with_status(404)
3159            .expect(1)
3160            .create_async()
3161            .await;
3162        let status_path = format!("/{STATUS_RPC}");
3163        let status = server
3164            .mock("POST", status_path.as_str())
3165            .with_status(401)
3166            .with_body(r#"{"code":"unauthenticated","message":"missing CSRF token"}"#)
3167            .expect(1)
3168            .create_async()
3169            .await;
3170        let quota = quota_mock(&mut server, "KEYRING-AT")
3171            .expect(1)
3172            .create_async()
3173            .await;
3174        let plan = server
3175            .mock("POST", "/daily/plan")
3176            .match_header("authorization", "Bearer KEYRING-AT")
3177            .with_status(200)
3178            .with_body(r#"{"currentTier":{"name":"google_ai_pro"}}"#)
3179            .expect(1)
3180            .create_async()
3181            .await;
3182        let blob = keyring_blob(VALID, true);
3183        let (_td, cache) = fixture();
3184
3185        let outcome = run(
3186            &cache,
3187            RemoteOverride {
3188                credential: SavedCredential::Blob(&blob),
3189                endpoints: Some(&eps),
3190                local_bases: Some(vec![server.url().into()]),
3191                ..Default::default()
3192            },
3193            Duration::ZERO,
3194        )
3195        .await
3196        .expect("saved session bypasses agy's unusable local RPC");
3197
3198        root.assert_async().await;
3199        status.assert_async().await;
3200        quota.assert_async().await;
3201        plan.assert_async().await;
3202        assert_eq!(outcome.snapshot.source, AntigravitySource::Remote);
3203    }
3204
3205    /// `agy` 1.3.1 prefixes the historical message with `unauthenticated: `.
3206    /// That still means the local RPC is unusable, so the saved session must
3207    /// answer the same way as the unprefixed wording above.
3208    #[tokio::test]
3209    async fn agys_prefixed_missing_csrf_response_uses_the_saved_session() {
3210        let mut server = mockito::Server::new_async().await;
3211        let eps = endpoints(&server);
3212        let root = server
3213            .mock("GET", "/")
3214            .with_status(404)
3215            .expect(1)
3216            .create_async()
3217            .await;
3218        let status_path = format!("/{STATUS_RPC}");
3219        let status = server
3220            .mock("POST", status_path.as_str())
3221            .with_status(401)
3222            .with_body(
3223                r#"{"code":"unauthenticated","message":"unauthenticated: missing CSRF token"}"#,
3224            )
3225            .expect(1)
3226            .create_async()
3227            .await;
3228        let quota = quota_mock(&mut server, "KEYRING-AT")
3229            .expect(1)
3230            .create_async()
3231            .await;
3232        let plan = server
3233            .mock("POST", "/daily/plan")
3234            .match_header("authorization", "Bearer KEYRING-AT")
3235            .with_status(200)
3236            .with_body(r#"{"currentTier":{"name":"google_ai_pro"}}"#)
3237            .expect(1)
3238            .create_async()
3239            .await;
3240        let blob = keyring_blob(VALID, true);
3241        let (_td, cache) = fixture();
3242
3243        let outcome = run(
3244            &cache,
3245            RemoteOverride {
3246                credential: SavedCredential::Blob(&blob),
3247                endpoints: Some(&eps),
3248                local_bases: Some(vec![server.url().into()]),
3249                ..Default::default()
3250            },
3251            Duration::ZERO,
3252        )
3253        .await
3254        .expect("saved session bypasses agy 1.3.1's prefixed missing-CSRF RPC");
3255
3256        root.assert_async().await;
3257        status.assert_async().await;
3258        quota.assert_async().await;
3259        plan.assert_async().await;
3260        assert_eq!(outcome.snapshot.source, AntigravitySource::Remote);
3261    }
3262
3263    #[tokio::test]
3264    async fn agys_missing_csrf_without_a_saved_session_explains_both_options() {
3265        let mut server = mockito::Server::new_async().await;
3266        let eps = endpoints(&server);
3267        let root = server
3268            .mock("GET", "/")
3269            .with_status(404)
3270            .expect(1)
3271            .create_async()
3272            .await;
3273        let status_path = format!("/{STATUS_RPC}");
3274        let status = server
3275            .mock("POST", status_path.as_str())
3276            .with_status(401)
3277            .with_body(r#"{"code":"unauthenticated","message":"missing CSRF token"}"#)
3278            .expect(1)
3279            .create_async()
3280            .await;
3281        let quota = quota_mock(&mut server, "KEYRING-AT")
3282            .expect(0)
3283            .create_async()
3284            .await;
3285        let (_td, cache) = fixture();
3286
3287        let error = run(
3288            &cache,
3289            RemoteOverride {
3290                credential: SavedCredential::Absent,
3291                endpoints: Some(&eps),
3292                local_bases: Some(vec![server.url().into()]),
3293                ..Default::default()
3294            },
3295            Duration::ZERO,
3296        )
3297        .await
3298        .expect_err("neither the local RPC nor a saved session is usable");
3299
3300        root.assert_async().await;
3301        status.assert_async().await;
3302        quota.assert_async().await;
3303        let message = error.to_string();
3304        assert!(message.contains("requires a CSRF token"), "{message}");
3305        assert!(message.contains("saved Google session"), "{message}");
3306    }
3307
3308    /// Both ports belong to the same `agy` process: once the RPC listener
3309    /// reports the missing-CSRF verdict, the companion TLS listener must never
3310    /// be probed — the cleartext request is exactly what makes Go log the
3311    /// handshake error.
3312    #[tokio::test]
3313    async fn agys_missing_csrf_aborts_further_candidate_probes_to_spare_tls_listeners() {
3314        let mut server1 = mockito::Server::new_async().await;
3315        let mut server2 = mockito::Server::new_async().await;
3316        let eps = endpoints(&server1);
3317        let root1 = server1
3318            .mock("GET", "/")
3319            .with_status(404)
3320            .expect(1)
3321            .create_async()
3322            .await;
3323        let status_path = format!("/{STATUS_RPC}");
3324        let status1 = server1
3325            .mock("POST", status_path.as_str())
3326            .with_status(401)
3327            .with_body(r#"{"code":"unauthenticated","message":"missing CSRF token"}"#)
3328            .expect(1)
3329            .create_async()
3330            .await;
3331
3332        // server2 is the same process's other listener: must NEVER be probed.
3333        let root2 = server2.mock("GET", "/").expect(0).create_async().await;
3334        let status2 = server2
3335            .mock("POST", status_path.as_str())
3336            .expect(0)
3337            .create_async()
3338            .await;
3339
3340        let (_td, cache) = fixture();
3341        let error = run(
3342            &cache,
3343            RemoteOverride {
3344                credential: SavedCredential::Absent,
3345                endpoints: Some(&eps),
3346                local_bases: Some(vec![
3347                    Candidate {
3348                        base: server1.url(),
3349                        pid: Some(42),
3350                        ide: false,
3351                    },
3352                    Candidate {
3353                        base: server2.url(),
3354                        pid: Some(42),
3355                        ide: false,
3356                    },
3357                ]),
3358                ..Default::default()
3359            },
3360            Duration::ZERO,
3361        )
3362        .await
3363        .expect_err("aborted probe returns agy missing csrf diagnosis");
3364
3365        root1.assert_async().await;
3366        status1.assert_async().await;
3367        root2.assert_async().await;
3368        status2.assert_async().await;
3369        let message = error.to_string();
3370        assert!(message.contains("requires a CSRF token"), "{message}");
3371    }
3372
3373    /// `agy` and a signed-in desktop product can run side by side, and the
3374    /// candidates reach `open_session` flattened rank by rank — the port after
3375    /// `agy`'s can be the other product's RPC listener, not `agy`'s TLS one.
3376    /// The missing-CSRF verdict covers only `agy`'s own remaining ports;
3377    /// skipping the whole list would sacrifice the usable session to spare
3378    /// one listener.
3379    #[tokio::test]
3380    async fn agys_missing_csrf_does_not_skip_other_products_servers() {
3381        let mut agy = mockito::Server::new_async().await;
3382        let mut ide = mockito::Server::new_async().await;
3383        let eps = endpoints(&agy);
3384        let agy_root = agy
3385            .mock("GET", "/")
3386            .with_status(404)
3387            .expect(1)
3388            .create_async()
3389            .await;
3390        let status_path = format!("/{STATUS_RPC}");
3391        let agy_status = agy
3392            .mock("POST", status_path.as_str())
3393            .with_status(401)
3394            .with_body(r#"{"code":"unauthenticated","message":"missing CSRF token"}"#)
3395            .expect(1)
3396            .create_async()
3397            .await;
3398
3399        let ide_root = ide
3400            .mock("GET", "/")
3401            .with_status(404)
3402            .expect(1)
3403            .create_async()
3404            .await;
3405        let ide_status = ide
3406            .mock("POST", status_path.as_str())
3407            .with_status(200)
3408            .with_body(r#"{"userStatus":{"userTier":{"name":"Pro"},"email":"u@example.com"}}"#)
3409            .expect(1)
3410            .create_async()
3411            .await;
3412        let quota_path = format!("/{QUOTA_RPC}");
3413        let ide_quota = ide
3414            .mock("POST", quota_path.as_str())
3415            .with_status(200)
3416            .with_body(QUOTA_JSON)
3417            .expect(1)
3418            .create_async()
3419            .await;
3420
3421        let (_td, cache) = fixture();
3422        let outcome = run(
3423            &cache,
3424            RemoteOverride {
3425                credential: SavedCredential::Absent,
3426                endpoints: Some(&eps),
3427                local_bases: Some(vec![
3428                    Candidate {
3429                        base: agy.url(),
3430                        pid: Some(42),
3431                        ide: false,
3432                    },
3433                    Candidate {
3434                        base: ide.url(),
3435                        pid: Some(7),
3436                        ide: false,
3437                    },
3438                ]),
3439                ..Default::default()
3440            },
3441            Duration::ZERO,
3442        )
3443        .await
3444        .expect("the other product's server still answers");
3445
3446        agy_root.assert_async().await;
3447        agy_status.assert_async().await;
3448        ide_root.assert_async().await;
3449        ide_status.assert_async().await;
3450        ide_quota.assert_async().await;
3451        assert_eq!(outcome.snapshot.source, AntigravitySource::Local);
3452        assert_eq!(outcome.snapshot.plan, "Pro");
3453    }
3454
3455    /// The refreshed token is persisted under the session's fingerprint, so
3456    /// the next poll spends no round-trip on the same refresh.
3457    #[tokio::test]
3458    async fn an_expired_keyring_token_is_refreshed_once_and_the_refresh_is_reused() {
3459        let mut server = mockito::Server::new_async().await;
3460        let eps = endpoints(&server);
3461        let _quota = quota_mock(&mut server, "NEW-AT")
3462            .expect(2)
3463            .create_async()
3464            .await;
3465        let token = token_mock(&mut server).expect(1).create_async().await;
3466        let blob = keyring_blob(EXPIRED, true);
3467        let (_td, cache) = fixture();
3468
3469        let first = run(&cache, remote(&blob, &eps), Duration::ZERO)
3470            .await
3471            .expect("refresh, then quota");
3472        assert_eq!(first.snapshot.source, AntigravitySource::Remote);
3473        token.assert_async().await;
3474
3475        let fingerprint = credential::parse_keyring_blob(&blob).unwrap().fingerprint;
3476        let persisted = cloud::read_persisted(&cloud::oauth_cache_path(&cache), &fingerprint)
3477            .expect("refreshed token persisted under the keyring session's fingerprint");
3478        assert_eq!(persisted.access_token, "NEW-AT");
3479
3480        // A zero TTL forces the network again; the token endpoint stays at one hit.
3481        run(&cache, remote(&blob, &eps), Duration::ZERO)
3482            .await
3483            .expect("persisted token reused");
3484        token.assert_async().await;
3485    }
3486
3487    /// The API rejecting a token that was not just minted is the token being
3488    /// stale, not the session: one refresh, one retry.
3489    #[tokio::test]
3490    async fn a_stale_token_the_api_rejects_gets_one_refresh_and_one_retry() {
3491        let mut server = mockito::Server::new_async().await;
3492        let eps = endpoints(&server);
3493        let rejected = server
3494            .mock("POST", "/daily/quota")
3495            .match_header("authorization", "Bearer KEYRING-AT")
3496            .with_status(401)
3497            .expect(1)
3498            .create_async()
3499            .await;
3500        let accepted = quota_mock(&mut server, "NEW-AT")
3501            .expect(1)
3502            .create_async()
3503            .await;
3504        let token = token_mock(&mut server).expect(1).create_async().await;
3505        let blob = keyring_blob(VALID, true);
3506        let (_td, cache) = fixture();
3507
3508        let outcome = run(&cache, remote(&blob, &eps), Duration::ZERO)
3509            .await
3510            .expect("retry with the refreshed token succeeds");
3511
3512        rejected.assert_async().await;
3513        accepted.assert_async().await;
3514        token.assert_async().await;
3515        assert_eq!(outcome.snapshot.source, AntigravitySource::Remote);
3516    }
3517
3518    /// A refresh Google refuses is the session being gone. The error is
3519    /// actionable and carries no token material.
3520    #[tokio::test]
3521    async fn a_refused_refresh_is_a_credentials_error_without_the_token() {
3522        let mut server = mockito::Server::new_async().await;
3523        let eps = endpoints(&server);
3524        let _token = server
3525            .mock("POST", "/token")
3526            .with_status(400)
3527            .with_body(r#"{"error":"invalid_grant"}"#)
3528            .create_async()
3529            .await;
3530        let blob = keyring_blob(EXPIRED, true);
3531        let (_td, cache) = fixture();
3532
3533        let err = run(&cache, remote(&blob, &eps), Duration::ZERO)
3534            .await
3535            .expect_err("no cache to fall back on");
3536
3537        assert!(matches!(err, AppError::Credentials(_)), "{err}");
3538        let rendered = err.to_string();
3539        assert!(!rendered.contains("KEYRING-RT"), "{rendered}");
3540        assert!(!rendered.contains("KEYRING-AT"), "{rendered}");
3541    }
3542
3543    /// A `401` against a token minted a moment ago is Google's verdict on the
3544    /// session; refreshing again would only repeat it.
3545    #[tokio::test]
3546    async fn a_rejection_right_after_a_refresh_asks_to_sign_in_again() {
3547        let mut server = mockito::Server::new_async().await;
3548        let eps = endpoints(&server);
3549        let _quota = server
3550            .mock("POST", "/daily/quota")
3551            .with_status(401)
3552            .create_async()
3553            .await;
3554        let token = token_mock(&mut server).expect(1).create_async().await;
3555        let blob = keyring_blob(EXPIRED, true);
3556        let (_td, cache) = fixture();
3557
3558        let err = run(&cache, remote(&blob, &eps), Duration::ZERO)
3559            .await
3560            .expect_err("rejected after refresh");
3561
3562        token.assert_async().await;
3563        assert!(matches!(err, AppError::Credentials(_)), "{err}");
3564        assert!(err.to_string().contains("sign in again"), "{err}");
3565    }
3566
3567    /// #405: a plan without Antigravity quota is Google's verdict on the plan,
3568    /// not on the session. The friendly message must reach the user as-is —
3569    /// no refresh round-trip, no `agy` run, no "sign in again" detour.
3570    #[cfg(unix)]
3571    #[tokio::test]
3572    async fn a_subscription_403_surfaces_the_plan_message_without_a_refresh_or_agy_run() {
3573        let mut server = mockito::Server::new_async().await;
3574        let eps = endpoints(&server);
3575        let quota = server
3576            .mock("POST", "/daily/quota")
3577            .match_header("authorization", "Bearer KEYRING-AT")
3578            .with_status(403)
3579            .with_body(
3580                r#"{"error":{"code":403,"status":"PERMISSION_DENIED","details":
3581                     [{"reason":"SUBSCRIPTION_REQUIRED","domain":"cloud.google.com"}]}}"#,
3582            )
3583            .expect(1)
3584            .create_async()
3585            .await;
3586        let prod = server
3587            .mock("POST", "/prod/quota")
3588            .expect(0)
3589            .create_async()
3590            .await;
3591        let token = token_mock(&mut server).expect(0).create_async().await;
3592        let (td, cache) = fixture();
3593        let file = td.path().join("credential");
3594        std::fs::write(&file, keyring_blob(VALID, true)).unwrap();
3595        let runs = td.path().join("runs");
3596        let agy = fake_agy(td.path(), &runs, &file, &keyring_blob(VALID, true));
3597
3598        let err = run_without_oauth(
3599            &cache,
3600            RemoteOverride {
3601                credential: SavedCredential::File(&file),
3602                endpoints: Some(&eps),
3603                local_bases: Some(vec![]),
3604                agy: super::super::agy::AgyCommand::At(agy),
3605                ..Default::default()
3606            },
3607        )
3608        .await
3609        .expect_err("a plan without quota is an error, not a snapshot");
3610
3611        quota.assert_async().await;
3612        prod.assert_async().await;
3613        token.assert_async().await;
3614        match err {
3615            AppError::Http { status, body } => {
3616                assert_eq!(status, 403);
3617                assert!(body.contains("paid subscription"), "{body}");
3618            }
3619            other => panic!("expected Http, got {other:?}"),
3620        }
3621        assert!(
3622            !runs.exists(),
3623            "a subscription verdict must not spawn `agy`"
3624        );
3625    }
3626
3627    /// #405: the pass-through is exactly the subscription verdict — any other
3628    /// 403 is still a rejected token: one refresh, one retry, then the session
3629    /// verdict.
3630    #[tokio::test]
3631    async fn a_non_subscription_403_keeps_the_refresh_and_retry_behavior() {
3632        let mut server = mockito::Server::new_async().await;
3633        let eps = endpoints(&server);
3634        let rejected = server
3635            .mock("POST", "/daily/quota")
3636            .match_header("authorization", "Bearer KEYRING-AT")
3637            .with_status(403)
3638            .with_body(r#"{"error":{"message":"acl detail"}}"#)
3639            .expect(1)
3640            .create_async()
3641            .await;
3642        let retried = server
3643            .mock("POST", "/daily/quota")
3644            .match_header("authorization", "Bearer NEW-AT")
3645            .with_status(403)
3646            .with_body(r#"{"error":{"message":"acl detail"}}"#)
3647            .expect(1)
3648            .create_async()
3649            .await;
3650        let token = token_mock(&mut server).expect(1).create_async().await;
3651        let blob = keyring_blob(VALID, true);
3652        let (_td, cache) = fixture();
3653
3654        let err = run(&cache, remote(&blob, &eps), Duration::ZERO)
3655            .await
3656            .expect_err("still a rejected session");
3657
3658        rejected.assert_async().await;
3659        retried.assert_async().await;
3660        token.assert_async().await;
3661        assert!(matches!(err, AppError::Credentials(_)), "{err}");
3662        assert!(err.to_string().contains("sign in again"), "{err}");
3663    }
3664
3665    /// `run` with no OAuth client configured, the case `agy` exists for.
3666    async fn run_without_oauth(cache: &Cache, remote: RemoteOverride<'_>) -> Result<FetchOutcome> {
3667        fetch_snapshot_at(
3668            &reqwest::Client::new(),
3669            cache,
3670            Duration::ZERO,
3671            None,
3672            remote,
3673            now(),
3674        )
3675        .await
3676    }
3677
3678    /// An `agy` stand-in: appends to `runs`, then writes `blob` into `file`
3679    /// the way the real CLI rewrites the keyring entry.
3680    #[cfg(unix)]
3681    fn fake_agy(
3682        dir: &std::path::Path,
3683        runs: &std::path::Path,
3684        file: &std::path::Path,
3685        blob: &str,
3686    ) -> std::path::PathBuf {
3687        use std::os::unix::fs::PermissionsExt;
3688        let path = dir.join("agy");
3689        std::fs::write(
3690            &path,
3691            format!(
3692                "#!/bin/sh\necho \"$@\" >> '{}'\nprintf '%s' '{}' > '{}'\n",
3693                runs.display(),
3694                blob,
3695                file.display()
3696            ),
3697        )
3698        .unwrap();
3699        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
3700        path
3701    }
3702
3703    /// The whole point: with no OAuth client and an expired session, running
3704    /// `agy` renews it and the quota comes back on the same poll.
3705    #[cfg(unix)]
3706    #[tokio::test]
3707    async fn an_expired_session_is_renewed_by_running_agy() {
3708        let mut server = mockito::Server::new_async().await;
3709        let eps = endpoints(&server);
3710        let quota = quota_mock(&mut server, "KEYRING-AT").create_async().await;
3711        let _plan = server
3712            .mock("POST", "/daily/plan")
3713            .with_status(200)
3714            .with_body(r#"{"currentTier":{"name":"Pro"}}"#)
3715            .create_async()
3716            .await;
3717        let (td, cache) = fixture();
3718        let file = td.path().join("credential");
3719        std::fs::write(&file, keyring_blob(EXPIRED, true)).unwrap();
3720        let runs = td.path().join("runs");
3721        let agy = fake_agy(td.path(), &runs, &file, &keyring_blob(VALID, true));
3722
3723        let out = run_without_oauth(
3724            &cache,
3725            RemoteOverride {
3726                credential: SavedCredential::File(&file),
3727                endpoints: Some(&eps),
3728                local_bases: Some(vec![]),
3729                agy: super::super::agy::AgyCommand::At(agy),
3730                ..Default::default()
3731            },
3732        )
3733        .await
3734        .expect("agy renewed the session");
3735
3736        quota.assert_async().await;
3737        assert_eq!(out.snapshot.source, AntigravitySource::Remote);
3738        assert_eq!(std::fs::read_to_string(runs).unwrap().trim(), "models");
3739    }
3740
3741    /// `agy` that leaves the session as it was changes nothing: the original
3742    /// error stands, and the failed attempt is not repeated on the next poll.
3743    #[cfg(unix)]
3744    #[tokio::test]
3745    async fn agy_that_does_not_renew_keeps_the_original_error_and_runs_once() {
3746        let server = mockito::Server::new_async().await;
3747        let eps = endpoints(&server);
3748        let (td, cache) = fixture();
3749        let file = td.path().join("credential");
3750        std::fs::write(&file, keyring_blob(EXPIRED, true)).unwrap();
3751        let runs = td.path().join("runs");
3752        let agy = fake_agy(td.path(), &runs, &file, &keyring_blob(EXPIRED, true));
3753        let remote = || RemoteOverride {
3754            credential: SavedCredential::File(&file),
3755            endpoints: Some(&eps),
3756            local_bases: Some(vec![]),
3757            agy: super::super::agy::AgyCommand::At(agy.clone()),
3758            ..Default::default()
3759        };
3760
3761        for _ in 0..2 {
3762            let err = run_without_oauth(&cache, remote())
3763                .await
3764                .expect_err("still expired");
3765            assert!(err.to_string().contains("no OAuth client"), "{err}");
3766        }
3767        assert_eq!(std::fs::read_to_string(runs).unwrap().lines().count(), 1);
3768    }
3769
3770    /// The default seam is off: a test that never mentions `agy` cannot run
3771    /// the real CLI, even with an expired session and no OAuth client.
3772    #[tokio::test]
3773    async fn without_an_agy_command_an_expired_session_is_left_alone() {
3774        let server = mockito::Server::new_async().await;
3775        let eps = endpoints(&server);
3776        let blob = keyring_blob(EXPIRED, true);
3777        let (_td, cache) = fixture();
3778
3779        let err = run_without_oauth(&cache, remote(&blob, &eps))
3780            .await
3781            .expect_err("nothing renews it");
3782        assert!(err.to_string().contains("no OAuth client"), "{err}");
3783    }
3784
3785    /// A session saved without a refresh token cannot be renewed here.
3786    #[tokio::test]
3787    async fn an_expired_session_without_a_refresh_token_asks_to_sign_in_again() {
3788        let mut server = mockito::Server::new_async().await;
3789        let eps = endpoints(&server);
3790        let token = token_mock(&mut server).expect(0).create_async().await;
3791        let blob = keyring_blob(EXPIRED, false);
3792        let (_td, cache) = fixture();
3793
3794        let err = run(&cache, remote(&blob, &eps), Duration::ZERO)
3795            .await
3796            .expect_err("nothing to refresh with");
3797
3798        token.assert_async().await;
3799        assert!(matches!(err, AppError::Credentials(_)), "{err}");
3800        assert!(err.to_string().contains("sign in again"), "{err}");
3801    }
3802
3803    /// Nothing running and nothing saved: the local diagnosis stands, plus
3804    /// the one thing the user can now do about it.
3805    #[tokio::test]
3806    async fn no_saved_session_extends_the_no_local_server_error() {
3807        let mut server = mockito::Server::new_async().await;
3808        let eps = endpoints(&server);
3809        let quota = quota_mock(&mut server, "KEYRING-AT")
3810            .expect(0)
3811            .create_async()
3812            .await;
3813        let (_td, cache) = fixture();
3814
3815        let err = run(
3816            &cache,
3817            RemoteOverride {
3818                credential: SavedCredential::Absent,
3819                endpoints: Some(&eps),
3820                local_bases: Some(vec![]),
3821                ..Default::default()
3822            },
3823            Duration::ZERO,
3824        )
3825        .await
3826        .expect_err("no source at all");
3827
3828        quota.assert_async().await;
3829        assert!(matches!(err, AppError::Credentials(_)), "{err}");
3830        let rendered = err.to_string();
3831        assert!(rendered.contains("no local server found"), "{rendered}");
3832        assert!(rendered.contains("saved Google session"), "{rendered}");
3833    }
3834
3835    /// A local server that is up but signed out is its own diagnosis; the
3836    /// saved session must not paper over it.
3837    #[tokio::test]
3838    async fn a_signed_out_local_server_is_reported_rather_than_bypassed() {
3839        let mut server = mockito::Server::new_async().await;
3840        let eps = endpoints(&server);
3841        let status_path = format!("/{STATUS_RPC}");
3842        let _status = server
3843            .mock("POST", status_path.as_str())
3844            .with_status(401)
3845            .create_async()
3846            .await;
3847        let quota = quota_mock(&mut server, "KEYRING-AT")
3848            .expect(0)
3849            .create_async()
3850            .await;
3851        let blob = keyring_blob(VALID, true);
3852        let (_td, cache) = fixture();
3853
3854        let err = run(
3855            &cache,
3856            RemoteOverride {
3857                credential: SavedCredential::Blob(&blob),
3858                endpoints: Some(&eps),
3859                local_bases: Some(vec![server.url().into()]),
3860                ..Default::default()
3861            },
3862            Duration::ZERO,
3863        )
3864        .await
3865        .expect_err("signed-out local server");
3866
3867        quota.assert_async().await;
3868        assert!(matches!(err, AppError::Http { status: 401, .. }), "{err}");
3869    }
3870
3871    /// #405: the reporter's exact setup. The IDE is open but its language
3872    /// server is signed out (a 403 that would outrank `agy` and mask the
3873    /// saved session); `ide_language_server = false` takes the IDE out of
3874    /// discovery, so `agy`'s missing-CSRF answer unlocks the saved session —
3875    /// which is what closing the IDE did by hand.
3876    #[tokio::test]
3877    async fn a_signed_out_ide_language_server_is_skipped_so_the_saved_session_answers() {
3878        let mut ide = mockito::Server::new_async().await;
3879        let mut agy = mockito::Server::new_async().await;
3880        let eps = endpoints(&agy);
3881        // The IDE must never be probed: its 403 is what masked `agy`.
3882        let ide_root = ide.mock("GET", "/").expect(0).create_async().await;
3883        let status_path = format!("/{STATUS_RPC}");
3884        let ide_status = ide
3885            .mock("POST", status_path.as_str())
3886            .with_status(403)
3887            .expect(0)
3888            .create_async()
3889            .await;
3890        // `agy`: no CSRF page, then its precise missing-CSRF verdict.
3891        let agy_root = agy
3892            .mock("GET", "/")
3893            .with_status(404)
3894            .expect(1)
3895            .create_async()
3896            .await;
3897        let agy_status = agy
3898            .mock("POST", status_path.as_str())
3899            .with_status(401)
3900            .with_body(r#"{"code":"unauthenticated","message":"missing CSRF token"}"#)
3901            .expect(1)
3902            .create_async()
3903            .await;
3904        let quota = quota_mock(&mut agy, "KEYRING-AT")
3905            .expect(1)
3906            .create_async()
3907            .await;
3908        let plan = agy
3909            .mock("POST", "/daily/plan")
3910            .match_header("authorization", "Bearer KEYRING-AT")
3911            .with_status(200)
3912            .with_body(r#"{"currentTier":{"name":"google_ai_pro"}}"#)
3913            .expect(1)
3914            .create_async()
3915            .await;
3916        let blob = keyring_blob(VALID, true);
3917        let (_td, cache) = fixture();
3918
3919        let outcome = run(
3920            &cache,
3921            RemoteOverride {
3922                credential: SavedCredential::Blob(&blob),
3923                endpoints: Some(&eps),
3924                local_bases: Some(vec![
3925                    Candidate {
3926                        base: ide.url(),
3927                        pid: Some(7),
3928                        ide: true,
3929                    },
3930                    Candidate {
3931                        base: agy.url(),
3932                        pid: Some(42),
3933                        ide: false,
3934                    },
3935                ]),
3936                ide_language_server: false,
3937                ..Default::default()
3938            },
3939            Duration::ZERO,
3940        )
3941        .await
3942        .expect("with the IDE skipped, agy's missing-CSRF answer unlocks the saved session");
3943
3944        ide_root.assert_async().await;
3945        ide_status.assert_async().await;
3946        agy_root.assert_async().await;
3947        agy_status.assert_async().await;
3948        quota.assert_async().await;
3949        plan.assert_async().await;
3950        assert_eq!(outcome.snapshot.source, AntigravitySource::Remote);
3951        assert_eq!(outcome.snapshot.plan, "Pro");
3952    }
3953
3954    /// With the IDE skipped and nothing else running, the machine behaves as
3955    /// if the IDE were closed: "no local server" sends the fetch down the
3956    /// saved-session path, never a generic "nothing answered".
3957    #[tokio::test]
3958    async fn skipping_the_only_ide_candidate_falls_through_to_the_saved_session() {
3959        let mut ide = mockito::Server::new_async().await;
3960        let eps = endpoints(&ide);
3961        let ide_root = ide.mock("GET", "/").expect(0).create_async().await;
3962        let status_path = format!("/{STATUS_RPC}");
3963        let ide_status = ide
3964            .mock("POST", status_path.as_str())
3965            .expect(0)
3966            .create_async()
3967            .await;
3968        let quota = quota_mock(&mut ide, "KEYRING-AT")
3969            .expect(1)
3970            .create_async()
3971            .await;
3972        let plan = ide
3973            .mock("POST", "/daily/plan")
3974            .with_status(200)
3975            .with_body(r#"{"currentTier":{"name":"Pro"}}"#)
3976            .expect(1)
3977            .create_async()
3978            .await;
3979        let blob = keyring_blob(VALID, true);
3980        let (_td, cache) = fixture();
3981
3982        let outcome = run(
3983            &cache,
3984            RemoteOverride {
3985                credential: SavedCredential::Blob(&blob),
3986                endpoints: Some(&eps),
3987                local_bases: Some(vec![Candidate {
3988                    base: ide.url(),
3989                    pid: Some(7),
3990                    ide: true,
3991                }]),
3992                ide_language_server: false,
3993                ..Default::default()
3994            },
3995            Duration::ZERO,
3996        )
3997        .await
3998        .expect("an IDE-only machine with the IDE skipped uses the saved session");
3999
4000        ide_root.assert_async().await;
4001        ide_status.assert_async().await;
4002        quota.assert_async().await;
4003        plan.assert_async().await;
4004        assert_eq!(outcome.snapshot.source, AntigravitySource::Remote);
4005    }
4006
4007    /// The remote path falls back exactly like the local one: the last good
4008    /// payload is served stale, with the failure attached.
4009    #[tokio::test]
4010    async fn a_cached_remote_snapshot_is_served_when_the_api_fails() {
4011        let mut server = mockito::Server::new_async().await;
4012        let eps = endpoints(&server);
4013        for path in ["/daily/quota", "/prod/quota"] {
4014            server
4015                .mock("POST", path)
4016                .with_status(500)
4017                .with_body("boom")
4018                .create_async()
4019                .await;
4020        }
4021        let blob = keyring_blob(VALID, true);
4022        let (_td, cache) = fixture();
4023        cache.ensure_dir().unwrap();
4024        let mut earlier = parsed();
4025        earlier.source = AntigravitySource::Remote;
4026        earlier.account = "acct:earlier".into();
4027        cache
4028            .write_payload(&serde_json::to_vec(&snap_to_json(&earlier)).unwrap())
4029            .unwrap();
4030
4031        let outcome = run(&cache, remote(&blob, &eps), Duration::ZERO)
4032            .await
4033            .expect("stale cache stands in");
4034
4035        assert!(outcome.stale);
4036        assert_eq!(outcome.snapshot, earlier);
4037        assert!(
4038            matches!(outcome.last_error, Some((500, _))),
4039            "{:?}",
4040            outcome.last_error
4041        );
4042    }
4043
4044    #[test]
4045    fn source_round_trips_through_the_cache_and_defaults_to_local() {
4046        let mut snap = parsed();
4047        snap.source = AntigravitySource::Remote;
4048        let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
4049        assert_eq!(
4050            parse_cache_at(&bytes, None, now()).unwrap().source,
4051            AntigravitySource::Remote
4052        );
4053
4054        snap.source = AntigravitySource::Statusline;
4055        let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
4056        assert_eq!(
4057            parse_cache_at(&bytes, None, now()).unwrap().source,
4058            AntigravitySource::Statusline
4059        );
4060
4061        // A payload from before the field existed is a local one.
4062        let mut legacy = snap_to_json(&snap);
4063        legacy.as_object_mut().unwrap().remove("source");
4064        let legacy = serde_json::to_vec(&legacy).unwrap();
4065        assert_eq!(
4066            parse_cache_at(&legacy, None, now()).unwrap().source,
4067            AntigravitySource::Local
4068        );
4069    }
4070}