From 31fcdf0a32f4696bdabd5dfe563cca1b3a60f425 Mon Sep 17 00:00:00 2001 From: Flavio Oliva Date: Tue, 10 Nov 2026 14:49:05 +0000 Subject: [PATCH 1/4] feat(doctor): report direct-read health in doctor and auth status flow_wire.health() is one wire credits read under every read_credits rule, plus the kill switch and macOS default; it returns only a status enum and the Flow build label. gflow doctor reports it for the default profile as auth.wire_read (info unless accepted, so exit 33 never depends on it), and gflow_auth_status carries it as a wire field, probed before the browser. Refs #1229 --- CHANGELOG.md | 7 ++ docs/CONFIGURATION.md | 1 + docs/MCP.md | 2 +- src/gflow_cli/api/flow_wire.py | 33 ++++++ src/gflow_cli/cli_doctor.py | 9 +- src/gflow_cli/mcp/resources.py | 3 +- src/gflow_cli/mcp/tools.py | 23 +++- src/gflow_cli/services/doctor.py | 48 ++++++++- tests/api/test_flow_wire.py | 163 +++++++++++++++++++++++++++++ tests/e2e/test_wire_health_e2e.py | 66 ++++++++++++ tests/fixtures/doctor_env.py | 8 ++ tests/mcp/test_auth_status_tool.py | 1 + tests/mcp/test_auth_status_wire.py | 61 +++++++++++ tests/services/test_doctor_wire.py | 110 +++++++++++++++++++ website/docs/CONFIGURATION.md | 1 + website/docs/MCP.md | 2 +- 16 files changed, 527 insertions(+), 11 deletions(-) create mode 100644 tests/e2e/test_wire_health_e2e.py create mode 100644 tests/mcp/test_auth_status_wire.py create mode 100644 tests/services/test_doctor_wire.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d7726298..ae2649aa4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,13 @@ 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 `GFLOW_CLI_BROWSER_PROXY` is set (#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 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 makes one read-only request to flow.google.com unless the direct read is off. The check + is informational: it never changes doctor's exit code (#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 f111e80c8..c806a7edc 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -517,6 +517,7 @@ per-command `--proxy` flags (the setting covers one-off runs: **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. **Proxy:** while `GFLOW_CLI_BROWSER_PROXY` is set the direct read is skipped (reason `proxy_configured`), so the profile's cookies never leave from a different IP address than its browser's. +**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 makes one direct read, under every rule above; with the setting off, or a proxy set, neither sends anything. ### `GFLOW_CLI_AGENT_CONFIRM` diff --git a/docs/MCP.md b/docs/MCP.md index 41f65966f..3c653ec60 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 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 read-only request 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`. `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/src/gflow_cli/api/flow_wire.py b/src/gflow_cli/api/flow_wire.py index 9c8c885e6..285093421 100644 --- a/src/gflow_cli/api/flow_wire.py +++ b/src/gflow_cli/api/flow_wire.py @@ -50,6 +50,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 = { @@ -271,3 +272,35 @@ 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 + + +async def health(profile_dir: Path) -> dict[str, str | None]: + """The direct read's health for ``gflow doctor`` and ``gflow_auth_status`` (G6). + + The probe IS one credits read (one GET, one POST), under every rule ``read_credits`` + keeps, plus the kill switch / macOS default it leaves to its caller. Returns only an + enum ``status`` (``accepted``, ``throttled``, or a :class:`WireFallbackReason` value) and + the Flow build label when one was read; never a cookie, token, id or URL. Never raises. + """ + status: str + if not get_settings().wire_reads: + status = WireFallbackReason.DISABLED.value + elif profile_dir in _signed_out: + # Flow already refused this session in this process; _gate would say DISABLED, + # which reads as the kill switch. shortcut: a 401 also lands here; split if needed. + status = WireFallbackReason.SIGNED_OUT_SHELL.value + else: + try: + await read_credits(profile_dir) + except WireReadUnavailable as exc: + status = exc.reason.value + except (RateLimitError, NetworkError): # only ever the throttle window here + status = "throttled" + except Exception as exc: # noqa: BLE001 - a health probe reports, it never fails a caller + log.warning("wire.health_unexpected", error_type=type(exc).__name__) + status = WireFallbackReason.UNEXPECTED.value + else: + 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} + return {"status": status, "build": None} diff --git a/src/gflow_cli/cli_doctor.py b/src/gflow_cli/cli_doctor.py index 12bdf2662..0c646e9ca 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) makes one read-only credits request 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 makes one read-only request 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..c06e471e9 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,17 @@ 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 result also carries " + "`wire: {status, build}`, the health of the direct (browser-free) credits read, " + "probed first with one read-only request to flow.google.com unless the direct read " + "is off. `status` is `accepted` (the saved cookies work), `signed_out_shell` or " + "`http_401` (Flow refused the saved session), `store_unreadable` (the cookie store " + "is locked, usually while Chrome has the profile open), `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 none of them changes `status` above. " + "`build` is Flow's build label when read, else null." ), ) @_guarded @@ -2134,20 +2144,24 @@ 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). """ 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)) 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 +2231,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/doctor.py b/src/gflow_cli/services/doctor.py index c042beb9f..0447aad5b 100644 --- a/src/gflow_cli/services/doctor.py +++ b/src/gflow_cli/services/doctor.py @@ -2,8 +2,10 @@ 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`` makes one read-only credits request 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 +28,7 @@ from __future__ import annotations +import asyncio import os import re from collections import Counter @@ -39,6 +42,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,6 +69,7 @@ "env.browsers_missing", "env.browser_proxy", "auth.files_present", + "auth.wire_read", ) _STUCK_THRESHOLD = timedelta(hours=24) @@ -480,6 +485,44 @@ 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 cookie store is locked while Chrome has this profile open;" + " close that Chrome window and re-run: gflow doctor", + "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", + "no_profile": "set a default profile with: gflow auth use ", +} + + +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. + """ + try: + name = profile_store.resolve_profile(None) + except (profile_store.NoProfilesError, profile_store.NoDefaultProfileError): + status, build = "no_profile", None + else: + result = asyncio.run(flow_wire.health(settings.profile_subdir(name))) + status, build = result["status"] or "unexpected", result["build"] + summary = f"status={status} build={build or 'unknown'} (direct credits read, default profile)" + if status == "accepted": + return [_finding("auth.wire_read", "pass", summary)] + hint = _WIRE_HINTS.get( + status, + "credits fall back to the browser read; for details re-run with GFLOW_CLI_LOG_LEVEL=DEBUG", + ) + return [_finding("auth.wire_read", "info", summary, hint)] + + # --------------------------------------------------------------------------- # Orchestration # --------------------------------------------------------------------------- @@ -537,6 +580,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), + ("auth.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 2bd6ee155..b6c80ba71 100644 --- a/tests/api/test_flow_wire.py +++ b/tests/api/test_flow_wire.py @@ -680,3 +680,166 @@ 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_reports_a_signed_out_shell_and_asks_only_once( + profile: Path, flow: _Flow, wire_on: None +) -> None: + flow.shell = lambda _r: httpx.Response(200, text=_SIGNED_OUT_SHELL) + + first = await flow_wire.health(profile) + second = await flow_wire.health(profile) # a long-lived MCP server asks again + + assert first == second == {"status": "signed_out_shell", "build": None} + assert (len(flow.gets), len(flow.posts)) == (1, 0) + + +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" + 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