diff --git a/CHANGELOG.md b/CHANGELOG.md index f1d831c0a..b0df7b737 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 instead, since the browser would send the same requests. `GFLOW_CLI_WIRE_READS=0` turns the direct read off; it is off by default on macOS and while any proxy is configured (#1229). +- **`gflow doctor` and the MCP `gflow_auth_status` tool report whether that direct read works.** + Doctor's new `auth.wire_read` check (default profile) and the tool's new `wire` field (on every session result) give + `accepted` with Flow's build label, or the reason credit reads use the browser + (`signed_out_shell`, `store_unreadable`, `disabled`, `proxy_configured`, `throttled`, …). + Each sends one GET and one POST to flow.google.com unless the direct read is off. The check + is informational: it never changes doctor's exit code. When Flow is throttling the account, + `gflow_auth_status` skips its browser probe and returns `status: throttled` with + `retry_after` (#1229). + ### Fixed - **No gflow process logs an httpx request URL any more.** Starting the MCP server set Python diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index c05ad38c3..c38344722 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -515,8 +515,9 @@ per-command `--proxy` flags (the setting covers one-off runs: **What:** Whether `gflow credits user`, `gflow credits list` and the MCP `gflow_get_credits` tool first read the balance **directly** from flow.google.com, with no browser ([#1229](https://github.com/ffroliva/gflow-cli/issues/1229)). The direct read takes the profile's saved cookies from its own cookie file, loads the app page once and asks for the balance once. If it fails (Flow does not accept the cookies, Chrome is open on the profile and holds the cookie file, a network error, an unexpected reply), gflow reads the balance in the profile's browser instead, as before. One exception: when Flow throttles the account (HTTP 429 or 503), the command reports that error (exit code as for any rate limit or network failure) rather than loading the page, because the page would send the same requests to the throttled account; further reads of that profile report it again, sending nothing, until the `Retry-After` window has passed (capped at 60 s; 60 s when Flow names none). **Values:** `1` / `true` — try the direct read first. `0` / `false` — always use the browser. **Default:** `true` on Windows and Linux; `false` on macOS, where reading Chrome's cookies can raise a Keychain prompt. -**Safety:** the direct read never writes to the profile's cookies, ignores any cookie Flow sends back, and follows no redirect. A profile Flow answers "signed out" (or HTTP 401) is not tried again for the rest of the process; other profiles are unaffected. +**Safety:** the direct read never writes to the profile's cookies, ignores any cookie Flow sends back, and follows no redirect. A profile Flow answers "signed out" (or HTTP 401) is not tried again by credit reads in that process (a browser read succeeding does not change the saved cookies) until `--refresh` renews it, or `gflow doctor` / `gflow_auth_status` asks Flow again and is accepted; other profiles are unaffected. **Proxy:** while any proxy is configured — `GFLOW_CLI_BROWSER_PROXY`, an `HTTPS_PROXY`/`ALL_PROXY`-style environment variable, or the operating system's proxy setting — the direct read is skipped (reason `proxy_configured`) and the browser reads the balance, so the profile's cookies never leave by a different route than its browser's. The direct read never takes a proxy from the environment. +**Health:** `gflow doctor` (check `auth.wire_read`, the default profile) and the MCP `gflow_auth_status` tool (its `wire` field) report whether the direct read works: `status=accepted` with Flow's build label, or the reason it falls back (`signed_out_shell`, `store_unreadable`, `disabled`, `proxy_configured`, `throttled`, …). Each sends one GET and one POST to flow.google.com, under every rule above, asking afresh even for a profile already marked signed out; with the setting off, any proxy configured, or inside a throttle window, neither sends anything. ### `GFLOW_CLI_AGENT_CONFIRM` diff --git a/docs/MCP.md b/docs/MCP.md index 41f65966f..0a804f7da 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -126,7 +126,7 @@ The server registers three protocol surfaces: > because gflow keeps task rows with no expiry. -* `gflow_auth_status(profile)`: Credit-free, non-interactive Flow session probe (#497) — wraps the same fail-closed `verify_flow_profile` check as `gflow auth status`. Returns `{"status": "authenticated", "profile", "user_email"}` or a problem-details error with a `remediation_hint`; a `verification_error` outcome (HTTP 503, `retryable: true`) means a network/endpoint problem, which re-login does not fix, and a `profile_marker_missing` outcome (HTTP 409, `retryable: false`, #796) means the profile lost its `.gflow_browser_strategy` marker so its cookies cannot be read — a retry does not help, and neither does a plain `gflow auth login`; re-run `gflow auth login --browser chrome` for that profile. A `cookie_store_unreadable` outcome (HTTP 409, `retryable: false`, #796) means the profile's Chrome cookie database is corrupt or unreadable — a local file fault, not the network; close any Chrome window using that profile, then re-run `gflow auth login` for it. Any other unauthenticated outcome is a dead session (HTTP 401). Call it before a generation tool to fail fast on dead auth, before a long blocking generation call. Login/logout remain CLI-only (genuinely interactive). +* `gflow_auth_status(profile)`: Credit-free, non-interactive Flow session probe (#497) — wraps the same fail-closed `verify_flow_profile` check as `gflow auth status`. Returns `{"status": "authenticated", "profile", "user_email"}` or a problem-details error with a `remediation_hint`; a `verification_error` outcome (HTTP 503, `retryable: true`) means a network/endpoint problem, which re-login does not fix, and a `profile_marker_missing` outcome (HTTP 409, `retryable: false`, #796) means the profile lost its `.gflow_browser_strategy` marker so its cookies cannot be read — a retry does not help, and neither does a plain `gflow auth login`; re-run `gflow auth login --browser chrome` for that profile. A `cookie_store_unreadable` outcome (HTTP 409, `retryable: false`, #796) means the profile's Chrome cookie database is corrupt or unreadable — a local file fault, not the network; close any Chrome window using that profile, then re-run `gflow auth login` for it. Any other unauthenticated outcome is a dead session (HTTP 401). Call it before a generation tool to fail fast on dead auth, before a long blocking generation call. Every session result also carries `wire: {status, build}` ([#1229](https://github.com/ffroliva/gflow-cli/issues/1229)): the health of the direct, browser-free credits read, probed first with one GET and one POST to flow.google.com unless `GFLOW_CLI_WIRE_READS` is off. `status` is `accepted`, or the reason credit reads fall back to the browser (`signed_out_shell`, `http_401`, `store_unreadable`, `disabled`, `proxy_configured`, `throttled`, `no_chrome_version`, `network`, `redirect`, `http_400`, `http_error`, `unparseable`, `unexpected`); `build` is Flow's build label, else `null`. It never changes the session `status`, except `throttled`: Flow is rate-limiting the account, so the browser probe is skipped and the tool returns `status: "throttled"` with a retryable 429 `error` carrying `retry_after` seconds. `store_unreadable` means the profile's cookies could not be read (Chrome has it open, or it has no saved session). `gflow doctor` reports the same probe for the default profile as its `auth.wire_read` check (doctor itself has no MCP twin, #542). Login/logout remain CLI-only (genuinely interactive). * `gflow_get_credits(profile, all_profiles, refresh)`: Current Flow balance query; spends no credits. With `all_profiles=true`, inspects every saved profile sequentially and preserves successful balances plus `total_credits` when another profile fails. The balance is read from flow.google.com in the profile's browser: the number the app itself loads, which is the one Flow's account menu shows (#795). That is one browser launch per profile, and the tier fields (`sku`, `service_tier`, `user_paygate_tier`, `subscription_credits`) are always `null`. With `refresh=true`, a profile that lands on a sign-in page is renewed in a headless browser and read again: one more Chrome launch per such profile, no credits. This is the MCP twin of `gflow credits user|list --refresh`. It runs the same renewal as `gflow auth renew`, but only for a profile whose credits read lands on a sign-in page. `auth renew` itself has no MCP tool; see the exemption in `tests/mcp/test_cli_parity.py`. Without `refresh` the call is read-only; with it, an expired profile's saved cookies are rewritten. This tool spends no credits and returns no cookies, bearer tokens, or browser API keys. The balance funds Veo video generation; image generation uses separate per-model daily quotas. A page that loads but never fetches the balance is a `FlowAppError` (exit 31), never an auth error. * `gflow_character_list(project, profile)`: Lists a project's saved Flow CHARACTER entities with their **entity ids** — read-only, spends no credits, drives a browser session. This is how an agent discovers what it can attach: an `entity_id` goes to `reference_entities`, a `display_name` can be used as an `@Name` mention or goes to `reference_entity_names` at the id's position. On flow.google.com it cannot read the project yet and returns a `wire-format` error (the CLI's exit 7, [#875](https://github.com/ffroliva/gflow-cli/issues/875)): ask the user for the name. An empty list means the project genuinely has none. * `gflow_character_show(project, entity_id, name, profile)`: Shows one character by id or exact display name (exactly one selector required). An ambiguous name is refused rather than resolved arbitrarily — which is the reason to prefer the id. Read-only; drives a browser session. diff --git a/docs/USAGE.md b/docs/USAGE.md index 5da2c7ff3..4597819be 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -1732,7 +1732,9 @@ environment. Doctor **diagnoses, never heals**: nothing is migrated, repaired, or written — not even pending schema migrations. All database access is strictly read-only (SQLite may create transient `-wal`/`-shm` sidecar files during the read-only open; the database itself is never modified), so it is -safe to run against a live catalog at any time. +safe to run against a live catalog at any time. One check uses the network: +`auth.wire_read` sends one GET and one POST to flow.google.com for the default +profile, and sends nothing when `GFLOW_CLI_WIRE_READS=0`. ```text gflow doctor [--json] @@ -1742,7 +1744,7 @@ Options: (experimental; shape may change). ``` -Eleven checks run on every invocation: +Twelve checks run on every invocation: | Check id | Detects | Suggested remediation | |---|---|---| @@ -1757,6 +1759,7 @@ Eleven checks run on every invocation: | `env.browsers_missing` | Playwright Chromium is not installed | `playwright install chromium` | | `auth.files_present` | No auth profiles, or profiles without saved cookies | `gflow auth login` | | `env.browser_proxy` | Reports whether `GFLOW_CLI_BROWSER_PROXY` is set (`scheme://host:port`, never credentials); informational, never changes the exit code | Use the same proxy for `HTTPS_PROXY` and for signing in ([CONFIGURATION](CONFIGURATION.md#gflow_cli_browser_proxy)) | +| `auth.wire_read` | Whether the default profile's direct (browser-free) credits read works: `status=accepted build=` passes; any other status (`signed_out_shell`, `store_unreadable`, `disabled`, `proxy_configured`, `throttled`, `no_profile`, …) is info only and never changes the exit code, because credits then read through the browser. The one network check: one GET and one POST to flow.google.com, nothing with `GFLOW_CLI_WIRE_READS=0` | The finding's own hint (e.g. close Chrome on the profile, or `gflow auth login`) ([CONFIGURATION](CONFIGURATION.md#gflow_cli_wire_reads)) | **Severity model.** Every check reports `pass`, `info`, `warn`, or `fail`. `info` is worth knowing but not a defect — it never flips the overall status diff --git a/src/gflow_cli/api/flow_wire.py b/src/gflow_cli/api/flow_wire.py index f00fa6bbd..803c6b7c9 100644 --- a/src/gflow_cli/api/flow_wire.py +++ b/src/gflow_cli/api/flow_wire.py @@ -21,11 +21,12 @@ import re import secrets import sys +import threading import time from dataclasses import dataclass from enum import StrEnum from http.cookiejar import CookieJar, DefaultCookiePolicy -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from urllib.parse import urlencode from urllib.request import getproxies @@ -37,8 +38,8 @@ from gflow_cli.api.transports.batchexecute import parse_frames, rpc_errors from gflow_cli.auth.cookies import CookieStoreUnreadable, read_cookies_for, require_in_home from gflow_cli.browser_manager import profile_last_version -from gflow_cli.config import get_settings -from gflow_cli.errors import NetworkError, RateLimitError +from gflow_cli.config import Settings, get_settings +from gflow_cli.errors import NetworkError, RateLimitError, SecurityError if TYPE_CHECKING: from pathlib import Path @@ -51,6 +52,7 @@ _ROUTE = "credits_wire" _THROTTLED_HINT = "Flow is throttling this account; wait a minute, then read the balance again." _THROTTLE_DEFAULT_S = 60.0 +_BUILD = re.compile(r"boq_[A-Za-z0-9._-]{1,80}") # cfb2h is scraped from remote HTML _CONNECT_S = 3.0 _TOTAL_S = 5.0 _UA_PLATFORM = { @@ -92,10 +94,11 @@ class _Tokens: bl: str | None -# Per process, per profile: the shell's tokens, profiles Flow said are signed out, throttle -# windows (monotonic deadline, the status that opened it), and one lock each. +# Per process, per profile: the shell's tokens, profiles Flow said are signed out (with the +# reason it gave), throttle windows (monotonic deadline, the status that opened it), and one +# lock each. _tokens: dict[Path, _Tokens] = {} -_signed_out: set[Path] = set() +_signed_out: dict[Path, WireFallbackReason] = {} _throttled: dict[Path, tuple[float, int]] = {} _locks: dict[Path, asyncio.Lock] = {} _clock = time.monotonic @@ -142,8 +145,8 @@ def _throttled_error(status: int, retry_after: float) -> RateLimitError | Networ def _gate(profile_dir: Path) -> None: """Refuse a read this profile must not make: signed out, or inside a throttle window.""" - if profile_dir in _signed_out: - raise WireReadUnavailable(WireFallbackReason.DISABLED) + if (reason := _signed_out.get(profile_dir)) is not None: + raise WireReadUnavailable(reason) # never DISABLED: that one names the kill switch if (window := _throttled.get(profile_dir)) is not None: deadline, status = window if (remaining := deadline - _clock()) > 0: @@ -163,7 +166,7 @@ def _check(response: httpx.Response, profile_dir: Path) -> None: if 300 <= status < 400: raise WireReadUnavailable(WireFallbackReason.REDIRECT) if status == 401: - _signed_out.add(profile_dir) + _signed_out[profile_dir] = WireFallbackReason.HTTP_401 raise WireReadUnavailable(WireFallbackReason.HTTP_401) if status in (429, 503): window = parse_retry_after(response) or _THROTTLE_DEFAULT_S @@ -226,13 +229,45 @@ async def _shell_tokens( response = await _send(client, client.build_request("GET", f"{_ORIGIN}/", headers=nav)) _check(response, profile_dir) if not (at := _wiz(response.text, "SNlM0e")): - _signed_out.add(profile_dir) + _signed_out[profile_dir] = WireFallbackReason.SIGNED_OUT_SHELL raise WireReadUnavailable(WireFallbackReason.SIGNED_OUT_SHELL) tokens = _Tokens(at, _wiz(response.text, "FdrFJe"), _wiz(response.text, "cfb2h")) _tokens[profile_dir] = tokens return tokens +async def _read_cookies(profile_dir: Path) -> list[tuple[str, str]]: + """``read_cookies_for`` on a daemon thread, bounded by ``_TOTAL_S``. + + Not ``asyncio.to_thread``: ``asyncio.run`` (``gflow doctor``) joins the default + executor at shutdown, so a cookie store that never answers (a Linux keyring) would hold + the process open long after the read gave up. A daemon thread is abandoned instead. + """ + loop = asyncio.get_running_loop() + future: asyncio.Future[list[tuple[str, str]]] = loop.create_future() + + def settle(result: list[tuple[str, str]] | None, error: BaseException | None) -> None: + if future.done(): # the wait already gave up + return + if error is not None: + future.set_exception(error) + else: + future.set_result(result or []) + + def run() -> None: + try: + result, error = read_cookies_for(profile_dir), None + except Exception as exc: # noqa: BLE001 - handed to the waiting coroutine + result, error = None, exc + try: + loop.call_soon_threadsafe(settle, result, error) + except RuntimeError: # the loop closed while the read was stuck + pass + + threading.Thread(target=run, name="gflow-cookie-read", daemon=True).start() + return await asyncio.wait_for(future, _TOTAL_S) + + async def read_credits(profile_dir: Path) -> CreditsInfo: """The balance over the wire, or :class:`WireReadUnavailable`. Never starts a browser.""" require_in_home(profile_dir) # before anything reads the profile (row 19) @@ -245,7 +280,7 @@ async def read_credits(profile_dir: Path) -> CreditsInfo: raise WireReadUnavailable(WireFallbackReason.NO_CHROME_VERSION) started = time.perf_counter() try: - cookies = await asyncio.wait_for(asyncio.to_thread(read_cookies_for, profile_dir), _TOTAL_S) + cookies = await _read_cookies(profile_dir) except (CookieStoreUnreadable, TimeoutError): raise WireReadUnavailable(WireFallbackReason.STORE_UNREADABLE) from None if not cookies: @@ -283,3 +318,48 @@ async def read_credits(profile_dir: Path) -> CreditsInfo: raise WireReadUnavailable(WireFallbackReason.UNPARSEABLE) from None log.info("credits.wire_read", ms=round((time.perf_counter() - started) * 1000)) return info + + +def session_recovered(profile_dir: Path) -> None: + """A renew reported the profile renewed (its saved cookies changed): lift the signed-out + mark and the cached ``at`` so the next read tries the wire once more. A page read is not + enough (the cookies the wire sends are the same), and a throttle window is time-based.""" + _signed_out.pop(profile_dir, None) + _tokens.pop(profile_dir, None) + + +async def health(profile_dir: Path, settings: Settings | None = None) -> dict[str, Any]: + """The direct read's health for ``gflow doctor`` and ``gflow_auth_status`` (G6). + + An explicit question, so it asks Flow afresh: one shell GET and one ``nzlxg`` POST, under + every rule ``read_credits`` keeps, plus the kill switch / macOS default and the proxy rule + from ``settings`` (the caller's, else the global ones). A profile already marked signed out + is asked again; a throttle window is honoured and sends nothing. Returns only ``status`` + (``accepted``, ``throttled``, or a :class:`WireFallbackReason` value), ``build`` (Flow's + build label, when read) and, when throttled, ``retry_after`` in seconds; never a cookie, + token, id or URL. Raises only ``SecurityError`` (a profile outside GFLOW_CLI_HOME). + """ + settings = settings or get_settings() + if not settings.wire_reads: + return {"status": WireFallbackReason.DISABLED.value, "build": None} + if settings.browser_proxy: + return {"status": WireFallbackReason.PROXY_CONFIGURED.value, "build": None} + require_in_home(profile_dir) + _signed_out.pop(profile_dir, None) # re-ask; a still-signed-out shell marks it again + _tokens.pop(profile_dir, None) # a fresh shell GET, so `build` is current + try: + await read_credits(profile_dir) + except WireReadUnavailable as exc: + return {"status": exc.reason.value, "build": None} + except (RateLimitError, NetworkError): # only ever the throttle window here + window = _throttled.get(profile_dir) + retry = max(0, round(window[0] - _clock())) if window else None + return {"status": "throttled", "build": None, "retry_after": retry} + except SecurityError: + raise + except Exception as exc: # noqa: BLE001 - a health probe reports and never fails a caller + log.warning("wire.health_unexpected", error_type=type(exc).__name__) + return {"status": WireFallbackReason.UNEXPECTED.value, "build": None} + tokens = _tokens.get(profile_dir) + bl = tokens.bl if tokens else None + return {"status": "accepted", "build": bl if bl and _BUILD.fullmatch(bl) else None} diff --git a/src/gflow_cli/cli_doctor.py b/src/gflow_cli/cli_doctor.py index 12bdf2662..8415cb574 100644 --- a/src/gflow_cli/cli_doctor.py +++ b/src/gflow_cli/cli_doctor.py @@ -1,4 +1,7 @@ -"""``gflow doctor`` — read-only diagnostics over the catalog, DB, and env (#542). +"""``gflow doctor`` — read-only diagnostics over the catalog, DB, env, and auth (#542). + +One check (``auth.wire_read``, #1229) sends one GET and one POST to +flow.google.com for the default profile, unless the direct read is disabled. Rendering only: every finding string arrives pre-redacted from :mod:`gflow_cli.services.doctor` (UUIDs not display names, safe paths, @@ -74,7 +77,9 @@ def _render_text(report: DoctorReport) -> None: def doctor(ctx: click.Context, as_json: bool) -> None: """Run read-only health checks over the catalog, database, and environment. - Diagnoses, never heals: nothing is migrated, repaired, or written. + Diagnoses, never heals: nothing is migrated, repaired, or written. Unless + disabled (GFLOW_CLI_WIRE_READS=0), it also sends one GET and one POST to + flow.google.com to check the default profile's direct credits read. Exit code 0 when clean, 33 when any warn/fail finding is reported. """ report = run_all(_db_path(), get_settings()) diff --git a/src/gflow_cli/mcp/resources.py b/src/gflow_cli/mcp/resources.py index ffffe2055..176624824 100644 --- a/src/gflow_cli/mcp/resources.py +++ b/src/gflow_cli/mcp/resources.py @@ -93,7 +93,8 @@ async def mcp_guide() -> str: ### gflow_auth_status Credit-free, non-interactive Flow session probe — call it before a generate -tool to fail fast on expired auth. Login itself is CLI-only. +tool to fail fast on expired auth. Its `wire` field says whether credit reads +work without a browser (`accepted`) or why not. Login itself is CLI-only. ### gflow_list_projects Browse the local project catalog. diff --git a/src/gflow_cli/mcp/tools.py b/src/gflow_cli/mcp/tools.py index b85c11122..ad3511fd0 100644 --- a/src/gflow_cli/mcp/tools.py +++ b/src/gflow_cli/mcp/tools.py @@ -37,7 +37,7 @@ from gflow_cli import auth as auth_mod from gflow_cli import telemetry, telemetry_upload from gflow_cli._cli_helpers import _FLOW_ID_RE, _exit_code_for, default_project # pyright: ignore[reportPrivateUsage] -from gflow_cli.api import routes +from gflow_cli.api import flow_wire, routes from gflow_cli.api.client import FlowApiClient from gflow_cli.api.image import AgentInstruction from gflow_cli.api.image_upscale import TargetResolution @@ -2119,7 +2119,19 @@ async def gflow_usage_stats(weeks: int = 12) -> dict[str, Any]: "BEFORE a generation tool to fail fast on expired auth, before a long " "blocking generation call. Never starts an interactive login flow. May boot a " "short-lived headless browser, which on flow.google.com is the usual path, and " - "may take up to ~2 min on a slow network." + "may take up to ~2 min on a slow network. Every session result also carries " + "`wire: {status, build}`, the health of the direct (browser-free) credits read, " + "probed first with one GET and one POST to flow.google.com unless the direct read " + "is off. `wire.status` is `accepted` (the saved cookies work), `signed_out_shell` or " + "`http_401` (Flow refused the saved session), `store_unreadable` (the profile's " + "cookies could not be read: Chrome has it open, or it has no saved session), " + "`disabled` (GFLOW_CLI_WIRE_READS=0 or the macOS default), `proxy_configured`, " + "`throttled`, `no_chrome_version`, `network`, `redirect`, `http_400`, `http_error`, " + "`unparseable` or `unexpected`. With `accepted`, credit reads need no browser; any " + "other status means they use the browser instead and does not change `status`, " + "except `throttled`: Flow is rate-limiting the account, so the browser probe is " + "skipped and the result is `status: throttled` with a retryable 429 `error` " + "carrying `retry_after` seconds. `build` is Flow's build label when read, else null." ), ) @_guarded @@ -2134,20 +2146,43 @@ async def gflow_auth_status(profile: str = _DEFAULT_PROFILE) -> dict[str, Any]: profile: gflow-cli profile name (``"default"`` auto-resolves like the CLI). Returns: - ``{"status": "authenticated", "profile", "user_email"}`` on success; + ``{"status": "authenticated", "profile", "user_email", "wire"}`` on success; otherwise ``{"status": , "profile", "error": {...problem - details with remediation_hint...}}``. + details with remediation_hint...}, "wire"}``. ``wire`` is + :func:`gflow_cli.api.flow_wire.health` (status enum + build label). A + throttled wire skips the browser probe: ``{"status": "throttled", ...}``. """ resolved = _resolve_and_validate_profile(profile) if isinstance(resolved, dict): return resolved log.info("mcp.tool.auth_status", profile=resolved) + # The wire first: a browser probe holding the profile would lock the cookie jar (#1229). + wire = await flow_wire.health(auth_mod.profile_dir(resolved)) + if wire["status"] == "throttled": + # The browser probe would send more requests to an account Flow is rate-limiting. + from gflow_cli.errors import RateLimitError + + retry_after = wire.get("retry_after") + throttled = RateLimitError( + "Flow is throttling this account", + status=429, + route="auth_status", + retry_after=retry_after, + remediation_hint="Wait, then call gflow_auth_status again; re-login does not help.", + ) + return { + "status": "throttled", + "profile": resolved, + "error": {**_gflow_error_dict(throttled), "retry_after": retry_after}, + "wire": wire, + } status = await verification.verify_flow_profile(auth_mod.profile_dir(resolved), source="mcp") if status.authenticated: return { "status": "authenticated", "profile": resolved, "user_email": status.user_email, + "wire": wire, } if status.outcome is verification.FlowSessionOutcome.PROFILE_MARKER_MISSING: # #796: a local profile-state fault. Not retryable (the marker will not @@ -2217,6 +2252,7 @@ async def gflow_auth_status(profile: str = _DEFAULT_PROFILE) -> dict[str, Any]: "status": status.outcome.value, "profile": resolved, "error": error, + "wire": wire, } diff --git a/src/gflow_cli/services/credits.py b/src/gflow_cli/services/credits.py index 7f1cbdad2..8dcfd4ab3 100644 --- a/src/gflow_cli/services/credits.py +++ b/src/gflow_cli/services/credits.py @@ -117,6 +117,8 @@ async def _fetch(meta: profile_store.ProfileMeta, *, refresh: bool = False) -> d labs_stale=result.labs_stale, ) raise expired + # Flow verified the session (labs is irrelevant to the direct read): one more wire try. + flow_wire.session_recovered(meta.profile_dir) return await _fetch_once(meta) diff --git a/src/gflow_cli/services/doctor.py b/src/gflow_cli/services/doctor.py index c042beb9f..d4e95bc3d 100644 --- a/src/gflow_cli/services/doctor.py +++ b/src/gflow_cli/services/doctor.py @@ -2,8 +2,11 @@ Brew-doctor philosophy: diagnose, never heal. Every check inspects local state (catalog DB, environment, auth files) and reports a :class:`Finding`; -nothing is migrated, repaired, or written. All DB access goes through -:meth:`DataStore.open_readonly`, and every check runs inside a guard that +nothing is migrated, repaired, or written. One check is not local: +``auth.wire_read`` sends one GET and one POST to flow.google.com for the +default profile (``flow_wire.health``), unless the direct read is disabled. +All DB access goes through :meth:`DataStore.open_readonly`, and every check +runs inside a guard that converts unexpected exceptions into a "fail" finding — doctor must never crash on a damaged database. @@ -26,6 +29,7 @@ from __future__ import annotations +import asyncio import os import re from collections import Counter @@ -39,6 +43,7 @@ # _cli_helpers is imported as a module (not `from ... import safe_path_text`) # so tests patching gflow_cli._cli_helpers.safe_path_text reach this layer. from gflow_cli import _cli_helpers, browser_manager, profile_store +from gflow_cli.api import flow_wire from gflow_cli.data.store import DataStore, inspect_schema from gflow_cli.errors import DataStoreError @@ -65,8 +70,10 @@ "env.browsers_missing", "env.browser_proxy", "auth.files_present", + "auth.wire_read", ) +_WIRE_READ = "auth.wire_read" _STUCK_THRESHOLD = timedelta(hours=24) _CONTROL_CHARS = re.compile(r"[\x00-\x1f\x7f-\x9f]") @@ -347,9 +354,9 @@ def _stuck_ids(rows: Iterable[sqlite3.Row], id_col: str, ts_col: str) -> list[st now = datetime.now(UTC) stuck: list[str] = [] for row in rows: - ts = _parse_ts(row[ts_col]) + ts = _parse_ts(row[ts_col]) # NOSONAR - sqlite3.Row takes a column name if ts is not None and now - ts > _STUCK_THRESHOLD: - stuck.append(str(row[id_col])) + stuck.append(str(row[id_col])) # NOSONAR - sqlite3.Row takes a column name return stuck @@ -480,6 +487,46 @@ def _check_auth_files_present() -> list[Finding]: return [] +_WIRE_HINTS = { + "signed_out_shell": "Flow did not accept the saved session; check it with: gflow auth status", + "http_401": "Flow did not accept the saved session; check it with: gflow auth status", + "store_unreadable": "the profile's cookies could not be read - Chrome has it open, or it" + " has no saved session; close Chrome on it, or run: gflow auth login", + "disabled": "the direct read is off (GFLOW_CLI_WIRE_READS=0, or the macOS default);" + " credits read through the browser", + "proxy_configured": "the direct read is skipped while GFLOW_CLI_BROWSER_PROXY is set;" + " credits read through the browser", + "throttled": "Flow is throttling this account; wait a minute, then re-run: gflow doctor", +} + + +def _check_wire_read(settings: Settings) -> list[Finding]: + """The direct credit read's health for the default profile (G6, #1229). + + Only ``accepted`` passes; every other status is ``info``, never flipping exit 33: the + browser read still answers in each, and a state that follows from Chrome being open, the + network, or a deliberate setting is not a defect. Status and build label only. + """ + hint = "" + try: + name = profile_store.resolve_profile(None) + except profile_store.NoProfilesError: + status, build, hint = "no_profile", None, "create one with: gflow auth login" + except profile_store.NoDefaultProfileError: + status, build, hint = "no_profile", None, "set a default with: gflow auth use " + else: + result = asyncio.run(flow_wire.health(settings.profile_subdir(name), settings)) + status, build = str(result["status"]), result["build"] + summary = f"status={status} build={build or 'unknown'} (direct credits read, default profile)" + if status == "accepted": + return [_finding(_WIRE_READ, "pass", summary)] + hint = hint or _WIRE_HINTS.get( + status, + "credits fall back to the browser read; for details re-run with GFLOW_CLI_LOG_LEVEL=DEBUG", + ) + return [_finding(_WIRE_READ, "info", summary, hint)] + + # --------------------------------------------------------------------------- # Orchestration # --------------------------------------------------------------------------- @@ -537,6 +584,7 @@ def run_all(db_path: Path, settings: Settings) -> DoctorReport: ("env.browsers_missing", _check_browsers_missing), ("env.browser_proxy", lambda: _check_browser_proxy(settings)), ("auth.files_present", _check_auth_files_present), + (_WIRE_READ, lambda: _check_wire_read(settings)), ) findings: list[Finding] = [] for check_id, fn in checks: diff --git a/tests/api/test_flow_wire.py b/tests/api/test_flow_wire.py index be8670a77..b050eafdc 100644 --- a/tests/api/test_flow_wire.py +++ b/tests/api/test_flow_wire.py @@ -186,8 +186,9 @@ async def test_signed_out_shell_falls_back_and_disables_the_wire( with pytest.raises(WireReadUnavailable) as second: await flow_wire.read_credits(profile) - assert _reason(second) is WireFallbackReason.DISABLED - assert len(flow.gets) == 1 # the disabled wire sends nothing + # The stored reason, never DISABLED: that one means the kill switch (review round, #1238). + assert _reason(second) is WireFallbackReason.SIGNED_OUT_SHELL + assert len(flow.gets) == 1 # the signed-out wire sends nothing async def test_http_401_falls_back_without_retry_and_disables_the_wire( @@ -202,7 +203,7 @@ async def test_http_401_falls_back_without_retry_and_disables_the_wire( with pytest.raises(WireReadUnavailable) as again: await flow_wire.read_credits(profile) - assert _reason(again) is WireFallbackReason.DISABLED + assert _reason(again) is WireFallbackReason.HTTP_401 # not relabelled signed_out_shell assert len(flow.posts) == 1 @@ -538,7 +539,7 @@ async def test_concurrent_reads_stop_after_a_signed_out_shell(profile: Path, flo assert len(flow.gets) == 1 later = [r for r in results[1:] if isinstance(r, WireReadUnavailable)] - assert [r.reason for r in later] == [WireFallbackReason.DISABLED] * 4 + assert [r.reason for r in later] == [WireFallbackReason.SIGNED_OUT_SHELL] * 4 async def test_concurrent_reads_during_a_429_all_raise_the_typed_error( @@ -569,7 +570,7 @@ async def test_a_signed_out_profile_does_not_turn_the_wire_off_for_another( with pytest.raises(WireReadUnavailable) as again: # the signed-out one stays off await flow_wire.read_credits(profile) - assert _reason(again) is WireFallbackReason.DISABLED + assert _reason(again) is WireFallbackReason.SIGNED_OUT_SHELL async def test_five_profiles_read_concurrently_each_on_its_own_lock( @@ -724,3 +725,278 @@ def stuck(_p: Path) -> list[tuple[str, str]]: with pytest.raises(WireReadUnavailable) as exc: await flow_wire.read_credits(profile) assert _reason(exc) is WireFallbackReason.STORE_UNREADABLE + + +# --- wire health for `gflow doctor` and `gflow_auth_status` (G6, SCENARIO row 25) ------------ + + +@pytest.fixture +def wire_on(monkeypatch: pytest.MonkeyPatch) -> None: + """tests/conftest.py turns the wire off for the suite; the health probe honours that.""" + from gflow_cli.config import reset_settings + + monkeypatch.setenv("GFLOW_CLI_WIRE_READS", "1") + reset_settings() + + +async def test_health_of_an_accepted_session_is_one_read_and_names_the_build( + profile: Path, flow: _Flow, wire_on: None +) -> None: + assert await flow_wire.health(profile) == { + "status": "accepted", + "build": "boq_labs-flow-frontend_20261110.00_p0", + } + assert (len(flow.gets), len(flow.posts)) == (1, 1) + + +async def test_health_asks_flow_again_for_a_profile_marked_signed_out( + profile: Path, flow: _Flow, wire_on: None +) -> None: + """An explicit question: the in-process mark is re-checked with one GET, never echoed.""" + flow.shell = lambda _r: httpx.Response(200, text=_SIGNED_OUT_SHELL) + with pytest.raises(WireReadUnavailable): + await flow_wire.read_credits(profile) # marks it + + assert await flow_wire.health(profile) == {"status": "signed_out_shell", "build": None} + assert (len(flow.gets), len(flow.posts)) == (2, 0) + + flow.shell = lambda _r: httpx.Response(200, text=_SHELL) # signed back in elsewhere + assert (await flow_wire.health(profile))["status"] == "accepted" + + +async def test_health_reports_a_401_as_http_401_on_every_ask( + profile: Path, flow: _Flow, wire_on: None +) -> None: + flow.rpc = lambda _r: httpx.Response(401, text="") + + first = await flow_wire.health(profile) + second = await flow_wire.health(profile) + + assert first == second == {"status": "http_401", "build": None} + + +async def test_health_always_reads_a_fresh_shell_so_the_build_is_current( + profile: Path, flow: _Flow, wire_on: None +) -> None: + await flow_wire.read_credits(profile) # caches `at` and the old build + flow.shell = lambda _r: httpx.Response( + 200, text=_SHELL.replace("20261110.00_p0", "20261111.00_p0") + ) + + result = await flow_wire.health(profile) + + assert result == {"status": "accepted", "build": "boq_labs-flow-frontend_20261111.00_p0"} + assert len(flow.gets) == 2 + + +async def test_health_honours_the_settings_it_is_given( + profile: Path, flow: _Flow, wire_on: None, monkeypatch: pytest.MonkeyPatch +) -> None: + """doctor passes its own Settings; the global ones say the wire is on.""" + from gflow_cli.config import get_settings + + reads: list[Path] = [] + monkeypatch.setattr(flow_wire, "read_cookies_for", lambda p: reads.append(p) or []) + off = get_settings().model_copy(update={"wire_reads": False}) + proxied = get_settings().model_copy(update={"browser_proxy": "http://127.0.0.1:3128"}) + + assert (await flow_wire.health(profile, off))["status"] == "disabled" + assert (await flow_wire.health(profile, proxied))["status"] == "proxy_configured" + assert reads == [] and flow.gets == [] + + +async def test_health_reports_a_system_or_env_proxy_like_the_read_path( + profile: Path, flow: _Flow, wire_on: None, monkeypatch: pytest.MonkeyPatch +) -> None: + """#1236 skips the read for ANY configured proxy; health must say so, sending nothing.""" + reads: list[Path] = [] + monkeypatch.setattr(flow_wire, "read_cookies_for", lambda p: reads.append(p) or []) + monkeypatch.setattr(flow_wire, "getproxies", lambda: {"https": "http://127.0.0.1:3128"}) + + assert await flow_wire.health(profile) == {"status": "proxy_configured", "build": None} + assert reads == [] and flow.gets == [] + + +async def test_health_raises_the_security_refusal_for_a_profile_outside_home( + tmp_path: Path, flow: _Flow, wire_on: None +) -> None: + """Like the credits path: a refusal, not an `unexpected` status with a warning.""" + from gflow_cli.errors import SecurityError + + outside = tmp_path / "elsewhere" + outside.mkdir() + + with pytest.raises(SecurityError): + await flow_wire.health(outside) + assert flow.gets == [] + + +async def test_health_reports_an_unreadable_jar( + profile: Path, flow: _Flow, wire_on: None, monkeypatch: pytest.MonkeyPatch +) -> None: + def locked(_p: Path) -> list[tuple[str, str]]: + raise CookieStoreUnreadable("locked") + + monkeypatch.setattr(flow_wire, "read_cookies_for", locked) + + assert await flow_wire.health(profile) == {"status": "store_unreadable", "build": None} + assert flow.gets == [] + + +async def test_health_reports_throttling_and_then_sends_nothing( + profile: Path, flow: _Flow, wire_on: None +) -> None: + flow.rpc = lambda _r: httpx.Response(429, headers={"retry-after": "120"}) + + assert await flow_wire.health(profile) == { + "status": "throttled", + "build": None, + "retry_after": 60, # Retry-After: 120, capped by _retry.parse_retry_after + } + sent = (len(flow.gets), len(flow.posts)) + assert (await flow_wire.health(profile))["status"] == "throttled" + assert (len(flow.gets), len(flow.posts)) == sent + + +@pytest.mark.parametrize("reason", list(WireFallbackReason)) +async def test_health_passes_every_fallback_reason_through( + profile: Path, wire_on: None, monkeypatch: pytest.MonkeyPatch, reason: WireFallbackReason +) -> None: + async def fails(_p: Path) -> object: + raise WireReadUnavailable(reason) + + monkeypatch.setattr(flow_wire, "read_credits", fails) + + assert await flow_wire.health(profile) == {"status": reason.value, "build": None} + + +async def test_health_turns_a_wire_bug_into_unexpected( + profile: Path, wire_on: None, monkeypatch: pytest.MonkeyPatch +) -> None: + async def boom(_p: Path) -> object: + raise RuntimeError("SID=fake-sid-value") + + monkeypatch.setattr(flow_wire, "read_credits", boom) + + assert await flow_wire.health(profile) == {"status": "unexpected", "build": None} + + +@pytest.mark.parametrize( + ("env", "platform", "status"), + [ + ({"GFLOW_CLI_WIRE_READS": "0"}, "win32", "disabled"), + ({}, "darwin", "disabled"), + ( + {"GFLOW_CLI_WIRE_READS": "1", "GFLOW_CLI_BROWSER_PROXY": "http://127.0.0.1:3128"}, + "win32", + "proxy_configured", + ), + ], +) +async def test_health_sends_nothing_when_the_wire_must_not_run( + profile: Path, + flow: _Flow, + monkeypatch: pytest.MonkeyPatch, + env: dict[str, str], + platform: str, + status: str, +) -> None: + import sys + + from gflow_cli.config import reset_settings + + reads: list[Path] = [] + monkeypatch.setattr(flow_wire, "read_cookies_for", lambda p: reads.append(p) or []) + monkeypatch.delenv("GFLOW_CLI_WIRE_READS", raising=False) + for name, value in env.items(): + monkeypatch.setenv(name, value) + monkeypatch.setattr(sys, "platform", platform) + reset_settings() + + assert await flow_wire.health(profile) == {"status": status, "build": None} + assert reads == [] and flow.gets == [] and flow.posts == [] + + +@pytest.mark.parametrize("label", ["boq_x